Skip to main content

nautilus_trading/
sessions.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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 std::sync::LazyLock;
28
29use jiff::{
30    Span, Timestamp, Zoned,
31    civil::{Time, Weekday},
32    tz::TimeZone,
33};
34use nautilus_core::datetime::get_timezone;
35use strum::{Display, EnumIter, EnumString, FromRepr};
36
37static SYDNEY_TIMEZONE: LazyLock<TimeZone> =
38    LazyLock::new(|| get_timezone("Australia/Sydney").expect("bundled Australia/Sydney timezone"));
39static TOKYO_TIMEZONE: LazyLock<TimeZone> =
40    LazyLock::new(|| get_timezone("Asia/Tokyo").expect("bundled Asia/Tokyo timezone"));
41static LONDON_TIMEZONE: LazyLock<TimeZone> =
42    LazyLock::new(|| get_timezone("Europe/London").expect("bundled Europe/London timezone"));
43static NEW_YORK_TIMEZONE: LazyLock<TimeZone> =
44    LazyLock::new(|| get_timezone("America/New_York").expect("bundled America/New_York timezone"));
45
46/// Represents a major Forex market session based on trading hours.
47#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromRepr, EnumIter, EnumString, Display)]
48#[strum(ascii_case_insensitive)]
49#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
50#[cfg_attr(
51    feature = "python",
52    pyo3::pyclass(
53        eq,
54        eq_int,
55        module = "nautilus_trader.trading",
56        from_py_object,
57        rename_all = "SCREAMING_SNAKE_CASE"
58    )
59)]
60#[cfg_attr(
61    feature = "python",
62    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.trading")
63)]
64pub enum ForexSession {
65    Sydney,
66    Tokyo,
67    London,
68    NewYork,
69}
70
71impl ForexSession {
72    /// Returns the timezone associated with the session.
73    fn timezone(self) -> &'static TimeZone {
74        match self {
75            Self::Sydney => &SYDNEY_TIMEZONE,
76            Self::Tokyo => &TOKYO_TIMEZONE,
77            Self::London => &LONDON_TIMEZONE,
78            Self::NewYork => &NEW_YORK_TIMEZONE,
79        }
80    }
81
82    /// Returns the start and end times for the session in local time.
83    const fn session_times(self) -> (Time, Time) {
84        match self {
85            Self::Sydney => (Time::constant(7, 0, 0, 0), Time::constant(16, 0, 0, 0)),
86            Self::Tokyo => (Time::constant(9, 0, 0, 0), Time::constant(18, 0, 0, 0)),
87            Self::London => (Time::constant(8, 0, 0, 0), Time::constant(16, 0, 0, 0)),
88            Self::NewYork => (Time::constant(8, 0, 0, 0), Time::constant(17, 0, 0, 0)),
89        }
90    }
91}
92
93/// Converts a UTC timestamp to the local time for the given Forex session.
94#[must_use]
95pub fn fx_local_from_utc(session: ForexSession, time_now: Timestamp) -> Zoned {
96    time_now.to_zoned(session.timezone().clone())
97}
98
99/// Returns the next session start time in UTC.
100#[must_use]
101pub fn fx_next_start(session: ForexSession, time_now: Timestamp) -> Timestamp {
102    let local_now = fx_local_from_utc(session, time_now);
103    let (start_time, _) = session.session_times();
104
105    fx_next_boundary(&local_now, start_time)
106}
107
108/// Returns the previous session start time in UTC.
109#[must_use]
110pub fn fx_prev_start(session: ForexSession, time_now: Timestamp) -> Timestamp {
111    let local_now = fx_local_from_utc(session, time_now);
112    let (start_time, _) = session.session_times();
113
114    fx_prev_boundary(&local_now, start_time)
115}
116
117/// Returns the next session end time in UTC.
118#[must_use]
119pub fn fx_next_end(session: ForexSession, time_now: Timestamp) -> Timestamp {
120    let local_now = fx_local_from_utc(session, time_now);
121    let (_, end_time) = session.session_times();
122
123    fx_next_boundary(&local_now, end_time)
124}
125
126/// Returns the previous session end time in UTC.
127#[must_use]
128pub fn fx_prev_end(session: ForexSession, time_now: Timestamp) -> Timestamp {
129    let local_now = fx_local_from_utc(session, time_now);
130    let (_, end_time) = session.session_times();
131
132    fx_prev_boundary(&local_now, end_time)
133}
134
135fn fx_next_boundary(local_now: &Zoned, session_time: Time) -> Timestamp {
136    let timezone = local_now.time_zone().clone();
137    let mut date = local_now.date();
138
139    if local_now.time() > session_time {
140        date = date
141            .checked_add(Span::new().days(1))
142            .expect("FX session date must be representable");
143    }
144
145    let weekend_days = match date.weekday() {
146        Weekday::Saturday => 2,
147        Weekday::Sunday => 1,
148        _ => 0,
149    };
150    date = date
151        .checked_add(Span::new().days(weekend_days))
152        .expect("FX session date must be representable");
153
154    timezone
155        .to_ambiguous_timestamp(date.to_datetime(session_time))
156        .unambiguous()
157        .expect("FX session boundary must be a unique local time")
158}
159
160fn fx_prev_boundary(local_now: &Zoned, session_time: Time) -> Timestamp {
161    let timezone = local_now.time_zone().clone();
162    let mut date = local_now.date();
163
164    if local_now.time() < session_time {
165        date = date
166            .checked_sub(Span::new().days(1))
167            .expect("FX session date must be representable");
168    }
169
170    let weekend_days = match date.weekday() {
171        Weekday::Saturday => 1,
172        Weekday::Sunday => 2,
173        _ => 0,
174    };
175    date = date
176        .checked_sub(Span::new().days(weekend_days))
177        .expect("FX session date must be representable");
178
179    timezone
180        .to_ambiguous_timestamp(date.to_datetime(session_time))
181        .unambiguous()
182        .expect("FX session boundary must be a unique local time")
183}
184
185#[cfg(test)]
186mod tests {
187    use jiff::{civil::Date, tz::Offset};
188    use rstest::rstest;
189
190    use super::*;
191
192    fn local_timestamp(
193        session: ForexSession,
194        year: i32,
195        month: u32,
196        day: u32,
197        hour: u32,
198    ) -> Timestamp {
199        let date = Date::new(
200            i16::try_from(year).unwrap(),
201            i8::try_from(month).unwrap(),
202            i8::try_from(day).unwrap(),
203        )
204        .unwrap();
205        let datetime = date.at(i8::try_from(hour).unwrap(), 0, 0, 0);
206
207        session
208            .timezone()
209            .to_ambiguous_timestamp(datetime)
210            .unambiguous()
211            .unwrap()
212    }
213
214    fn utc_timestamp(year: i32, month: i8, day: i8, hour: i8, minute: i8) -> Timestamp {
215        Offset::UTC
216            .to_timestamp(
217                Date::new(i16::try_from(year).unwrap(), month, day)
218                    .unwrap()
219                    .at(hour, minute, 0, 0),
220            )
221            .unwrap()
222    }
223
224    #[rstest]
225    #[case(ForexSession::Sydney, "1970-01-01T10:00:00+10:00")]
226    #[case(ForexSession::Tokyo, "1970-01-01T09:00:00+09:00")]
227    #[case(ForexSession::London, "1970-01-01T01:00:00+01:00")]
228    #[case(ForexSession::NewYork, "1969-12-31T19:00:00-05:00")]
229    pub fn test_fx_local_from_utc(#[case] session: ForexSession, #[case] expected: &str) {
230        let unix_epoch = Timestamp::UNIX_EPOCH;
231        let result = fx_local_from_utc(session, unix_epoch);
232        assert_eq!(
233            result.strftime("%Y-%m-%dT%H:%M:%S%:z").to_string(),
234            expected
235        );
236    }
237
238    #[rstest]
239    #[case(ForexSession::Sydney, "1970-01-01T21:00:00+00:00")]
240    #[case(ForexSession::Tokyo, "1970-01-01T00:00:00+00:00")]
241    #[case(ForexSession::London, "1970-01-01T07:00:00+00:00")]
242    #[case(ForexSession::NewYork, "1970-01-01T13:00:00+00:00")]
243    pub fn test_fx_next_start(#[case] session: ForexSession, #[case] expected: &str) {
244        let unix_epoch = Timestamp::UNIX_EPOCH;
245        let result = fx_next_start(session, unix_epoch);
246        assert_eq!(result, expected.parse::<Timestamp>().unwrap());
247    }
248
249    #[rstest]
250    #[case(ForexSession::Sydney, "1969-12-31T21:00:00+00:00")]
251    #[case(ForexSession::Tokyo, "1970-01-01T00:00:00+00:00")]
252    #[case(ForexSession::London, "1969-12-31T07:00:00+00:00")]
253    #[case(ForexSession::NewYork, "1969-12-31T13:00:00+00:00")]
254    pub fn test_fx_prev_start(#[case] session: ForexSession, #[case] expected: &str) {
255        let unix_epoch = Timestamp::UNIX_EPOCH;
256        let result = fx_prev_start(session, unix_epoch);
257        assert_eq!(result, expected.parse::<Timestamp>().unwrap());
258    }
259
260    #[rstest]
261    #[case(ForexSession::Sydney, "1970-01-01T06:00:00+00:00")]
262    #[case(ForexSession::Tokyo, "1970-01-01T09:00:00+00:00")]
263    #[case(ForexSession::London, "1970-01-01T15:00:00+00:00")]
264    #[case(ForexSession::NewYork, "1970-01-01T22:00:00+00:00")]
265    pub fn test_fx_next_end(#[case] session: ForexSession, #[case] expected: &str) {
266        let unix_epoch = Timestamp::UNIX_EPOCH;
267        let result = fx_next_end(session, unix_epoch);
268        assert_eq!(result, expected.parse::<Timestamp>().unwrap());
269    }
270
271    #[rstest]
272    #[case(ForexSession::Sydney, "1969-12-31T06:00:00+00:00")]
273    #[case(ForexSession::Tokyo, "1969-12-31T09:00:00+00:00")]
274    #[case(ForexSession::London, "1969-12-31T15:00:00+00:00")]
275    #[case(ForexSession::NewYork, "1969-12-31T22:00:00+00:00")]
276    pub fn test_fx_prev_end(#[case] session: ForexSession, #[case] expected: &str) {
277        let unix_epoch = Timestamp::UNIX_EPOCH;
278        let result = fx_prev_end(session, unix_epoch);
279        assert_eq!(result, expected.parse::<Timestamp>().unwrap());
280    }
281
282    #[rstest]
283    #[case(ForexSession::Sydney, (2024, 4, 5), (2024, 4, 8), 7)]
284    #[case(ForexSession::Sydney, (2024, 10, 4), (2024, 10, 7), 7)]
285    #[case(ForexSession::London, (2024, 3, 29), (2024, 4, 1), 8)]
286    #[case(ForexSession::London, (2024, 10, 25), (2024, 10, 28), 8)]
287    #[case(ForexSession::NewYork, (2024, 3, 8), (2024, 3, 11), 8)]
288    #[case(ForexSession::NewYork, (2024, 11, 1), (2024, 11, 4), 8)]
289    // Saturday input: advances to Sunday, then takes the Sunday weekend arm.
290    #[case(ForexSession::London, (2024, 3, 30), (2024, 4, 1), 8)]
291    fn test_fx_next_start_across_dst_weekend(
292        #[case] session: ForexSession,
293        #[case] input_date: (i32, u32, u32),
294        #[case] expected_date: (i32, u32, u32),
295        #[case] expected_hour: u32,
296    ) {
297        let (input_year, input_month, input_day) = input_date;
298        let (expected_year, expected_month, expected_day) = expected_date;
299        let time_now = local_timestamp(session, input_year, input_month, input_day, 18);
300        let expected = local_timestamp(
301            session,
302            expected_year,
303            expected_month,
304            expected_day,
305            expected_hour,
306        );
307
308        assert_eq!(fx_next_start(session, time_now), expected);
309    }
310
311    #[rstest]
312    #[case(ForexSession::Sydney, (2024, 4, 8), (2024, 4, 5), 7)]
313    #[case(ForexSession::Sydney, (2024, 10, 7), (2024, 10, 4), 7)]
314    #[case(ForexSession::London, (2024, 4, 1), (2024, 3, 29), 8)]
315    #[case(ForexSession::London, (2024, 10, 28), (2024, 10, 25), 8)]
316    #[case(ForexSession::NewYork, (2024, 3, 11), (2024, 3, 8), 8)]
317    #[case(ForexSession::NewYork, (2024, 11, 4), (2024, 11, 1), 8)]
318    // Sunday input: retreats to Saturday, then takes the Saturday weekend arm.
319    #[case(ForexSession::London, (2024, 3, 31), (2024, 3, 29), 8)]
320    fn test_fx_prev_start_across_dst_weekend(
321        #[case] session: ForexSession,
322        #[case] input_date: (i32, u32, u32),
323        #[case] expected_date: (i32, u32, u32),
324        #[case] expected_hour: u32,
325    ) {
326        let (input_year, input_month, input_day) = input_date;
327        let (expected_year, expected_month, expected_day) = expected_date;
328        let time_now = local_timestamp(session, input_year, input_month, input_day, 6);
329        let expected = local_timestamp(
330            session,
331            expected_year,
332            expected_month,
333            expected_day,
334            expected_hour,
335        );
336
337        assert_eq!(fx_prev_start(session, time_now), expected);
338    }
339
340    #[rstest]
341    #[case(ForexSession::Sydney, (2024, 4, 5), (2024, 4, 8), 16)]
342    #[case(ForexSession::Sydney, (2024, 10, 4), (2024, 10, 7), 16)]
343    #[case(ForexSession::London, (2024, 3, 29), (2024, 4, 1), 16)]
344    #[case(ForexSession::London, (2024, 10, 25), (2024, 10, 28), 16)]
345    #[case(ForexSession::NewYork, (2024, 3, 8), (2024, 3, 11), 17)]
346    #[case(ForexSession::NewYork, (2024, 11, 1), (2024, 11, 4), 17)]
347    fn test_fx_next_end_across_dst_weekend(
348        #[case] session: ForexSession,
349        #[case] input_date: (i32, u32, u32),
350        #[case] expected_date: (i32, u32, u32),
351        #[case] expected_hour: u32,
352    ) {
353        let (input_year, input_month, input_day) = input_date;
354        let (expected_year, expected_month, expected_day) = expected_date;
355        let time_now = local_timestamp(session, input_year, input_month, input_day, 18);
356        let expected = local_timestamp(
357            session,
358            expected_year,
359            expected_month,
360            expected_day,
361            expected_hour,
362        );
363
364        assert_eq!(fx_next_end(session, time_now), expected);
365    }
366
367    #[rstest]
368    #[case(ForexSession::Sydney, (2024, 4, 8), (2024, 4, 5), 16)]
369    #[case(ForexSession::Sydney, (2024, 10, 7), (2024, 10, 4), 16)]
370    #[case(ForexSession::London, (2024, 4, 1), (2024, 3, 29), 16)]
371    #[case(ForexSession::London, (2024, 10, 28), (2024, 10, 25), 16)]
372    #[case(ForexSession::NewYork, (2024, 3, 11), (2024, 3, 8), 17)]
373    #[case(ForexSession::NewYork, (2024, 11, 4), (2024, 11, 1), 17)]
374    fn test_fx_prev_end_across_dst_weekend(
375        #[case] session: ForexSession,
376        #[case] input_date: (i32, u32, u32),
377        #[case] expected_date: (i32, u32, u32),
378        #[case] expected_hour: u32,
379    ) {
380        let (input_year, input_month, input_day) = input_date;
381        let (expected_year, expected_month, expected_day) = expected_date;
382        let time_now = local_timestamp(session, input_year, input_month, input_day, 6);
383        let expected = local_timestamp(
384            session,
385            expected_year,
386            expected_month,
387            expected_day,
388            expected_hour,
389        );
390
391        assert_eq!(fx_prev_end(session, time_now), expected);
392    }
393
394    #[rstest]
395    pub fn test_fx_next_start_on_weekend() {
396        let sunday_utc = utc_timestamp(2020, 7, 12, 9, 0); // Sunday
397        let result = fx_next_start(ForexSession::Tokyo, sunday_utc);
398        let expected = utc_timestamp(2020, 7, 13, 0, 0); // Monday
399
400        assert_eq!(result, expected);
401    }
402
403    #[rstest]
404    pub fn test_fx_next_start_during_active_session() {
405        let during_session = utc_timestamp(2020, 7, 13, 10, 0); // Sydney session is active
406        let result = fx_next_start(ForexSession::Sydney, during_session);
407        let expected = utc_timestamp(2020, 7, 13, 21, 0); // Next Sydney session start
408
409        assert_eq!(result, expected);
410    }
411
412    #[rstest]
413    pub fn test_fx_prev_start_before_session() {
414        let before_session = utc_timestamp(2020, 7, 13, 6, 0); // Before Tokyo session start
415        let result = fx_prev_start(ForexSession::Tokyo, before_session);
416        let expected = utc_timestamp(2020, 7, 13, 0, 0); // Current Tokyo session start
417
418        assert_eq!(result, expected);
419    }
420
421    #[rstest]
422    pub fn test_fx_next_end_crossing_midnight() {
423        let late_night = utc_timestamp(2020, 7, 13, 23, 0); // After NY session ended
424        let result = fx_next_end(ForexSession::NewYork, late_night);
425        let expected = utc_timestamp(2020, 7, 14, 21, 0); // Next NY session end
426
427        assert_eq!(result, expected);
428    }
429
430    #[rstest]
431    pub fn test_fx_prev_end_after_session() {
432        let after_session = utc_timestamp(2020, 7, 13, 17, 30); // Just after NY session ended
433        let result = fx_prev_end(ForexSession::NewYork, after_session);
434        let expected = utc_timestamp(2020, 7, 10, 21, 0); // Previous NY session end
435
436        assert_eq!(result, expected);
437    }
438}