1use chrono::{
2 Datelike, Duration, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
3 Weekday,
4};
5use chrono_tz::Tz;
6use std::cell::RefCell;
7use std::collections::{BTreeMap, BTreeSet};
8
9use qs_strategy::{ScalarType, Value, ValueType};
10
11use super::{
12 HistoricalNamedInputProjector, NamedInputProjectionContext, NamedInputProjectionError,
13 ProjectedNamedInput, SeriesId,
14};
15
16mod configured;
17
18pub use configured::{
19 CalendarAdmissionLimits, CalendarInputSpec, CalendarTimeBasis,
20 ConfiguredCalendarFeatureProjector, ConfiguredCalendarInput, ConfiguredTradingCalendar,
21 DEFAULT_CALENDAR_SESSION_ID, LocalMarketIntervalSpec, MarketScheduleSpec, NamedSessionSpec,
22 ResolvedSessionOccurrence, ResolvedTradingDay, SessionOccurrenceId, SessionScheduleSpec,
23 SessionSpanSpec, TradingCalendarSpec, WeeklyMarketIntervalSpec,
24};
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ResolvedSession {
27 pub trading_day: NaiveDate,
28 pub trading_week_monday: NaiveDate,
29 pub open_utc: NaiveDateTime,
30 pub close_utc: NaiveDateTime,
31}
32#[derive(Debug, Clone, PartialEq)]
33pub struct CalendarBar {
34 pub open_utc: NaiveDateTime,
35 pub close_utc: NaiveDateTime,
36 pub available_at: NaiveDateTime,
37 pub high: f64,
38 pub low: f64,
39}
40#[derive(Debug, Clone, PartialEq)]
41pub struct OpeningRange {
42 pub high: f64,
43 pub low: f64,
44 pub complete_children: usize,
45 pub required_children: usize,
46 pub final_value: bool,
47}
48#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
49pub enum CalendarError {
50 #[error("invalid IANA timezone '{0}'")]
51 InvalidTimezone(String),
52 #[error("session interval is not positive")]
53 NonPositiveSession,
54 #[error("local time could not be resolved within three hours")]
55 UnresolvedLocalTime,
56 #[error("child duration and alignment are invalid for opening range")]
57 InvalidChildGeometry,
58 #[error("opening-range timestamp overflowed")]
59 TimestampOverflow,
60 #[error("opening-range bars overlap, mismatch, or exceed bounds")]
61 InvalidChildren,
62 #[error("invalid calendar configuration: {0}")]
63 InvalidConfiguration(String),
64 #[error("calendar resource limit exceeded: {0}")]
65 ResourceLimit(String),
66}
67#[derive(Debug, Clone)]
68pub struct IanaTradingCalendar {
69 timezone: Tz,
70 open: NaiveTime,
71 close: NaiveTime,
72 holidays: BTreeSet<NaiveDate>,
73 early_closes: BTreeMap<NaiveDate, NaiveTime>,
74}
75impl IanaTradingCalendar {
76 pub fn new(
77 timezone: &str,
78 open: NaiveTime,
79 close: NaiveTime,
80 holidays: BTreeSet<NaiveDate>,
81 early_closes: BTreeMap<NaiveDate, NaiveTime>,
82 ) -> Result<Self, CalendarError> {
83 let timezone = timezone
84 .parse()
85 .map_err(|_| CalendarError::InvalidTimezone(timezone.into()))?;
86 Ok(Self {
87 timezone,
88 open,
89 close,
90 holidays,
91 early_closes,
92 })
93 }
94 pub fn timezone(&self) -> Tz {
95 self.timezone
96 }
97 pub fn resolve_session(
98 &self,
99 trading_day: NaiveDate,
100 ) -> Result<Option<ResolvedSession>, CalendarError> {
101 if self.holidays.contains(&trading_day) {
102 return Ok(None);
103 }
104 let open_local = trading_day.and_time(self.open);
105 let close_time = self
106 .early_closes
107 .get(&trading_day)
108 .copied()
109 .unwrap_or(self.close);
110 let close_date = if close_time <= self.open {
111 trading_day
112 .succ_opt()
113 .ok_or(CalendarError::TimestampOverflow)?
114 } else {
115 trading_day
116 };
117 let close_local = close_date.and_time(close_time);
118 let open_utc = resolve_local(self.timezone, open_local, true)?;
119 let close_utc = resolve_local(self.timezone, close_local, false)?;
120 if close_utc <= open_utc {
121 return Err(CalendarError::NonPositiveSession);
122 }
123 let monday = trading_day
124 .checked_sub_days(chrono::Days::new(
125 trading_day.weekday().num_days_from_monday() as u64,
126 ))
127 .ok_or(CalendarError::TimestampOverflow)?;
128 Ok(Some(ResolvedSession {
129 trading_day,
130 trading_week_monday: monday,
131 open_utc,
132 close_utc,
133 }))
134 }
135 pub fn session_containing(
136 &self,
137 utc: NaiveDateTime,
138 ) -> Result<Option<ResolvedSession>, CalendarError> {
139 let local_date = Utc
140 .from_utc_datetime(&utc)
141 .with_timezone(&self.timezone)
142 .date_naive();
143 for trading_day in [local_date.pred_opt(), Some(local_date)]
144 .into_iter()
145 .flatten()
146 {
147 if let Some(session) = self.resolve_session(trading_day)?
148 && utc >= session.open_utc
149 && utc < session.close_utc
150 {
151 return Ok(Some(session));
152 }
153 }
154 Ok(None)
155 }
156
157 pub fn local_fields(&self, utc: NaiveDateTime) -> (NaiveDate, Weekday, u32) {
158 let local = Utc.from_utc_datetime(&utc).with_timezone(&self.timezone);
159 (
160 local.date_naive(),
161 local.weekday(),
162 local.time().num_seconds_from_midnight(),
163 )
164 }
165 pub fn opening_range_interval(
166 &self,
167 trading_day: NaiveDate,
168 minutes: u32,
169 ) -> Result<Option<(NaiveDateTime, NaiveDateTime)>, CalendarError> {
170 let Some(session) = self.resolve_session(trading_day)? else {
171 return Ok(None);
172 };
173 let local_start = trading_day.and_time(self.open);
174 let local_end = local_start
175 .checked_add_signed(Duration::minutes(i64::from(minutes)))
176 .ok_or(CalendarError::TimestampOverflow)?;
177 let end = resolve_local(self.timezone, local_end, false)?.min(session.close_utc);
178 if end <= session.open_utc {
179 return Err(CalendarError::NonPositiveSession);
180 }
181 Ok(Some((session.open_utc, end)))
182 }
183 pub fn opening_range(
184 &self,
185 trading_day: NaiveDate,
186 minutes: u32,
187 child_seconds: u64,
188 alignment_offset_seconds: i64,
189 bars: &[CalendarBar],
190 observed_through: NaiveDateTime,
191 ) -> Result<Option<OpeningRange>, CalendarError> {
192 let Some((start, end)) = self.opening_range_interval(trading_day, minutes)? else {
193 return Ok(None);
194 };
195 let child = i64::try_from(child_seconds)
196 .ok()
197 .filter(|v| *v > 0)
198 .ok_or(CalendarError::InvalidChildGeometry)?;
199 if (start.and_utc().timestamp() - alignment_offset_seconds).rem_euclid(child) != 0
200 || (end - start).num_seconds() % child != 0
201 {
202 return Err(CalendarError::InvalidChildGeometry);
203 }
204 let required = usize::try_from((end - start).num_seconds() / child)
205 .map_err(|_| CalendarError::InvalidChildGeometry)?;
206 let mut selected = bars
207 .iter()
208 .filter(|bar| bar.open_utc >= start && bar.open_utc < end)
209 .collect::<Vec<_>>();
210 selected.sort_by_key(|bar| bar.open_utc);
211 for (index, bar) in selected.iter().enumerate() {
212 let expected = start
213 .checked_add_signed(Duration::seconds(child * i64::try_from(index).unwrap()))
214 .ok_or(CalendarError::TimestampOverflow)?;
215 if bar.open_utc != expected
216 || bar.close_utc != expected + Duration::seconds(child)
217 || bar.available_at < bar.close_utc
218 || !bar.high.is_finite()
219 || !bar.low.is_finite()
220 || bar.high < bar.low
221 {
222 return Err(CalendarError::InvalidChildren);
223 }
224 }
225 let revealed = selected
226 .iter()
227 .filter(|bar| bar.available_at <= observed_through)
228 .copied()
229 .collect::<Vec<_>>();
230 if revealed.is_empty() {
231 return Ok(None);
232 }
233 let high = revealed
234 .iter()
235 .map(|bar| bar.high)
236 .fold(f64::NEG_INFINITY, f64::max);
237 let low = revealed
238 .iter()
239 .map(|bar| bar.low)
240 .fold(f64::INFINITY, f64::min);
241 let final_value = selected.len() == required && revealed.len() == required;
242 Ok(Some(OpeningRange {
243 high,
244 low,
245 complete_children: revealed.len(),
246 required_children: required,
247 final_value,
248 }))
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum CalendarFeatureKind {
255 LocalSecondOfDay,
256 SessionElapsedSeconds,
257 PreviousSessionHigh,
258 PreviousSessionLow,
259 PreviousDayHigh,
260 PreviousDayLow,
261 PreviousWeekHigh,
262 PreviousWeekLow,
263 OpeningRangeHighSoFar,
264 OpeningRangeLowSoFar,
265 OpeningRangeHighFinal,
266 OpeningRangeLowFinal,
267 PastSameSlotRangeRatio,
268 PastSameSlotCount,
269 SessionMembership,
270}
271
272impl CalendarFeatureKind {
273 fn scalar_type(self) -> ScalarType {
274 match self {
275 Self::LocalSecondOfDay | Self::SessionElapsedSeconds | Self::PastSameSlotCount => {
276 ScalarType::Integer
277 }
278 Self::PastSameSlotRangeRatio => ScalarType::Ratio,
279 Self::SessionMembership => ScalarType::Bool,
280 _ => ScalarType::Price,
281 }
282 }
283}
284
285#[derive(Debug, Clone, Copy, Default)]
286struct CalendarPeriodStats {
287 high: f64,
288 low: f64,
289 initialized: bool,
290}
291
292impl CalendarPeriodStats {
293 fn observe(&mut self, high: f64, low: f64) {
294 if self.initialized {
295 self.high = self.high.max(high);
296 self.low = self.low.min(low);
297 } else {
298 self.high = high;
299 self.low = low;
300 self.initialized = true;
301 }
302 }
303}
304
305#[derive(Default)]
306struct CalendarProjectorState {
307 last_open: Option<NaiveDateTime>,
308 retained: Option<Value>,
309 sessions: BTreeMap<NaiveDate, CalendarPeriodStats>,
310 weeks: BTreeMap<NaiveDate, CalendarPeriodStats>,
311 opening_bars: BTreeMap<NaiveDate, Vec<CalendarBar>>,
312 slot_ranges: BTreeMap<u32, Vec<f64>>,
313}
314
315pub struct CalendarFeatureProjector {
317 series_id: SeriesId,
318 calendar: IanaTradingCalendar,
319 kind: CalendarFeatureKind,
320 opening_range_minutes: u32,
321 child_seconds: u64,
322 alignment_offset_seconds: i64,
323 maximum_slot_history: usize,
324 state: RefCell<CalendarProjectorState>,
325}
326
327impl CalendarFeatureProjector {
328 #[allow(clippy::too_many_arguments)]
329 pub fn new(
330 series_id: SeriesId,
331 calendar: IanaTradingCalendar,
332 kind: CalendarFeatureKind,
333 opening_range_minutes: u32,
334 child_seconds: u64,
335 alignment_offset_seconds: i64,
336 maximum_slot_history: usize,
337 ) -> Result<Self, CalendarError> {
338 if opening_range_minutes == 0
339 || child_seconds < 60
340 || i64::try_from(child_seconds).is_err()
341 || maximum_slot_history == 0
342 {
343 return Err(CalendarError::InvalidChildGeometry);
344 }
345 Ok(Self {
346 series_id,
347 calendar,
348 kind,
349 opening_range_minutes,
350 child_seconds,
351 alignment_offset_seconds,
352 maximum_slot_history,
353 state: RefCell::new(CalendarProjectorState::default()),
354 })
355 }
356
357 fn missing(&self, updated: bool) -> ProjectedNamedInput {
358 ProjectedNamedInput {
359 value: Value::Missing(self.kind.scalar_type()),
360 updated,
361 }
362 }
363}
364
365impl HistoricalNamedInputProjector for CalendarFeatureProjector {
366 fn output_type(&self) -> ValueType {
367 ValueType::optional(self.kind.scalar_type())
368 }
369
370 fn project(
371 &self,
372 context: NamedInputProjectionContext<'_>,
373 ) -> Result<ProjectedNamedInput, NamedInputProjectionError> {
374 let Some(bar) = context
375 .closed_bars
376 .iter()
377 .find(|bar| bar.series_id() == &self.series_id)
378 else {
379 return Ok(self.missing(false));
380 };
381 let mut state = self.state.borrow_mut();
382 if state.last_open == Some(bar.open_time()) {
383 return Ok(ProjectedNamedInput {
384 value: state
385 .retained
386 .clone()
387 .unwrap_or(Value::Missing(self.kind.scalar_type())),
388 updated: false,
389 });
390 }
391 let Some(session) = self
392 .calendar
393 .session_containing(bar.open_time())
394 .map_err(|error| NamedInputProjectionError::new(error.to_string()))?
395 else {
396 state.last_open = Some(bar.open_time());
397 state.retained = Some(Value::Missing(self.kind.scalar_type()));
398 return Ok(self.missing(true));
399 };
400 let previous_session = state
401 .sessions
402 .range(..session.trading_day)
403 .next_back()
404 .map(|(_, stats)| *stats);
405 let previous_week = state
406 .weeks
407 .range(..session.trading_week_monday)
408 .next_back()
409 .map(|(_, stats)| *stats);
410 let elapsed = (bar.open_time() - session.open_utc).num_seconds();
411 if elapsed < 0 {
412 return Err(NamedInputProjectionError::new(
413 "calendar bar precedes its resolved session",
414 ));
415 }
416 let child_seconds = i64::try_from(self.child_seconds)
417 .map_err(|_| NamedInputProjectionError::new("calendar child duration exceeds i64"))?;
418 let slot = u32::try_from(elapsed / child_seconds)
419 .map_err(|_| NamedInputProjectionError::new("calendar slot exceeds u32"))?;
420 let prior_slot = state.slot_ranges.get(&slot).cloned().unwrap_or_default();
421 let current_range = bar.high() - bar.low();
422
423 state
424 .sessions
425 .entry(session.trading_day)
426 .or_default()
427 .observe(bar.high(), bar.low());
428 state
429 .weeks
430 .entry(session.trading_week_monday)
431 .or_default()
432 .observe(bar.high(), bar.low());
433 while state.sessions.len() > self.maximum_slot_history {
434 if let Some(oldest) = state.sessions.keys().next().copied() {
435 state.sessions.remove(&oldest);
436 state.opening_bars.remove(&oldest);
437 }
438 }
439 while state.weeks.len() > self.maximum_slot_history {
440 if let Some(oldest) = state.weeks.keys().next().copied() {
441 state.weeks.remove(&oldest);
442 }
443 }
444 if let Some((opening_start, opening_end)) = self
445 .calendar
446 .opening_range_interval(session.trading_day, self.opening_range_minutes)
447 .map_err(|error| NamedInputProjectionError::new(error.to_string()))?
448 && bar.open_time() >= opening_start
449 && bar.open_time() < opening_end
450 {
451 state
452 .opening_bars
453 .entry(session.trading_day)
454 .or_default()
455 .push(CalendarBar {
456 open_utc: bar.open_time(),
457 close_utc: bar.close_time(),
458 available_at: context.observed_through,
459 high: bar.high(),
460 low: bar.low(),
461 });
462 }
463 let opening_bars = state
464 .opening_bars
465 .get(&session.trading_day)
466 .map(Vec::as_slice)
467 .unwrap_or_default();
468 let opening = self
469 .calendar
470 .opening_range(
471 session.trading_day,
472 self.opening_range_minutes,
473 self.child_seconds,
474 self.alignment_offset_seconds,
475 opening_bars,
476 context.observed_through,
477 )
478 .map_err(|error| NamedInputProjectionError::new(error.to_string()))?;
479 let value = match self.kind {
480 CalendarFeatureKind::LocalSecondOfDay => {
481 Value::Integer(i64::from(self.calendar.local_fields(bar.open_time()).2))
482 }
483 CalendarFeatureKind::SessionElapsedSeconds => Value::Integer(elapsed),
484 CalendarFeatureKind::PreviousSessionHigh | CalendarFeatureKind::PreviousDayHigh => {
485 previous_session
486 .map(|stats| Value::Price(stats.high))
487 .unwrap_or(Value::Missing(ScalarType::Price))
488 }
489 CalendarFeatureKind::PreviousSessionLow | CalendarFeatureKind::PreviousDayLow => {
490 previous_session
491 .map(|stats| Value::Price(stats.low))
492 .unwrap_or(Value::Missing(ScalarType::Price))
493 }
494 CalendarFeatureKind::PreviousWeekHigh => previous_week
495 .map(|stats| Value::Price(stats.high))
496 .unwrap_or(Value::Missing(ScalarType::Price)),
497 CalendarFeatureKind::PreviousWeekLow => previous_week
498 .map(|stats| Value::Price(stats.low))
499 .unwrap_or(Value::Missing(ScalarType::Price)),
500 CalendarFeatureKind::OpeningRangeHighSoFar => opening
501 .as_ref()
502 .map(|range| Value::Price(range.high))
503 .unwrap_or(Value::Missing(ScalarType::Price)),
504 CalendarFeatureKind::OpeningRangeLowSoFar => opening
505 .as_ref()
506 .map(|range| Value::Price(range.low))
507 .unwrap_or(Value::Missing(ScalarType::Price)),
508 CalendarFeatureKind::OpeningRangeHighFinal => opening
509 .filter(|range| range.final_value)
510 .map(|range| Value::Price(range.high))
511 .unwrap_or(Value::Missing(ScalarType::Price)),
512 CalendarFeatureKind::OpeningRangeLowFinal => opening
513 .filter(|range| range.final_value)
514 .map(|range| Value::Price(range.low))
515 .unwrap_or(Value::Missing(ScalarType::Price)),
516 CalendarFeatureKind::PastSameSlotRangeRatio => {
517 if prior_slot.is_empty() {
518 Value::Missing(ScalarType::Ratio)
519 } else {
520 let mean = prior_slot.iter().sum::<f64>() / prior_slot.len() as f64;
521 if mean > 0.0 {
522 Value::Ratio(current_range / mean)
523 } else {
524 Value::Missing(ScalarType::Ratio)
525 }
526 }
527 }
528 CalendarFeatureKind::PastSameSlotCount => Value::Integer(
529 i64::try_from(prior_slot.len())
530 .map_err(|_| NamedInputProjectionError::new("same-slot history exceeds i64"))?,
531 ),
532 CalendarFeatureKind::SessionMembership => Value::Bool(true),
533 };
534 let history = state.slot_ranges.entry(slot).or_default();
535 history.push(current_range);
536 if history.len() > self.maximum_slot_history {
537 history.remove(0);
538 }
539 state.last_open = Some(bar.open_time());
540 state.retained = Some(value.clone());
541 Ok(ProjectedNamedInput {
542 value,
543 updated: true,
544 })
545 }
546}
547
548fn resolve_local(
549 timezone: Tz,
550 mut local: NaiveDateTime,
551 open: bool,
552) -> Result<NaiveDateTime, CalendarError> {
553 for _ in 0..=180 {
554 match timezone.from_local_datetime(&local) {
555 LocalResult::Single(value) => return Ok(value.with_timezone(&Utc).naive_utc()),
556 LocalResult::Ambiguous(first, second) => {
557 let (a, b) = (
558 first.with_timezone(&Utc).naive_utc(),
559 second.with_timezone(&Utc).naive_utc(),
560 );
561 return Ok(if open { a.min(b) } else { a.max(b) });
562 }
563 LocalResult::None => {
564 local = local
565 .checked_add_signed(Duration::minutes(1))
566 .ok_or(CalendarError::TimestampOverflow)?
567 }
568 }
569 }
570 Err(CalendarError::UnresolvedLocalTime)
571}
572#[cfg(test)]
573mod tests {
574 use super::*;
575 fn time(h: u32, m: u32) -> NaiveTime {
576 NaiveTime::from_hms_opt(h, m, 0).unwrap()
577 }
578 #[test]
579 fn dst_gap_shifts_forward_and_fold_uses_earliest_open_latest_close() {
580 let gap = IanaTradingCalendar::new(
581 "America/New_York",
582 time(2, 30),
583 time(4, 0),
584 BTreeSet::new(),
585 BTreeMap::new(),
586 )
587 .unwrap()
588 .resolve_session(NaiveDate::from_ymd_opt(2026, 3, 8).unwrap())
589 .unwrap()
590 .unwrap();
591 assert_eq!(
592 gap.open_utc,
593 NaiveDate::from_ymd_opt(2026, 3, 8)
594 .unwrap()
595 .and_hms_opt(7, 0, 0)
596 .unwrap()
597 );
598 let fold = IanaTradingCalendar::new(
599 "America/New_York",
600 time(1, 30),
601 time(1, 45),
602 BTreeSet::new(),
603 BTreeMap::new(),
604 )
605 .unwrap()
606 .resolve_session(NaiveDate::from_ymd_opt(2026, 11, 1).unwrap())
607 .unwrap()
608 .unwrap();
609 assert_eq!(fold.close_utc - fold.open_utc, Duration::minutes(75));
610 }
611 #[test]
612 fn opening_range_final_waits_for_every_actual_child_reveal() {
613 let calendar = IanaTradingCalendar::new(
614 "America/New_York",
615 time(9, 30),
616 time(16, 0),
617 BTreeSet::new(),
618 BTreeMap::new(),
619 )
620 .unwrap();
621 let day = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
622 let (start, end) = calendar.opening_range_interval(day, 10).unwrap().unwrap();
623 let bars = [
624 CalendarBar {
625 open_utc: start,
626 close_utc: start + Duration::minutes(5),
627 available_at: start + Duration::minutes(5),
628 high: 2.0,
629 low: 1.0,
630 },
631 CalendarBar {
632 open_utc: start + Duration::minutes(5),
633 close_utc: end,
634 available_at: end + Duration::minutes(7),
635 high: 3.0,
636 low: 0.5,
637 },
638 ];
639 let early = calendar
640 .opening_range(day, 10, 300, 0, &bars, end)
641 .unwrap()
642 .unwrap();
643 assert!(!early.final_value);
644 assert_eq!(early.high, 2.0);
645 let final_value = calendar
646 .opening_range(day, 10, 300, 0, &bars, end + Duration::minutes(7))
647 .unwrap()
648 .unwrap();
649 assert!(final_value.final_value);
650 assert_eq!((final_value.high, final_value.low), (3.0, 0.5));
651 }
652}