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////////////////////////////////////////////////////////////////////////////////
203// Tests
204////////////////////////////////////////////////////////////////////////////////
205#[cfg(test)]
206mod tests {
207    use rstest::rstest;
208
209    use super::*;
210
211    #[rstest]
212    #[case(ForexSession::Sydney, "1970-01-01T10:00:00+10:00")]
213    #[case(ForexSession::Tokyo, "1970-01-01T09:00:00+09:00")]
214    #[case(ForexSession::London, "1970-01-01T01:00:00+01:00")]
215    #[case(ForexSession::NewYork, "1969-12-31T19:00:00-05:00")]
216    pub fn test_fx_local_from_utc(#[case] session: ForexSession, #[case] expected: &str) {
217        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
218        let result = fx_local_from_utc(session, unix_epoch);
219        assert_eq!(result.to_rfc3339(), expected);
220    }
221
222    #[rstest]
223    #[case(ForexSession::Sydney, "1970-01-01T21:00:00+00:00")]
224    #[case(ForexSession::Tokyo, "1970-01-01T00:00:00+00:00")]
225    #[case(ForexSession::London, "1970-01-01T07:00:00+00:00")]
226    #[case(ForexSession::NewYork, "1970-01-01T13:00:00+00:00")]
227    pub fn test_fx_next_start(#[case] session: ForexSession, #[case] expected: &str) {
228        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
229        let result = fx_next_start(session, unix_epoch);
230        assert_eq!(result.to_rfc3339(), expected);
231    }
232
233    #[rstest]
234    #[case(ForexSession::Sydney, "1969-12-31T21:00:00+00:00")]
235    #[case(ForexSession::Tokyo, "1970-01-01T00:00:00+00:00")]
236    #[case(ForexSession::London, "1969-12-31T07:00:00+00:00")]
237    #[case(ForexSession::NewYork, "1969-12-31T13:00:00+00:00")]
238    pub fn test_fx_prev_start(#[case] session: ForexSession, #[case] expected: &str) {
239        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
240        let result = fx_prev_start(session, unix_epoch);
241        assert_eq!(result.to_rfc3339(), expected);
242    }
243
244    #[rstest]
245    #[case(ForexSession::Sydney, "1970-01-01T06:00:00+00:00")]
246    #[case(ForexSession::Tokyo, "1970-01-01T09:00:00+00:00")]
247    #[case(ForexSession::London, "1970-01-01T15:00:00+00:00")]
248    #[case(ForexSession::NewYork, "1970-01-01T22:00:00+00:00")]
249    pub fn test_fx_next_end(#[case] session: ForexSession, #[case] expected: &str) {
250        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
251        let result = fx_next_end(session, unix_epoch);
252        assert_eq!(result.to_rfc3339(), expected);
253    }
254
255    #[rstest]
256    #[case(ForexSession::Sydney, "1969-12-31T06:00:00+00:00")]
257    #[case(ForexSession::Tokyo, "1969-12-31T09:00:00+00:00")]
258    #[case(ForexSession::London, "1969-12-31T15:00:00+00:00")]
259    #[case(ForexSession::NewYork, "1969-12-31T22:00:00+00:00")]
260    pub fn test_fx_prev_end(#[case] session: ForexSession, #[case] expected: &str) {
261        let unix_epoch = Utc.timestamp_opt(0, 0).unwrap();
262        let result = fx_prev_end(session, unix_epoch);
263        assert_eq!(result.to_rfc3339(), expected);
264    }
265
266    #[rstest]
267    pub fn test_fx_next_start_on_weekend() {
268        let sunday_utc = Utc.with_ymd_and_hms(2020, 7, 12, 9, 0, 0).unwrap(); // Sunday
269        let result = fx_next_start(ForexSession::Tokyo, sunday_utc);
270        let expected = Utc.with_ymd_and_hms(2020, 7, 13, 0, 0, 0).unwrap(); // Monday
271
272        assert_eq!(result, expected);
273    }
274
275    #[rstest]
276    pub fn test_fx_next_start_during_active_session() {
277        let during_session = Utc.with_ymd_and_hms(2020, 7, 13, 10, 0, 0).unwrap(); // Sydney session is active
278        let result = fx_next_start(ForexSession::Sydney, during_session);
279        let expected = Utc.with_ymd_and_hms(2020, 7, 13, 21, 0, 0).unwrap(); // Next Sydney session start
280
281        assert_eq!(result, expected);
282    }
283
284    #[rstest]
285    pub fn test_fx_prev_start_before_session() {
286        let before_session = Utc.with_ymd_and_hms(2020, 7, 13, 6, 0, 0).unwrap(); // Before Tokyo session start
287        let result = fx_prev_start(ForexSession::Tokyo, before_session);
288        let expected = Utc.with_ymd_and_hms(2020, 7, 13, 0, 0, 0).unwrap(); // Current Tokyo session start
289
290        assert_eq!(result, expected);
291    }
292
293    #[rstest]
294    pub fn test_fx_next_end_crossing_midnight() {
295        let late_night = Utc.with_ymd_and_hms(2020, 7, 13, 23, 0, 0).unwrap(); // After NY session ended
296        let result = fx_next_end(ForexSession::NewYork, late_night);
297        let expected = Utc.with_ymd_and_hms(2020, 7, 14, 21, 0, 0).unwrap(); // Next NY session end
298
299        assert_eq!(result, expected);
300    }
301
302    #[rstest]
303    pub fn test_fx_prev_end_after_session() {
304        let after_session = Utc.with_ymd_and_hms(2020, 7, 13, 17, 30, 0).unwrap(); // Just after NY session ended
305        let result = fx_prev_end(ForexSession::NewYork, after_session);
306        let expected = Utc.with_ymd_and_hms(2020, 7, 10, 21, 0, 0).unwrap(); // Previous NY session end
307
308        assert_eq!(result, expected);
309    }
310}