nautilus_trading/
sessions.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides utilities for determining Forex session times.
17//! Includes functions to convert UTC times to session local times
18//! and retrieve the next or previous session start/end.
19//!
20//! All FX sessions run Monday to Friday local time:
21//!
22//! - Sydney Session    0700-1600 (Australia / Sydney)
23//! - Tokyo Session     0900-1800 (Asia / Tokyo)
24//! - London Session    0800-1600 (Europe / London)
25//! - New York Session  0800-1700 (America / New York)
26
27use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Timelike, Utc};
28use chrono_tz::{America::New_York, Asia::Tokyo, Australia::Sydney, Europe::London, Tz};
29use strum::{Display, EnumIter, EnumString, FromRepr};
30
31/// Represents a major Forex market session based on trading hours.
32#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromRepr, EnumIter, EnumString, Display)]
33#[strum(ascii_case_insensitive)]
34#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(eq, eq_int, module = "nautilus_trader.core.nautilus_pyo3.common.enums")
38)]
39pub enum ForexSession {
40    Sydney,
41    Tokyo,
42    London,
43    NewYork,
44}
45
46impl ForexSession {
47    /// Returns the timezone associated with the session.
48    const fn timezone(&self) -> Tz {
49        match self {
50            Self::Sydney => Sydney,
51            Self::Tokyo => Tokyo,
52            Self::London => London,
53            Self::NewYork => New_York,
54        }
55    }
56
57    /// Returns the start and end times for the session in local time.
58    const fn session_times(&self) -> (NaiveTime, NaiveTime) {
59        match self {
60            Self::Sydney => (
61                NaiveTime::from_hms_opt(7, 0, 0).unwrap(),
62                NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
63            ),
64            Self::Tokyo => (
65                NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
66                NaiveTime::from_hms_opt(18, 0, 0).unwrap(),
67            ),
68            Self::London => (
69                NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
70                NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
71            ),
72            Self::NewYork => (
73                NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
74                NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
75            ),
76        }
77    }
78}
79
80/// Converts a UTC timestamp to the local time for the given Forex session.
81#[must_use]
82pub fn fx_local_from_utc(session: ForexSession, time_now: DateTime<Utc>) -> DateTime<Tz> {
83    session.timezone().from_utc_datetime(&time_now.naive_utc())
84}
85
86/// Returns the next session start time in UTC.
87#[must_use]
88pub fn fx_next_start(session: ForexSession, time_now: DateTime<Utc>) -> DateTime<Utc> {
89    let timezone = session.timezone();
90    let local_now = fx_local_from_utc(session, time_now);
91    let (start_time, _) = session.session_times();
92
93    let mut next_start = timezone
94        .with_ymd_and_hms(
95            local_now.year(),
96            local_now.month(),
97            local_now.day(),
98            start_time.hour(),
99            start_time.minute(),
100            0,
101        )
102        .unwrap();
103
104    if local_now > next_start {
105        next_start += Duration::days(1);
106    }
107
108    if next_start.weekday().number_from_monday() > 5 {
109        next_start += Duration::days(8 - i64::from(next_start.weekday().number_from_monday()));
110    }
111
112    next_start.with_timezone(&Utc)
113}
114
115/// Returns the previous session start time in UTC.
116#[must_use]
117pub fn fx_prev_start(session: ForexSession, time_now: DateTime<Utc>) -> DateTime<Utc> {
118    let timezone = session.timezone();
119    let local_now = fx_local_from_utc(session, time_now);
120    let (start_time, _) = session.session_times();
121
122    let mut prev_start = timezone
123        .with_ymd_and_hms(
124            local_now.year(),
125            local_now.month(),
126            local_now.day(),
127            start_time.hour(),
128            start_time.minute(),
129            0,
130        )
131        .unwrap();
132
133    if local_now < prev_start {
134        prev_start -= Duration::days(1);
135    }
136
137    if prev_start.weekday().number_from_monday() > 5 {
138        prev_start -= Duration::days(i64::from(prev_start.weekday().number_from_monday()) - 5);
139    }
140
141    prev_start.with_timezone(&Utc)
142}
143
144/// Returns the next session end time in UTC.
145#[must_use]
146pub fn fx_next_end(session: ForexSession, time_now: DateTime<Utc>) -> DateTime<Utc> {
147    let timezone = session.timezone();
148    let local_now = fx_local_from_utc(session, time_now);
149    let (_, end_time) = session.session_times();
150
151    let mut next_end = timezone
152        .with_ymd_and_hms(
153            local_now.year(),
154            local_now.month(),
155            local_now.day(),
156            end_time.hour(),
157            end_time.minute(),
158            0,
159        )
160        .unwrap();
161
162    if local_now > next_end {
163        next_end += Duration::days(1);
164    }
165
166    if next_end.weekday().number_from_monday() > 5 {
167        next_end += Duration::days(8 - i64::from(next_end.weekday().number_from_monday()));
168    }
169
170    next_end.with_timezone(&Utc)
171}
172
173/// Returns the previous session end time in UTC.
174#[must_use]
175pub fn fx_prev_end(session: ForexSession, time_now: DateTime<Utc>) -> DateTime<Utc> {
176    let timezone = session.timezone();
177    let local_now = fx_local_from_utc(session, time_now);
178    let (_, end_time) = session.session_times();
179
180    let mut prev_end = timezone
181        .with_ymd_and_hms(
182            local_now.year(),
183            local_now.month(),
184            local_now.day(),
185            end_time.hour(),
186            end_time.minute(),
187            0,
188        )
189        .unwrap();
190
191    if local_now < prev_end {
192        prev_end -= Duration::days(1);
193    }
194
195    if prev_end.weekday().number_from_monday() > 5 {
196        prev_end -= Duration::days(i64::from(prev_end.weekday().number_from_monday()) - 5);
197    }
198
199    prev_end.with_timezone(&Utc)
200}
201
202#[cfg(test)]
203mod tests {
204    use rstest::rstest;
205
206    use super::*;
207
208    #[rstest]
209    #[case(ForexSession::Sydney, "1970-01-01T10:00:00+10:00")]
210    #[case(ForexSession::Tokyo, "1970-01-01T09:00:00+09:00")]
211    #[case(ForexSession::London, "1970-01-01T01:00:00+01:00")]
212    #[case(ForexSession::NewYork, "1969-12-31T19:00:00-05:00")]
213    pub fn test_fx_local_from_utc(#[case] session: ForexSession, #[case] expected: &str) {
214        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
215        let result = fx_local_from_utc(session, unix_epoch);
216        assert_eq!(result.to_rfc3339(), expected);
217    }
218
219    #[rstest]
220    #[case(ForexSession::Sydney, "1970-01-01T21:00:00+00:00")]
221    #[case(ForexSession::Tokyo, "1970-01-01T00:00:00+00:00")]
222    #[case(ForexSession::London, "1970-01-01T07:00:00+00:00")]
223    #[case(ForexSession::NewYork, "1970-01-01T13:00:00+00:00")]
224    pub fn test_fx_next_start(#[case] session: ForexSession, #[case] expected: &str) {
225        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
226        let result = fx_next_start(session, unix_epoch);
227        assert_eq!(result.to_rfc3339(), expected);
228    }
229
230    #[rstest]
231    #[case(ForexSession::Sydney, "1969-12-31T21:00:00+00:00")]
232    #[case(ForexSession::Tokyo, "1970-01-01T00:00:00+00:00")]
233    #[case(ForexSession::London, "1969-12-31T07:00:00+00:00")]
234    #[case(ForexSession::NewYork, "1969-12-31T13:00:00+00:00")]
235    pub fn test_fx_prev_start(#[case] session: ForexSession, #[case] expected: &str) {
236        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
237        let result = fx_prev_start(session, unix_epoch);
238        assert_eq!(result.to_rfc3339(), expected);
239    }
240
241    #[rstest]
242    #[case(ForexSession::Sydney, "1970-01-01T06:00:00+00:00")]
243    #[case(ForexSession::Tokyo, "1970-01-01T09:00:00+00:00")]
244    #[case(ForexSession::London, "1970-01-01T15:00:00+00:00")]
245    #[case(ForexSession::NewYork, "1970-01-01T22:00:00+00:00")]
246    pub fn test_fx_next_end(#[case] session: ForexSession, #[case] expected: &str) {
247        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
248        let result = fx_next_end(session, unix_epoch);
249        assert_eq!(result.to_rfc3339(), expected);
250    }
251
252    #[rstest]
253    #[case(ForexSession::Sydney, "1969-12-31T06:00:00+00:00")]
254    #[case(ForexSession::Tokyo, "1969-12-31T09:00:00+00:00")]
255    #[case(ForexSession::London, "1969-12-31T15:00:00+00:00")]
256    #[case(ForexSession::NewYork, "1969-12-31T22:00:00+00:00")]
257    pub fn test_fx_prev_end(#[case] session: ForexSession, #[case] expected: &str) {
258        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
259        let result = fx_prev_end(session, unix_epoch);
260        assert_eq!(result.to_rfc3339(), expected);
261    }
262
263    #[rstest]
264    pub fn test_fx_next_start_on_weekend() {
265        let sunday_utc = Utc.with_ymd_and_hms(2020, 7, 12, 9, 0, 0).unwrap(); // Sunday
266        let result = fx_next_start(ForexSession::Tokyo, sunday_utc);
267        let expected = Utc.with_ymd_and_hms(2020, 7, 13, 0, 0, 0).unwrap(); // Monday
268
269        assert_eq!(result, expected);
270    }
271
272    #[rstest]
273    pub fn test_fx_next_start_during_active_session() {
274        let during_session = Utc.with_ymd_and_hms(2020, 7, 13, 10, 0, 0).unwrap(); // Sydney session is active
275        let result = fx_next_start(ForexSession::Sydney, during_session);
276        let expected = Utc.with_ymd_and_hms(2020, 7, 13, 21, 0, 0).unwrap(); // Next Sydney session start
277
278        assert_eq!(result, expected);
279    }
280
281    #[rstest]
282    pub fn test_fx_prev_start_before_session() {
283        let before_session = Utc.with_ymd_and_hms(2020, 7, 13, 6, 0, 0).unwrap(); // Before Tokyo session start
284        let result = fx_prev_start(ForexSession::Tokyo, before_session);
285        let expected = Utc.with_ymd_and_hms(2020, 7, 13, 0, 0, 0).unwrap(); // Current Tokyo session start
286
287        assert_eq!(result, expected);
288    }
289
290    #[rstest]
291    pub fn test_fx_next_end_crossing_midnight() {
292        let late_night = Utc.with_ymd_and_hms(2020, 7, 13, 23, 0, 0).unwrap(); // After NY session ended
293        let result = fx_next_end(ForexSession::NewYork, late_night);
294        let expected = Utc.with_ymd_and_hms(2020, 7, 14, 21, 0, 0).unwrap(); // Next NY session end
295
296        assert_eq!(result, expected);
297    }
298
299    #[rstest]
300    pub fn test_fx_prev_end_after_session() {
301        let after_session = Utc.with_ymd_and_hms(2020, 7, 13, 17, 30, 0).unwrap(); // Just after NY session ended
302        let result = fx_prev_end(ForexSession::NewYork, after_session);
303        let expected = Utc.with_ymd_and_hms(2020, 7, 10, 21, 0, 0).unwrap(); // Previous NY session end
304
305        assert_eq!(result, expected);
306    }
307}