1use 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#[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 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 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#[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#[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#[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#[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#[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 #[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 #[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); let result = fx_next_start(ForexSession::Tokyo, sunday_utc);
398 let expected = utc_timestamp(2020, 7, 13, 0, 0); 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); let result = fx_next_start(ForexSession::Sydney, during_session);
407 let expected = utc_timestamp(2020, 7, 13, 21, 0); 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); let result = fx_prev_start(ForexSession::Tokyo, before_session);
416 let expected = utc_timestamp(2020, 7, 13, 0, 0); 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); let result = fx_next_end(ForexSession::NewYork, late_night);
425 let expected = utc_timestamp(2020, 7, 14, 21, 0); 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); let result = fx_prev_end(ForexSession::NewYork, after_session);
434 let expected = utc_timestamp(2020, 7, 10, 21, 0); assert_eq!(result, expected);
437 }
438}