Skip to main content

qs_backtest/strategy/
calendar.rs

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
315/// Projects caller-calendar features from completed bars at their actual reveal boundary.
316pub 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}