Skip to main content

qs_backtest/strategy/calendar/
configured.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet, VecDeque};
3
4use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc};
5use chrono_tz::Tz;
6use qs_strategy::{ScalarType, Value, ValueType};
7use serde::{Deserialize, Serialize};
8
9use super::{CalendarBar, CalendarError, CalendarFeatureKind, OpeningRange};
10use crate::strategy::{
11    ConfiguredNamedInputBinding, HistoricalNamedInputProjector, NamedInputProjectionContext,
12    NamedInputProjectionError, ProjectedNamedInput, SeriesId,
13};
14
15pub const DEFAULT_CALENDAR_SESSION_ID: &str = "full_day";
16pub const MAX_CALENDAR_ID_BYTES: usize = 64;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum CalendarTimeBasis {
21    #[default]
22    SourceOpen,
23    DecisionTime,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
28pub enum SessionScheduleSpec {
29    #[default]
30    FullDay,
31    Custom {
32        items: Vec<NamedSessionSpec>,
33    },
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
38pub enum SessionSpanSpec {
39    FullDay,
40    Timed {
41        start: NaiveTime,
42        end: NaiveTime,
43        end_day_offset: u8,
44    },
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct NamedSessionSpec {
50    pub id: String,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub timezone: Option<String>,
53    pub span: SessionSpanSpec,
54    #[serde(default)]
55    pub weekdays: BTreeSet<u8>,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
60pub enum MarketScheduleSpec {
61    #[default]
62    Unspecified,
63    Continuous,
64    Weekly {
65        intervals: Vec<WeeklyMarketIntervalSpec>,
66        #[serde(default)]
67        exceptions: BTreeMap<NaiveDate, Vec<LocalMarketIntervalSpec>>,
68    },
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct WeeklyMarketIntervalSpec {
74    pub weekday: u8,
75    pub start: NaiveTime,
76    pub end: NaiveTime,
77    pub end_day_offset: u8,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct LocalMarketIntervalSpec {
83    pub start: NaiveTime,
84    pub end: NaiveTime,
85    pub end_day_offset: u8,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct TradingCalendarSpec {
91    pub id: String,
92    pub timezone: String,
93    #[serde(default = "midnight")]
94    pub day_boundary: NaiveTime,
95    #[serde(default)]
96    pub sessions: SessionScheduleSpec,
97    #[serde(default)]
98    pub market: MarketScheduleSpec,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct CalendarAdmissionLimits {
104    pub max_sessions: usize,
105    pub max_market_intervals: usize,
106    pub max_exceptions: usize,
107    pub max_history_occurrences: usize,
108    pub max_resolved_children: usize,
109    pub max_owned_bytes: usize,
110}
111
112impl CalendarAdmissionLimits {
113    pub fn new(
114        max_sessions: usize,
115        max_market_intervals: usize,
116        max_exceptions: usize,
117        max_history_occurrences: usize,
118        max_resolved_children: usize,
119        max_owned_bytes: usize,
120    ) -> Result<Self, CalendarError> {
121        if [
122            max_sessions,
123            max_market_intervals,
124            max_exceptions,
125            max_history_occurrences,
126            max_resolved_children,
127            max_owned_bytes,
128        ]
129        .contains(&0)
130        {
131            return Err(CalendarError::InvalidConfiguration(
132                "calendar admission limits must be positive".into(),
133            ));
134        }
135        Ok(Self {
136            max_sessions,
137            max_market_intervals,
138            max_exceptions,
139            max_history_occurrences,
140            max_resolved_children,
141            max_owned_bytes,
142        })
143    }
144}
145
146impl Default for CalendarAdmissionLimits {
147    fn default() -> Self {
148        Self {
149            max_sessions: 32,
150            max_market_intervals: 64,
151            max_exceptions: 366,
152            max_history_occurrences: 512,
153            max_resolved_children: 1_000_000,
154            max_owned_bytes: 64 * 1024 * 1024,
155        }
156    }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct SessionOccurrenceId {
162    pub calendar_id: String,
163    pub session_id: String,
164    pub start_utc: NaiveDateTime,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ResolvedTradingDay {
169    pub label: NaiveDate,
170    pub week_monday: NaiveDate,
171    pub start_utc: NaiveDateTime,
172    pub end_utc: NaiveDateTime,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ResolvedSessionOccurrence {
177    pub id: SessionOccurrenceId,
178    pub trading_day: NaiveDate,
179    pub start_utc: NaiveDateTime,
180    pub end_utc: NaiveDateTime,
181}
182
183#[derive(Debug, Clone)]
184struct EffectiveSession {
185    id: String,
186    timezone: Tz,
187    span: SessionSpanSpec,
188    weekdays: BTreeSet<u8>,
189}
190
191#[derive(Debug, Clone)]
192pub struct ConfiguredTradingCalendar {
193    id: String,
194    timezone: Tz,
195    day_boundary: NaiveTime,
196    sessions: Vec<EffectiveSession>,
197    market: MarketScheduleSpec,
198    limits: CalendarAdmissionLimits,
199}
200
201impl ConfiguredTradingCalendar {
202    pub fn new(
203        spec: TradingCalendarSpec,
204        limits: CalendarAdmissionLimits,
205    ) -> Result<Self, CalendarError> {
206        validate_id("calendar", &spec.id)?;
207        let timezone = spec
208            .timezone
209            .parse::<Tz>()
210            .map_err(|_| CalendarError::InvalidTimezone(spec.timezone.clone()))?;
211        let raw_sessions = match spec.sessions {
212            SessionScheduleSpec::FullDay => vec![NamedSessionSpec {
213                id: DEFAULT_CALENDAR_SESSION_ID.into(),
214                timezone: None,
215                span: SessionSpanSpec::FullDay,
216                weekdays: BTreeSet::new(),
217            }],
218            SessionScheduleSpec::Custom { items } if items.is_empty() => {
219                return Err(CalendarError::InvalidConfiguration(
220                    "custom calendar sessions cannot be empty".into(),
221                ));
222            }
223            SessionScheduleSpec::Custom { items } => items,
224        };
225        if raw_sessions.len() > limits.max_sessions {
226            return Err(CalendarError::ResourceLimit(
227                "calendar session count exceeds admission".into(),
228            ));
229        }
230        let mut ids = BTreeSet::new();
231        let mut sessions = Vec::with_capacity(raw_sessions.len());
232        for session in raw_sessions {
233            validate_id("session", &session.id)?;
234            if !ids.insert(session.id.clone()) {
235                return Err(CalendarError::InvalidConfiguration(format!(
236                    "duplicate calendar session '{}'",
237                    session.id
238                )));
239            }
240            if session.weekdays.iter().any(|weekday| *weekday > 6) {
241                return Err(CalendarError::InvalidConfiguration(format!(
242                    "session '{}' has an invalid weekday",
243                    session.id
244                )));
245            }
246            validate_span(&session.id, &session.span)?;
247            let session_timezone = match session.timezone {
248                Some(value) => value
249                    .parse::<Tz>()
250                    .map_err(|_| CalendarError::InvalidTimezone(value))?,
251                None => timezone,
252            };
253            sessions.push(EffectiveSession {
254                id: session.id,
255                timezone: session_timezone,
256                span: session.span,
257                weekdays: session.weekdays,
258            });
259        }
260        validate_market(&spec.market, limits)?;
261        let owned_bytes = spec
262            .id
263            .len()
264            .checked_add(spec.timezone.len())
265            .and_then(|value| {
266                sessions
267                    .iter()
268                    .try_fold(value, |total, session| total.checked_add(session.id.len()))
269            })
270            .ok_or_else(|| CalendarError::ResourceLimit("calendar byte count overflowed".into()))?;
271        if owned_bytes > limits.max_owned_bytes {
272            return Err(CalendarError::ResourceLimit(
273                "calendar configuration exceeds owned-byte admission".into(),
274            ));
275        }
276        Ok(Self {
277            id: spec.id,
278            timezone,
279            day_boundary: spec.day_boundary,
280            sessions,
281            market: spec.market,
282            limits,
283        })
284    }
285
286    pub fn id(&self) -> &str {
287        &self.id
288    }
289
290    pub fn session_ids(&self) -> impl Iterator<Item = &str> {
291        self.sessions.iter().map(|session| session.id.as_str())
292    }
293
294    pub fn resolve_trading_day(
295        &self,
296        label: NaiveDate,
297    ) -> Result<ResolvedTradingDay, CalendarError> {
298        let next = label.succ_opt().ok_or(CalendarError::TimestampOverflow)?;
299        let start_utc = resolve_boundary(self.timezone, label.and_time(self.day_boundary))?;
300        let end_utc = resolve_boundary(self.timezone, next.and_time(self.day_boundary))?;
301        if end_utc <= start_utc {
302            return Err(CalendarError::NonPositiveSession);
303        }
304        let week_monday = label
305            .checked_sub_days(chrono::Days::new(
306                label.weekday().num_days_from_monday() as u64
307            ))
308            .ok_or(CalendarError::TimestampOverflow)?;
309        Ok(ResolvedTradingDay {
310            label,
311            week_monday,
312            start_utc,
313            end_utc,
314        })
315    }
316
317    pub fn trading_day_containing(
318        &self,
319        utc: NaiveDateTime,
320    ) -> Result<ResolvedTradingDay, CalendarError> {
321        let local_date = Utc
322            .from_utc_datetime(&utc)
323            .with_timezone(&self.timezone)
324            .date_naive();
325        for label in [
326            local_date.pred_opt(),
327            Some(local_date),
328            local_date.succ_opt(),
329        ]
330        .into_iter()
331        .flatten()
332        {
333            let day = self.resolve_trading_day(label)?;
334            if utc >= day.start_utc && utc < day.end_utc {
335                return Ok(day);
336            }
337        }
338        Err(CalendarError::InvalidConfiguration(
339            "instant is not contained by adjacent trading-day boundaries".into(),
340        ))
341    }
342
343    pub fn session_occurrences(
344        &self,
345        trading_day: &ResolvedTradingDay,
346    ) -> Result<Vec<ResolvedSessionOccurrence>, CalendarError> {
347        let mut occurrences = Vec::new();
348        for session in &self.sessions {
349            match session.span {
350                SessionSpanSpec::FullDay => {
351                    let weekday = trading_day.label.weekday().num_days_from_monday() as u8;
352                    if !session.weekdays.is_empty() && !session.weekdays.contains(&weekday) {
353                        continue;
354                    }
355                    occurrences.push(ResolvedSessionOccurrence {
356                        id: SessionOccurrenceId {
357                            calendar_id: self.id.clone(),
358                            session_id: session.id.clone(),
359                            start_utc: trading_day.start_utc,
360                        },
361                        trading_day: trading_day.label,
362                        start_utc: trading_day.start_utc,
363                        end_utc: trading_day.end_utc,
364                    });
365                }
366                SessionSpanSpec::Timed {
367                    start,
368                    end,
369                    end_day_offset,
370                } => {
371                    let local_at_day_start = Utc
372                        .from_utc_datetime(&trading_day.start_utc)
373                        .with_timezone(&session.timezone)
374                        .date_naive();
375                    for date in [
376                        local_at_day_start.pred_opt(),
377                        Some(local_at_day_start),
378                        local_at_day_start.succ_opt(),
379                    ]
380                    .into_iter()
381                    .flatten()
382                    {
383                        let weekday = date.weekday().num_days_from_monday() as u8;
384                        if !session.weekdays.is_empty() && !session.weekdays.contains(&weekday) {
385                            continue;
386                        }
387                        let end_date = date
388                            .checked_add_days(chrono::Days::new(u64::from(end_day_offset)))
389                            .ok_or(CalendarError::TimestampOverflow)?;
390                        let start_utc =
391                            resolve_session_local(session.timezone, date.and_time(start), true)?;
392                        let end_utc =
393                            resolve_session_local(session.timezone, end_date.and_time(end), false)?;
394                        if end_utc <= start_utc {
395                            return Err(CalendarError::NonPositiveSession);
396                        }
397                        if start_utc >= trading_day.start_utc && start_utc < trading_day.end_utc {
398                            occurrences.push(ResolvedSessionOccurrence {
399                                id: SessionOccurrenceId {
400                                    calendar_id: self.id.clone(),
401                                    session_id: session.id.clone(),
402                                    start_utc,
403                                },
404                                trading_day: trading_day.label,
405                                start_utc,
406                                end_utc,
407                            });
408                        }
409                    }
410                }
411            }
412        }
413        occurrences.sort_by(|left, right| {
414            left.start_utc
415                .cmp(&right.start_utc)
416                .then_with(|| left.id.session_id.cmp(&right.id.session_id))
417        });
418        let mut last_end = BTreeMap::new();
419        for occurrence in &occurrences {
420            if last_end
421                .get(&occurrence.id.session_id)
422                .is_some_and(|end| occurrence.start_utc < *end)
423            {
424                return Err(CalendarError::InvalidConfiguration(format!(
425                    "session '{}' has overlapping occurrences",
426                    occurrence.id.session_id
427                )));
428            }
429            last_end.insert(occurrence.id.session_id.clone(), occurrence.end_utc);
430        }
431        Ok(occurrences)
432    }
433
434    pub fn selected_occurrence_containing(
435        &self,
436        session_id: &str,
437        utc: NaiveDateTime,
438    ) -> Result<Option<ResolvedSessionOccurrence>, CalendarError> {
439        let day = self.trading_day_containing(utc)?;
440        for label in [day.label.pred_opt(), Some(day.label)]
441            .into_iter()
442            .flatten()
443        {
444            let candidate_day = self.resolve_trading_day(label)?;
445            for occurrence in self.session_occurrences(&candidate_day)? {
446                if occurrence.id.session_id == session_id
447                    && utc >= occurrence.start_utc
448                    && utc < occurrence.end_utc
449                {
450                    return Ok(Some(occurrence));
451                }
452            }
453        }
454        Ok(None)
455    }
456
457    pub fn latest_occurrence(
458        &self,
459        session_id: &str,
460        at: NaiveDateTime,
461    ) -> Result<Option<ResolvedSessionOccurrence>, CalendarError> {
462        let current_day = self.trading_day_containing(at)?;
463        let mut best = None;
464        for days_back in 0..=2 {
465            let Some(label) = current_day
466                .label
467                .checked_sub_days(chrono::Days::new(days_back))
468            else {
469                break;
470            };
471            let day = self.resolve_trading_day(label)?;
472            for occurrence in self.session_occurrences(&day)? {
473                if occurrence.id.session_id == session_id
474                    && occurrence.start_utc <= at
475                    && best
476                        .as_ref()
477                        .is_none_or(|value: &ResolvedSessionOccurrence| {
478                            occurrence.start_utc > value.start_utc
479                        })
480                {
481                    best = Some(occurrence);
482                }
483            }
484        }
485        Ok(best)
486    }
487
488    pub fn opening_range_end(
489        &self,
490        occurrence: &ResolvedSessionOccurrence,
491        minutes: u32,
492    ) -> Result<NaiveDateTime, CalendarError> {
493        let session = self
494            .sessions
495            .iter()
496            .find(|session| session.id == occurrence.id.session_id)
497            .ok_or_else(|| {
498                CalendarError::InvalidConfiguration(format!(
499                    "unknown calendar session '{}'",
500                    occurrence.id.session_id
501                ))
502            })?;
503        let start_local = Utc
504            .from_utc_datetime(&occurrence.start_utc)
505            .with_timezone(&session.timezone)
506            .naive_local();
507        let end_local = start_local
508            .checked_add_signed(Duration::minutes(i64::from(minutes)))
509            .ok_or(CalendarError::TimestampOverflow)?;
510        resolve_session_local(session.timezone, end_local, false)
511            .map(|end| end.min(occurrence.end_utc))
512    }
513
514    pub fn previous_occurrence(
515        &self,
516        session_id: &str,
517        before: NaiveDateTime,
518    ) -> Result<Option<ResolvedSessionOccurrence>, CalendarError> {
519        let current_day = self.trading_day_containing(before)?;
520        let scan = self.limits.max_history_occurrences.min(4096);
521        let mut best = None;
522        for days_back in 0..=scan {
523            let Some(label) = current_day
524                .label
525                .checked_sub_days(chrono::Days::new(days_back as u64))
526            else {
527                break;
528            };
529            let day = self.resolve_trading_day(label)?;
530            for occurrence in self.session_occurrences(&day)? {
531                if occurrence.id.session_id == session_id
532                    && occurrence.end_utc <= before
533                    && best
534                        .as_ref()
535                        .is_none_or(|value: &ResolvedSessionOccurrence| {
536                            occurrence.end_utc > value.end_utc
537                        })
538                {
539                    best = Some(occurrence);
540                }
541            }
542            if best.is_some() {
543                break;
544            }
545        }
546        Ok(best)
547    }
548
549    pub fn previous_trading_day(
550        &self,
551        current: NaiveDate,
552        child_seconds: i64,
553        alignment_offset_seconds: i64,
554    ) -> Result<Option<ResolvedTradingDay>, CalendarError> {
555        let scan = self.limits.max_history_occurrences.min(4096);
556        for days_back in 1..=scan {
557            let Some(label) = current.checked_sub_days(chrono::Days::new(days_back as u64)) else {
558                break;
559            };
560            let day = self.resolve_trading_day(label)?;
561            if self.day_is_eligible(&day, child_seconds, alignment_offset_seconds)? {
562                return Ok(Some(day));
563            }
564        }
565        Ok(None)
566    }
567
568    pub fn expected_child_opens(
569        &self,
570        start: NaiveDateTime,
571        end: NaiveDateTime,
572        child_seconds: i64,
573        alignment_offset_seconds: i64,
574    ) -> Result<Option<Vec<NaiveDateTime>>, CalendarError> {
575        if child_seconds <= 0 || end <= start {
576            return Err(CalendarError::InvalidChildGeometry);
577        }
578        let intervals = match &self.market {
579            MarketScheduleSpec::Unspecified => return Ok(None),
580            MarketScheduleSpec::Continuous => vec![(start, end)],
581            MarketScheduleSpec::Weekly { .. } => self.market_intervals(start, end)?,
582        };
583        let mut opens = Vec::new();
584        for (interval_start, interval_end) in intervals {
585            if (interval_start.and_utc().timestamp() - alignment_offset_seconds)
586                .rem_euclid(child_seconds)
587                != 0
588                || (interval_end - interval_start).num_seconds() % child_seconds != 0
589            {
590                return Err(CalendarError::InvalidChildGeometry);
591            }
592            let mut open = interval_start;
593            while open < interval_end {
594                if opens.len() >= self.limits.max_resolved_children {
595                    return Err(CalendarError::ResourceLimit(
596                        "calendar child count exceeds admission".into(),
597                    ));
598                }
599                opens.push(open);
600                open = open
601                    .checked_add_signed(Duration::seconds(child_seconds))
602                    .ok_or(CalendarError::TimestampOverflow)?;
603            }
604        }
605        opens.sort_unstable();
606        opens.dedup();
607        Ok(Some(opens))
608    }
609
610    fn day_is_eligible(
611        &self,
612        day: &ResolvedTradingDay,
613        child_seconds: i64,
614        alignment_offset_seconds: i64,
615    ) -> Result<bool, CalendarError> {
616        match self.expected_child_opens(
617            day.start_utc,
618            day.end_utc,
619            child_seconds,
620            alignment_offset_seconds,
621        )? {
622            Some(opens) => Ok(!opens.is_empty()),
623            None => Ok(true),
624        }
625    }
626
627    fn market_intervals(
628        &self,
629        start: NaiveDateTime,
630        end: NaiveDateTime,
631    ) -> Result<Vec<(NaiveDateTime, NaiveDateTime)>, CalendarError> {
632        let MarketScheduleSpec::Weekly {
633            intervals,
634            exceptions,
635        } = &self.market
636        else {
637            return Ok(vec![(start, end)]);
638        };
639        let start_date = Utc
640            .from_utc_datetime(&start)
641            .with_timezone(&self.timezone)
642            .date_naive()
643            .pred_opt()
644            .ok_or(CalendarError::TimestampOverflow)?;
645        let end_date = Utc
646            .from_utc_datetime(&end)
647            .with_timezone(&self.timezone)
648            .date_naive()
649            .succ_opt()
650            .ok_or(CalendarError::TimestampOverflow)?;
651        let mut result = Vec::new();
652        let mut date = start_date;
653        while date <= end_date {
654            let local_intervals = if let Some(replacement) = exceptions.get(&date) {
655                replacement.clone()
656            } else {
657                let weekday = date.weekday().num_days_from_monday() as u8;
658                intervals
659                    .iter()
660                    .filter(|item| item.weekday == weekday)
661                    .map(|item| LocalMarketIntervalSpec {
662                        start: item.start,
663                        end: item.end,
664                        end_day_offset: item.end_day_offset,
665                    })
666                    .collect()
667            };
668            for interval in local_intervals {
669                let interval_end_date = date
670                    .checked_add_days(chrono::Days::new(u64::from(interval.end_day_offset)))
671                    .ok_or(CalendarError::TimestampOverflow)?;
672                let resolved_start =
673                    resolve_session_local(self.timezone, date.and_time(interval.start), true)?;
674                let resolved_end = resolve_session_local(
675                    self.timezone,
676                    interval_end_date.and_time(interval.end),
677                    false,
678                )?;
679                let clipped_start = resolved_start.max(start);
680                let clipped_end = resolved_end.min(end);
681                if clipped_end > clipped_start {
682                    result.push((clipped_start, clipped_end));
683                }
684            }
685            date = date.succ_opt().ok_or(CalendarError::TimestampOverflow)?;
686        }
687        result.sort_unstable();
688        let mut merged: Vec<(NaiveDateTime, NaiveDateTime)> = Vec::new();
689        for interval in result {
690            if let Some(last) = merged.last_mut()
691                && interval.0 <= last.1
692            {
693                last.1 = last.1.max(interval.1);
694            } else {
695                merged.push(interval);
696            }
697        }
698        Ok(merged)
699    }
700}
701
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(deny_unknown_fields)]
704pub struct ConfiguredCalendarInput {
705    pub name: String,
706    pub source: String,
707    pub calendar: TradingCalendarSpec,
708    pub input: CalendarInputSpec,
709    #[serde(default)]
710    pub limits: CalendarAdmissionLimits,
711}
712
713impl ConfiguredCalendarInput {
714    pub fn history_start(
715        &self,
716        evaluation_start: NaiveDateTime,
717    ) -> Result<NaiveDateTime, CalendarError> {
718        let calendar = ConfiguredTradingCalendar::new(self.calendar.clone(), self.limits)?;
719        let current = calendar.trading_day_containing(evaluation_start)?;
720        let child_seconds = i64::try_from(self.input.child_seconds)
721            .map_err(|_| CalendarError::InvalidChildGeometry)?;
722        match self.input.kind {
723            CalendarFeatureKind::PreviousSessionHigh
724            | CalendarFeatureKind::PreviousSessionLow
725            | CalendarFeatureKind::PastSameSlotRangeRatio
726            | CalendarFeatureKind::PastSameSlotCount => {
727                let session_id = self.input.session_id.as_deref().unwrap_or(
728                    calendar.session_ids().next().ok_or_else(|| {
729                        CalendarError::InvalidConfiguration(
730                            "calendar has no effective session".into(),
731                        )
732                    })?,
733                );
734                let mut before = evaluation_start;
735                let mut earliest = current.start_utc;
736                for _ in 0..self.input.maximum_history {
737                    let occurrence = calendar
738                        .previous_occurrence(session_id, before)?
739                        .ok_or_else(|| {
740                            CalendarError::InvalidConfiguration(format!(
741                                "calendar history cannot resolve {} prior '{}' occurrences",
742                                self.input.maximum_history, session_id
743                            ))
744                        })?;
745                    earliest = earliest.min(occurrence.start_utc);
746                    before = occurrence.start_utc;
747                }
748                Ok(earliest)
749            }
750            CalendarFeatureKind::PreviousDayHigh | CalendarFeatureKind::PreviousDayLow => {
751                let mut label = current.label;
752                let mut earliest = current.start_utc;
753                for _ in 0..self.input.maximum_history {
754                    let day = calendar
755                        .previous_trading_day(
756                            label,
757                            child_seconds,
758                            self.input.alignment_offset_seconds,
759                        )?
760                        .ok_or_else(|| {
761                            CalendarError::InvalidConfiguration(
762                                "calendar history cannot resolve the required prior trading days"
763                                    .into(),
764                            )
765                        })?;
766                    earliest = earliest.min(day.start_utc);
767                    label = day.label;
768                }
769                Ok(earliest)
770            }
771            CalendarFeatureKind::PreviousWeekHigh | CalendarFeatureKind::PreviousWeekLow => {
772                let weeks = self
773                    .input
774                    .maximum_history
775                    .checked_add(1)
776                    .and_then(|value| value.checked_mul(7))
777                    .ok_or_else(|| {
778                        CalendarError::ResourceLimit("calendar week history overflowed".into())
779                    })?;
780                let label = current
781                    .week_monday
782                    .checked_sub_days(chrono::Days::new(weeks as u64))
783                    .ok_or(CalendarError::TimestampOverflow)?;
784                calendar.resolve_trading_day(label).map(|day| day.start_utc)
785            }
786            _ => Ok(current.start_utc),
787        }
788    }
789
790    pub fn estimated_owned_bytes(&self) -> Result<usize, CalendarError> {
791        let calendar = ConfiguredTradingCalendar::new(self.calendar.clone(), self.limits)?;
792        let child_seconds = i64::try_from(self.input.child_seconds)
793            .map_err(|_| CalendarError::InvalidChildGeometry)?;
794        estimated_projector_bytes(&calendar, &self.input, child_seconds)
795    }
796
797    pub fn binding(&self) -> Result<ConfiguredNamedInputBinding, CalendarError> {
798        validate_id("historical input", &self.name)?;
799        let series_id = SeriesId::new(&self.source)
800            .map_err(|error| CalendarError::InvalidConfiguration(error.to_string()))?;
801        let calendar = ConfiguredTradingCalendar::new(self.calendar.clone(), self.limits)?;
802        let projector =
803            ConfiguredCalendarFeatureProjector::new(series_id, calendar, self.input.clone())?;
804        Ok(ConfiguredNamedInputBinding::new(
805            self.name.clone(),
806            Box::new(projector),
807        ))
808    }
809}
810
811#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(deny_unknown_fields)]
813pub struct CalendarInputSpec {
814    pub calendar_id: String,
815    pub kind: CalendarFeatureKind,
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub session_id: Option<String>,
818    #[serde(default)]
819    pub time_basis: CalendarTimeBasis,
820    #[serde(default = "default_opening_range_minutes")]
821    pub opening_range_minutes: u32,
822    pub child_seconds: u64,
823    pub alignment_offset_seconds: i64,
824    pub maximum_history: usize,
825}
826
827#[derive(Debug, Clone, Default)]
828struct PeriodAccumulator {
829    stats: super::CalendarPeriodStats,
830    observed_opens: BTreeSet<NaiveDateTime>,
831}
832
833impl PeriodAccumulator {
834    fn observe(&mut self, bar: &crate::strategy::series::ClosedBar) {
835        self.stats.observe(bar.high(), bar.low());
836        self.observed_opens.insert(bar.open_time());
837    }
838}
839
840#[derive(Debug, Default)]
841struct ConfiguredCalendarState {
842    last_open: Option<NaiveDateTime>,
843    retained: Option<Value>,
844    days: BTreeMap<NaiveDate, PeriodAccumulator>,
845    weeks: BTreeMap<NaiveDate, PeriodAccumulator>,
846    sessions: BTreeMap<SessionOccurrenceId, PeriodAccumulator>,
847    opening_bars: BTreeMap<SessionOccurrenceId, Vec<CalendarBar>>,
848    slot_ranges: BTreeMap<(String, u32), VecDeque<f64>>,
849}
850
851pub struct ConfiguredCalendarFeatureProjector {
852    series_id: SeriesId,
853    calendar: ConfiguredTradingCalendar,
854    input: CalendarInputSpec,
855    child_seconds: i64,
856    selected_session_id: Option<String>,
857    state: RefCell<ConfiguredCalendarState>,
858}
859
860impl ConfiguredCalendarFeatureProjector {
861    pub fn new(
862        series_id: SeriesId,
863        calendar: ConfiguredTradingCalendar,
864        mut input: CalendarInputSpec,
865    ) -> Result<Self, CalendarError> {
866        if input.calendar_id != calendar.id {
867            return Err(CalendarError::InvalidConfiguration(format!(
868                "calendar input references '{}', but received '{}'",
869                input.calendar_id, calendar.id
870            )));
871        }
872        if input.opening_range_minutes == 0
873            || input.child_seconds < 60
874            || input.maximum_history == 0
875            || input.maximum_history > calendar.limits.max_history_occurrences
876        {
877            return Err(CalendarError::InvalidChildGeometry);
878        }
879        let child_seconds =
880            i64::try_from(input.child_seconds).map_err(|_| CalendarError::InvalidChildGeometry)?;
881        let estimated_bytes = estimated_projector_bytes(&calendar, &input, child_seconds)?;
882        if estimated_bytes > calendar.limits.max_owned_bytes {
883            return Err(CalendarError::ResourceLimit(format!(
884                "calendar projector needs an estimated {estimated_bytes} bytes, above {}",
885                calendar.limits.max_owned_bytes
886            )));
887        }
888        let session_required = matches!(
889            input.kind,
890            CalendarFeatureKind::SessionElapsedSeconds
891                | CalendarFeatureKind::SessionMembership
892                | CalendarFeatureKind::PreviousSessionHigh
893                | CalendarFeatureKind::PreviousSessionLow
894                | CalendarFeatureKind::OpeningRangeHighSoFar
895                | CalendarFeatureKind::OpeningRangeLowSoFar
896                | CalendarFeatureKind::OpeningRangeHighFinal
897                | CalendarFeatureKind::OpeningRangeLowFinal
898                | CalendarFeatureKind::PastSameSlotRangeRatio
899                | CalendarFeatureKind::PastSameSlotCount
900        );
901        let session_ids = calendar.session_ids().collect::<Vec<_>>();
902        let selected_session_id = match (session_required, input.session_id.take()) {
903            (true, Some(value)) => {
904                if !session_ids.contains(&value.as_str()) {
905                    return Err(CalendarError::InvalidConfiguration(format!(
906                        "unknown calendar session '{value}'"
907                    )));
908                }
909                Some(value)
910            }
911            (true, None) if session_ids == [DEFAULT_CALENDAR_SESSION_ID] => {
912                Some(DEFAULT_CALENDAR_SESSION_ID.into())
913            }
914            (true, None) => {
915                return Err(CalendarError::InvalidConfiguration(
916                    "a custom-session calendar input requires session_id".into(),
917                ));
918            }
919            (false, Some(_)) => {
920                return Err(CalendarError::InvalidConfiguration(
921                    "day/week calendar inputs cannot select a session".into(),
922                ));
923            }
924            (false, None) => None,
925        };
926        input.session_id = selected_session_id.clone();
927        Ok(Self {
928            series_id,
929            calendar,
930            input,
931            child_seconds,
932            selected_session_id,
933            state: RefCell::new(ConfiguredCalendarState::default()),
934        })
935    }
936
937    pub fn effective_input(&self) -> &CalendarInputSpec {
938        &self.input
939    }
940
941    fn missing(&self, updated: bool) -> ProjectedNamedInput {
942        ProjectedNamedInput {
943            value: Value::Missing(self.input.kind.scalar_type()),
944            updated,
945        }
946    }
947
948    fn period_complete(
949        &self,
950        period: &PeriodAccumulator,
951        start: NaiveDateTime,
952        end: NaiveDateTime,
953        observed_through: NaiveDateTime,
954    ) -> Result<bool, CalendarError> {
955        if observed_through < end {
956            return Ok(false);
957        }
958        let Some(expected) = self.calendar.expected_child_opens(
959            start,
960            end,
961            self.child_seconds,
962            self.input.alignment_offset_seconds,
963        )?
964        else {
965            return Ok(false);
966        };
967        Ok(!expected.is_empty()
968            && expected.len() == period.observed_opens.len()
969            && expected
970                .iter()
971                .all(|open| period.observed_opens.contains(open)))
972    }
973
974    fn previous_day_stats(
975        &self,
976        state: &ConfiguredCalendarState,
977        current: &ResolvedTradingDay,
978        observed_through: NaiveDateTime,
979    ) -> Result<Option<super::CalendarPeriodStats>, CalendarError> {
980        let Some(previous) = self.calendar.previous_trading_day(
981            current.label,
982            self.child_seconds,
983            self.input.alignment_offset_seconds,
984        )?
985        else {
986            return Ok(None);
987        };
988        let Some(period) = state.days.get(&previous.label) else {
989            return Ok(None);
990        };
991        self.period_complete(
992            period,
993            previous.start_utc,
994            previous.end_utc,
995            observed_through,
996        )
997        .map(|complete| complete.then_some(period.stats))
998    }
999
1000    fn previous_week_stats(
1001        &self,
1002        state: &ConfiguredCalendarState,
1003        current: &ResolvedTradingDay,
1004        observed_through: NaiveDateTime,
1005    ) -> Result<Option<super::CalendarPeriodStats>, CalendarError> {
1006        let previous_monday = current
1007            .week_monday
1008            .checked_sub_days(chrono::Days::new(7))
1009            .ok_or(CalendarError::TimestampOverflow)?;
1010        let Some(period) = state.weeks.get(&previous_monday) else {
1011            return Ok(None);
1012        };
1013        let start = self
1014            .calendar
1015            .resolve_trading_day(previous_monday)?
1016            .start_utc;
1017        let end_label = previous_monday
1018            .checked_add_days(chrono::Days::new(7))
1019            .ok_or(CalendarError::TimestampOverflow)?;
1020        let end = self.calendar.resolve_trading_day(end_label)?.start_utc;
1021        self.period_complete(period, start, end, observed_through)
1022            .map(|complete| complete.then_some(period.stats))
1023    }
1024
1025    fn previous_session_stats(
1026        &self,
1027        state: &ConfiguredCalendarState,
1028        session_id: &str,
1029        before: NaiveDateTime,
1030        observed_through: NaiveDateTime,
1031    ) -> Result<Option<super::CalendarPeriodStats>, CalendarError> {
1032        let Some(previous) = self.calendar.previous_occurrence(session_id, before)? else {
1033            return Ok(None);
1034        };
1035        let Some(period) = state.sessions.get(&previous.id) else {
1036            return Ok(None);
1037        };
1038        self.period_complete(
1039            period,
1040            previous.start_utc,
1041            previous.end_utc,
1042            observed_through,
1043        )
1044        .map(|complete| complete.then_some(period.stats))
1045    }
1046
1047    fn retain_bounds(&self, state: &mut ConfiguredCalendarState) {
1048        let keep = self.input.maximum_history.saturating_add(2);
1049        while state.days.len() > keep {
1050            if let Some(oldest) = state.days.keys().next().copied() {
1051                state.days.remove(&oldest);
1052            }
1053        }
1054        while state.weeks.len() > keep {
1055            if let Some(oldest) = state.weeks.keys().next().copied() {
1056                state.weeks.remove(&oldest);
1057            }
1058        }
1059        while state.sessions.len() > keep {
1060            if let Some(oldest) = state.sessions.keys().next().cloned() {
1061                state.sessions.remove(&oldest);
1062                state.opening_bars.remove(&oldest);
1063            }
1064        }
1065    }
1066}
1067
1068impl HistoricalNamedInputProjector for ConfiguredCalendarFeatureProjector {
1069    fn output_type(&self) -> ValueType {
1070        ValueType::optional(self.input.kind.scalar_type())
1071    }
1072
1073    fn project(
1074        &self,
1075        context: NamedInputProjectionContext<'_>,
1076    ) -> Result<ProjectedNamedInput, NamedInputProjectionError> {
1077        let Some(bar) = context
1078            .closed_bars
1079            .iter()
1080            .find(|bar| bar.series_id() == &self.series_id)
1081        else {
1082            return Ok(self.missing(false));
1083        };
1084        let mut state = self.state.borrow_mut();
1085        if state.last_open == Some(bar.open_time()) {
1086            return Ok(ProjectedNamedInput {
1087                value: state
1088                    .retained
1089                    .clone()
1090                    .unwrap_or_else(|| Value::Missing(self.input.kind.scalar_type())),
1091                updated: false,
1092            });
1093        }
1094        let day = self
1095            .calendar
1096            .trading_day_containing(bar.open_time())
1097            .map_err(project_error)?;
1098        let selected_occurrence = match self.selected_session_id.as_deref() {
1099            Some(session_id) => self
1100                .calendar
1101                .selected_occurrence_containing(session_id, bar.open_time())
1102                .map_err(project_error)?,
1103            None => None,
1104        };
1105        match self.input.kind {
1106            CalendarFeatureKind::PreviousDayHigh | CalendarFeatureKind::PreviousDayLow => {
1107                state.days.entry(day.label).or_default().observe(bar);
1108            }
1109            CalendarFeatureKind::PreviousWeekHigh | CalendarFeatureKind::PreviousWeekLow => {
1110                state.weeks.entry(day.week_monday).or_default().observe(bar);
1111            }
1112            CalendarFeatureKind::PreviousSessionHigh | CalendarFeatureKind::PreviousSessionLow => {
1113                if let Some(occurrence) = selected_occurrence.as_ref() {
1114                    state
1115                        .sessions
1116                        .entry(occurrence.id.clone())
1117                        .or_default()
1118                        .observe(bar);
1119                }
1120            }
1121            CalendarFeatureKind::OpeningRangeHighSoFar
1122            | CalendarFeatureKind::OpeningRangeLowSoFar
1123            | CalendarFeatureKind::OpeningRangeHighFinal
1124            | CalendarFeatureKind::OpeningRangeLowFinal => {
1125                if let Some(occurrence) = selected_occurrence.as_ref() {
1126                    let opening_end = self
1127                        .calendar
1128                        .opening_range_end(occurrence, self.input.opening_range_minutes)
1129                        .map_err(project_error)?;
1130                    if bar.open_time() >= occurrence.start_utc && bar.open_time() < opening_end {
1131                        state
1132                            .opening_bars
1133                            .entry(occurrence.id.clone())
1134                            .or_default()
1135                            .push(CalendarBar {
1136                                open_utc: bar.open_time(),
1137                                close_utc: bar.close_time(),
1138                                available_at: context.observed_through,
1139                                high: bar.high(),
1140                                low: bar.low(),
1141                            });
1142                    }
1143                }
1144            }
1145            _ => {}
1146        }
1147        self.retain_bounds(&mut state);
1148
1149        let value = match self.input.kind {
1150            CalendarFeatureKind::LocalSecondOfDay => {
1151                let local = Utc
1152                    .from_utc_datetime(&bar.open_time())
1153                    .with_timezone(&self.calendar.timezone);
1154                Value::Integer(i64::from(local.time().num_seconds_from_midnight()))
1155            }
1156            CalendarFeatureKind::SessionMembership => {
1157                let instant = match self.input.time_basis {
1158                    CalendarTimeBasis::SourceOpen => bar.open_time(),
1159                    CalendarTimeBasis::DecisionTime => context.observed_through,
1160                };
1161                let session_id = self
1162                    .selected_session_id
1163                    .as_deref()
1164                    .expect("session admitted");
1165                Value::Bool(
1166                    self.calendar
1167                        .selected_occurrence_containing(session_id, instant)
1168                        .map_err(project_error)?
1169                        .is_some(),
1170                )
1171            }
1172            CalendarFeatureKind::SessionElapsedSeconds => selected_occurrence
1173                .as_ref()
1174                .map(|occurrence| {
1175                    Value::Integer((bar.open_time() - occurrence.start_utc).num_seconds())
1176                })
1177                .unwrap_or(Value::Missing(ScalarType::Integer)),
1178            CalendarFeatureKind::PreviousSessionHigh | CalendarFeatureKind::PreviousSessionLow => {
1179                let stats = self
1180                    .previous_session_stats(
1181                        &state,
1182                        self.selected_session_id
1183                            .as_deref()
1184                            .expect("session admitted"),
1185                        context.observed_through,
1186                        context.observed_through,
1187                    )
1188                    .map_err(project_error)?;
1189                stats
1190                    .map(|stats| {
1191                        if self.input.kind == CalendarFeatureKind::PreviousSessionHigh {
1192                            Value::Price(stats.high)
1193                        } else {
1194                            Value::Price(stats.low)
1195                        }
1196                    })
1197                    .unwrap_or(Value::Missing(ScalarType::Price))
1198            }
1199            CalendarFeatureKind::PreviousDayHigh | CalendarFeatureKind::PreviousDayLow => {
1200                let stats = self
1201                    .previous_day_stats(&state, &day, context.observed_through)
1202                    .map_err(project_error)?;
1203                stats
1204                    .map(|stats| {
1205                        if self.input.kind == CalendarFeatureKind::PreviousDayHigh {
1206                            Value::Price(stats.high)
1207                        } else {
1208                            Value::Price(stats.low)
1209                        }
1210                    })
1211                    .unwrap_or(Value::Missing(ScalarType::Price))
1212            }
1213            CalendarFeatureKind::PreviousWeekHigh | CalendarFeatureKind::PreviousWeekLow => {
1214                let stats = self
1215                    .previous_week_stats(&state, &day, context.observed_through)
1216                    .map_err(project_error)?;
1217                stats
1218                    .map(|stats| {
1219                        if self.input.kind == CalendarFeatureKind::PreviousWeekHigh {
1220                            Value::Price(stats.high)
1221                        } else {
1222                            Value::Price(stats.low)
1223                        }
1224                    })
1225                    .unwrap_or(Value::Missing(ScalarType::Price))
1226            }
1227            CalendarFeatureKind::OpeningRangeHighSoFar
1228            | CalendarFeatureKind::OpeningRangeLowSoFar
1229            | CalendarFeatureKind::OpeningRangeHighFinal
1230            | CalendarFeatureKind::OpeningRangeLowFinal => {
1231                let opening_occurrence = self
1232                    .calendar
1233                    .latest_occurrence(
1234                        self.selected_session_id
1235                            .as_deref()
1236                            .expect("session admitted"),
1237                        context.observed_through,
1238                    )
1239                    .map_err(project_error)?;
1240                let opening = match opening_occurrence.as_ref() {
1241                    Some(occurrence) => configured_opening_range(
1242                        &self.calendar,
1243                        occurrence,
1244                        self.input.opening_range_minutes,
1245                        self.child_seconds,
1246                        self.input.alignment_offset_seconds,
1247                        state
1248                            .opening_bars
1249                            .get(&occurrence.id)
1250                            .map(Vec::as_slice)
1251                            .unwrap_or_default(),
1252                        context.observed_through,
1253                    )
1254                    .map_err(project_error)?,
1255                    None => None,
1256                };
1257                match self.input.kind {
1258                    CalendarFeatureKind::OpeningRangeHighSoFar => opening
1259                        .map(|range| Value::Price(range.high))
1260                        .unwrap_or(Value::Missing(ScalarType::Price)),
1261                    CalendarFeatureKind::OpeningRangeLowSoFar => opening
1262                        .map(|range| Value::Price(range.low))
1263                        .unwrap_or(Value::Missing(ScalarType::Price)),
1264                    CalendarFeatureKind::OpeningRangeHighFinal => opening
1265                        .filter(|range| range.final_value)
1266                        .map(|range| Value::Price(range.high))
1267                        .unwrap_or(Value::Missing(ScalarType::Price)),
1268                    CalendarFeatureKind::OpeningRangeLowFinal => opening
1269                        .filter(|range| range.final_value)
1270                        .map(|range| Value::Price(range.low))
1271                        .unwrap_or(Value::Missing(ScalarType::Price)),
1272                    _ => unreachable!(),
1273                }
1274            }
1275            CalendarFeatureKind::PastSameSlotRangeRatio
1276            | CalendarFeatureKind::PastSameSlotCount => {
1277                let Some(occurrence) = selected_occurrence.as_ref() else {
1278                    state.last_open = Some(bar.open_time());
1279                    state.retained = Some(Value::Missing(self.input.kind.scalar_type()));
1280                    return Ok(self.missing(true));
1281                };
1282                let elapsed = (bar.open_time() - occurrence.start_utc).num_seconds();
1283                let slot = u32::try_from(elapsed / self.child_seconds)
1284                    .map_err(|_| NamedInputProjectionError::new("calendar slot exceeds u32"))?;
1285                let key = (occurrence.id.session_id.clone(), slot);
1286                let prior = state.slot_ranges.get(&key).cloned().unwrap_or_default();
1287                let value = if self.input.kind == CalendarFeatureKind::PastSameSlotCount {
1288                    Value::Integer(i64::try_from(prior.len()).map_err(|_| {
1289                        NamedInputProjectionError::new("same-slot history exceeds i64")
1290                    })?)
1291                } else if prior.len() < self.input.maximum_history {
1292                    Value::Missing(ScalarType::Ratio)
1293                } else {
1294                    let mean = prior.iter().sum::<f64>() / prior.len() as f64;
1295                    if mean > 0.0 {
1296                        Value::Ratio((bar.high() - bar.low()) / mean)
1297                    } else {
1298                        Value::Missing(ScalarType::Ratio)
1299                    }
1300                };
1301                let history = state.slot_ranges.entry(key).or_default();
1302                history.push_back(bar.high() - bar.low());
1303                while history.len() > self.input.maximum_history {
1304                    history.pop_front();
1305                }
1306                value
1307            }
1308        };
1309        state.last_open = Some(bar.open_time());
1310        state.retained = Some(value.clone());
1311        Ok(ProjectedNamedInput {
1312            value,
1313            updated: true,
1314        })
1315    }
1316}
1317
1318fn configured_opening_range(
1319    calendar: &ConfiguredTradingCalendar,
1320    occurrence: &ResolvedSessionOccurrence,
1321    opening_range_minutes: u32,
1322    child_seconds: i64,
1323    alignment_offset_seconds: i64,
1324    bars: &[CalendarBar],
1325    observed_through: NaiveDateTime,
1326) -> Result<Option<OpeningRange>, CalendarError> {
1327    let end = calendar.opening_range_end(occurrence, opening_range_minutes)?;
1328    let Some(expected) = calendar.expected_child_opens(
1329        occurrence.start_utc,
1330        end,
1331        child_seconds,
1332        alignment_offset_seconds,
1333    )?
1334    else {
1335        return Ok(None);
1336    };
1337    let mut revealed = bars
1338        .iter()
1339        .filter(|bar| bar.available_at <= observed_through)
1340        .collect::<Vec<_>>();
1341    revealed.sort_by_key(|bar| bar.open_utc);
1342    if revealed.is_empty() {
1343        return Ok(None);
1344    }
1345    let high = revealed
1346        .iter()
1347        .map(|bar| bar.high)
1348        .fold(f64::NEG_INFINITY, f64::max);
1349    let low = revealed
1350        .iter()
1351        .map(|bar| bar.low)
1352        .fold(f64::INFINITY, f64::min);
1353    let observed = revealed
1354        .iter()
1355        .map(|bar| bar.open_utc)
1356        .collect::<BTreeSet<_>>();
1357    let final_value = !expected.is_empty()
1358        && observed_through >= end
1359        && expected.len() == observed.len()
1360        && expected.iter().all(|open| observed.contains(open));
1361    Ok(Some(OpeningRange {
1362        high,
1363        low,
1364        complete_children: observed.len(),
1365        required_children: expected.len(),
1366        final_value,
1367    }))
1368}
1369
1370fn estimated_projector_bytes(
1371    calendar: &ConfiguredTradingCalendar,
1372    input: &CalendarInputSpec,
1373    child_seconds: i64,
1374) -> Result<usize, CalendarError> {
1375    let maximum_slots = 176_400_i64
1376        .checked_add(child_seconds - 1)
1377        .and_then(|value| value.checked_div(child_seconds))
1378        .and_then(|value| usize::try_from(value).ok())
1379        .ok_or_else(|| CalendarError::ResourceLimit("calendar slot bound overflowed".into()))?;
1380    let retained_periods = input
1381        .maximum_history
1382        .checked_add(2)
1383        .ok_or_else(|| CalendarError::ResourceLimit("calendar history bound overflowed".into()))?;
1384    let observed_bytes = retained_periods
1385        .checked_mul(maximum_slots)
1386        .and_then(|value| value.checked_mul(std::mem::size_of::<NaiveDateTime>() + 32))
1387        .and_then(|value| value.checked_mul(3));
1388    let slot_bytes = maximum_slots
1389        .checked_mul(input.maximum_history)
1390        .and_then(|value| value.checked_mul(std::mem::size_of::<f64>() + 8));
1391    let opening_bytes = retained_periods
1392        .checked_mul(maximum_slots)
1393        .and_then(|value| value.checked_mul(std::mem::size_of::<CalendarBar>() + 16));
1394    let temporary_bytes = maximum_slots
1395        .checked_mul(std::mem::size_of::<NaiveDateTime>() + 16)
1396        .and_then(|value| value.checked_mul(2));
1397    let identity_element = std::mem::size_of::<SessionOccurrenceId>()
1398        .checked_add(calendar.id.len() + MAX_CALENDAR_ID_BYTES + 32)
1399        .ok_or_else(|| {
1400            CalendarError::ResourceLimit("calendar identity byte bound overflowed".into())
1401        })?;
1402    let identity_bytes = retained_periods.checked_mul(identity_element);
1403    observed_bytes
1404        .and_then(|value| slot_bytes.and_then(|slots| value.checked_add(slots)))
1405        .and_then(|value| opening_bytes.and_then(|opening| value.checked_add(opening)))
1406        .and_then(|value| temporary_bytes.and_then(|temporary| value.checked_add(temporary)))
1407        .and_then(|value| identity_bytes.and_then(|identity| value.checked_add(identity)))
1408        .ok_or_else(|| CalendarError::ResourceLimit("calendar state bound overflowed".into()))
1409}
1410
1411fn validate_id(kind: &str, value: &str) -> Result<(), CalendarError> {
1412    if value.is_empty()
1413        || value.len() > MAX_CALENDAR_ID_BYTES
1414        || !value
1415            .bytes()
1416            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
1417    {
1418        return Err(CalendarError::InvalidConfiguration(format!(
1419            "{kind} ID must be a bounded ASCII identifier"
1420        )));
1421    }
1422    Ok(())
1423}
1424
1425fn validate_span(id: &str, span: &SessionSpanSpec) -> Result<(), CalendarError> {
1426    if let SessionSpanSpec::Timed {
1427        start,
1428        end,
1429        end_day_offset,
1430    } = span
1431        && (*end_day_offset > 1 || (*end_day_offset == 0 && end <= start))
1432    {
1433        return Err(CalendarError::InvalidConfiguration(format!(
1434            "session '{id}' has a non-positive or unsupported timed span"
1435        )));
1436    }
1437    Ok(())
1438}
1439
1440fn validate_market(
1441    market: &MarketScheduleSpec,
1442    limits: CalendarAdmissionLimits,
1443) -> Result<(), CalendarError> {
1444    if let MarketScheduleSpec::Weekly {
1445        intervals,
1446        exceptions,
1447    } = market
1448    {
1449        if intervals.len() > limits.max_market_intervals
1450            || exceptions.len() > limits.max_exceptions
1451            || exceptions
1452                .values()
1453                .any(|items| items.len() > limits.max_market_intervals)
1454        {
1455            return Err(CalendarError::ResourceLimit(
1456                "market schedule exceeds admission".into(),
1457            ));
1458        }
1459        for interval in intervals {
1460            if interval.weekday > 6
1461                || interval.end_day_offset > 1
1462                || (interval.end_day_offset == 0 && interval.end <= interval.start)
1463            {
1464                return Err(CalendarError::InvalidConfiguration(
1465                    "market interval is invalid".into(),
1466                ));
1467            }
1468        }
1469        for interval in exceptions.values().flatten() {
1470            if interval.end_day_offset > 1
1471                || (interval.end_day_offset == 0 && interval.end <= interval.start)
1472            {
1473                return Err(CalendarError::InvalidConfiguration(
1474                    "market exception interval is invalid".into(),
1475                ));
1476            }
1477        }
1478    }
1479    Ok(())
1480}
1481
1482fn resolve_session_local(
1483    timezone: Tz,
1484    local: NaiveDateTime,
1485    open: bool,
1486) -> Result<NaiveDateTime, CalendarError> {
1487    let mut probe = local;
1488    for _ in 0..=10_800 {
1489        match timezone.from_local_datetime(&probe) {
1490            chrono::LocalResult::Single(value) => {
1491                return Ok(value.with_timezone(&Utc).naive_utc());
1492            }
1493            chrono::LocalResult::Ambiguous(first, second) => {
1494                let first = first.with_timezone(&Utc).naive_utc();
1495                let second = second.with_timezone(&Utc).naive_utc();
1496                return Ok(if open {
1497                    first.min(second)
1498                } else {
1499                    first.max(second)
1500                });
1501            }
1502            chrono::LocalResult::None => {
1503                probe = probe
1504                    .checked_add_signed(Duration::seconds(1))
1505                    .ok_or(CalendarError::TimestampOverflow)?;
1506            }
1507        }
1508    }
1509    Err(CalendarError::UnresolvedLocalTime)
1510}
1511
1512fn resolve_boundary(timezone: Tz, local: NaiveDateTime) -> Result<NaiveDateTime, CalendarError> {
1513    let mut probe = local;
1514    for _ in 0..=10_800 {
1515        match timezone.from_local_datetime(&probe) {
1516            chrono::LocalResult::Single(value) => {
1517                return Ok(value.with_timezone(&Utc).naive_utc());
1518            }
1519            chrono::LocalResult::Ambiguous(first, second) => {
1520                return Ok(first
1521                    .with_timezone(&Utc)
1522                    .naive_utc()
1523                    .min(second.with_timezone(&Utc).naive_utc()));
1524            }
1525            chrono::LocalResult::None => {
1526                probe = probe
1527                    .checked_add_signed(Duration::seconds(1))
1528                    .ok_or(CalendarError::TimestampOverflow)?;
1529            }
1530        }
1531    }
1532    Err(CalendarError::UnresolvedLocalTime)
1533}
1534
1535fn project_error(error: CalendarError) -> NamedInputProjectionError {
1536    NamedInputProjectionError::new(error.to_string())
1537}
1538
1539fn midnight() -> NaiveTime {
1540    NaiveTime::MIN
1541}
1542
1543fn default_opening_range_minutes() -> u32 {
1544    5
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549    use super::*;
1550
1551    fn time(hour: u32, minute: u32) -> NaiveTime {
1552        NaiveTime::from_hms_opt(hour, minute, 0).unwrap()
1553    }
1554
1555    fn full_day(zone: &str) -> ConfiguredTradingCalendar {
1556        ConfiguredTradingCalendar::new(
1557            TradingCalendarSpec {
1558                id: "main".into(),
1559                timezone: zone.into(),
1560                day_boundary: NaiveTime::MIN,
1561                sessions: SessionScheduleSpec::FullDay,
1562                market: MarketScheduleSpec::Continuous,
1563            },
1564            CalendarAdmissionLimits::default(),
1565        )
1566        .unwrap()
1567    }
1568
1569    #[test]
1570    fn omitted_full_day_is_one_session_and_tracks_dst_day_length() {
1571        let calendar = full_day("America/New_York");
1572        assert_eq!(calendar.session_ids().collect::<Vec<_>>(), ["full_day"]);
1573        let spring = calendar
1574            .resolve_trading_day(NaiveDate::from_ymd_opt(2026, 3, 8).unwrap())
1575            .unwrap();
1576        let fall = calendar
1577            .resolve_trading_day(NaiveDate::from_ymd_opt(2026, 11, 1).unwrap())
1578            .unwrap();
1579        assert_eq!(spring.end_utc - spring.start_utc, Duration::hours(23));
1580        assert_eq!(fall.end_utc - fall.start_utc, Duration::hours(25));
1581    }
1582
1583    #[test]
1584    fn custom_sessions_replace_default_and_can_overlap_by_id() {
1585        let calendar = ConfiguredTradingCalendar::new(
1586            TradingCalendarSpec {
1587                id: "main".into(),
1588                timezone: "UTC".into(),
1589                day_boundary: NaiveTime::MIN,
1590                sessions: SessionScheduleSpec::Custom {
1591                    items: vec![
1592                        NamedSessionSpec {
1593                            id: "morning".into(),
1594                            timezone: None,
1595                            span: SessionSpanSpec::Timed {
1596                                start: time(9, 0),
1597                                end: time(12, 0),
1598                                end_day_offset: 0,
1599                            },
1600                            weekdays: BTreeSet::new(),
1601                        },
1602                        NamedSessionSpec {
1603                            id: "focus".into(),
1604                            timezone: None,
1605                            span: SessionSpanSpec::Timed {
1606                                start: time(11, 0),
1607                                end: time(14, 0),
1608                                end_day_offset: 0,
1609                            },
1610                            weekdays: BTreeSet::new(),
1611                        },
1612                    ],
1613                },
1614                market: MarketScheduleSpec::Continuous,
1615            },
1616            CalendarAdmissionLimits::default(),
1617        )
1618        .unwrap();
1619        assert_eq!(
1620            calendar.session_ids().collect::<Vec<_>>(),
1621            ["morning", "focus"]
1622        );
1623        let day = calendar
1624            .resolve_trading_day(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap())
1625            .unwrap();
1626        let occurrences = calendar.session_occurrences(&day).unwrap();
1627        assert_eq!(occurrences.len(), 2);
1628        assert!(occurrences[1].start_utc < occurrences[0].end_utc);
1629    }
1630
1631    #[test]
1632    fn explicit_empty_custom_sessions_reject() {
1633        let error = ConfiguredTradingCalendar::new(
1634            TradingCalendarSpec {
1635                id: "main".into(),
1636                timezone: "UTC".into(),
1637                day_boundary: NaiveTime::MIN,
1638                sessions: SessionScheduleSpec::Custom { items: vec![] },
1639                market: MarketScheduleSpec::Unspecified,
1640            },
1641            CalendarAdmissionLimits::default(),
1642        )
1643        .unwrap_err();
1644        assert!(error.to_string().contains("cannot be empty"));
1645    }
1646
1647    struct UnusedSeries;
1648
1649    impl crate::strategy::HistoricalSeriesView for UnusedSeries {
1650        fn latest_bar(
1651            &self,
1652            _: &SeriesId,
1653        ) -> Result<Option<&crate::strategy::ClosedBar>, crate::strategy::SeriesViewError> {
1654            unreachable!()
1655        }
1656
1657        fn bars(
1658            &self,
1659            _: &SeriesId,
1660            _: usize,
1661        ) -> Result<crate::strategy::BarWindow<'_>, crate::strategy::SeriesViewError> {
1662            unreachable!()
1663        }
1664
1665        fn warmup(
1666            &self,
1667            _: &SeriesId,
1668        ) -> Result<crate::strategy::SeriesWarmupState, crate::strategy::SeriesViewError> {
1669            unreachable!()
1670        }
1671    }
1672
1673    struct UnusedObservations;
1674
1675    impl crate::strategy::HistoricalObservationView for UnusedObservations {
1676        fn observations(&self, _: usize) -> crate::strategy::ObservationWindow<'_> {
1677            unreachable!()
1678        }
1679
1680        fn for_symbol<'a>(
1681            &'a self,
1682            _: &'a str,
1683            _: usize,
1684        ) -> crate::strategy::ObservationSelection<'a> {
1685            unreachable!()
1686        }
1687
1688        fn latest_zone(
1689            &self,
1690            _: &crate::strategy::ZoneId,
1691        ) -> Option<&crate::strategy::StrategyObservation> {
1692            unreachable!()
1693        }
1694
1695        fn omitted(&self) -> u64 {
1696            0
1697        }
1698    }
1699
1700    fn project_bar(
1701        projector: &ConfiguredCalendarFeatureProjector,
1702        bar: &crate::strategy::ClosedBar,
1703    ) -> Value {
1704        projector
1705            .project(NamedInputProjectionContext {
1706                observed_through: bar.close_time(),
1707                closed_bars: std::slice::from_ref(bar),
1708                observations: &[],
1709                series: &UnusedSeries,
1710                observation_history: &UnusedObservations,
1711            })
1712            .unwrap()
1713            .value
1714    }
1715
1716    #[test]
1717    fn previous_day_and_named_session_have_independent_aggregates() {
1718        let calendar = TradingCalendarSpec {
1719            id: "main".into(),
1720            timezone: "UTC".into(),
1721            day_boundary: NaiveTime::MIN,
1722            sessions: SessionScheduleSpec::Custom {
1723                items: vec![
1724                    NamedSessionSpec {
1725                        id: "morning".into(),
1726                        timezone: None,
1727                        span: SessionSpanSpec::Timed {
1728                            start: time(0, 0),
1729                            end: time(12, 0),
1730                            end_day_offset: 0,
1731                        },
1732                        weekdays: BTreeSet::new(),
1733                    },
1734                    NamedSessionSpec {
1735                        id: "afternoon".into(),
1736                        timezone: None,
1737                        span: SessionSpanSpec::Timed {
1738                            start: time(12, 0),
1739                            end: time(0, 0),
1740                            end_day_offset: 1,
1741                        },
1742                        weekdays: BTreeSet::new(),
1743                    },
1744                ],
1745            },
1746            market: MarketScheduleSpec::Continuous,
1747        };
1748        let make_projector = |kind, session_id| {
1749            ConfiguredCalendarFeatureProjector::new(
1750                SeriesId::new("primary").unwrap(),
1751                ConfiguredTradingCalendar::new(
1752                    calendar.clone(),
1753                    CalendarAdmissionLimits::default(),
1754                )
1755                .unwrap(),
1756                CalendarInputSpec {
1757                    calendar_id: "main".into(),
1758                    kind,
1759                    session_id,
1760                    time_basis: CalendarTimeBasis::SourceOpen,
1761                    opening_range_minutes: 5,
1762                    child_seconds: 3600,
1763                    alignment_offset_seconds: 0,
1764                    maximum_history: 2,
1765                },
1766            )
1767            .unwrap()
1768        };
1769        let day_projector = make_projector(CalendarFeatureKind::PreviousDayHigh, None);
1770        let session_projector = make_projector(
1771            CalendarFeatureKind::PreviousSessionHigh,
1772            Some("morning".into()),
1773        );
1774        let start = NaiveDate::from_ymd_opt(2026, 6, 1)
1775            .unwrap()
1776            .and_hms_opt(0, 0, 0)
1777            .unwrap();
1778        let series = SeriesId::new("primary").unwrap();
1779        for hour in 0..37 {
1780            let open = start + Duration::hours(hour);
1781            let high = if hour < 12 {
1782                10.0
1783            } else if hour < 24 {
1784                100.0
1785            } else {
1786                20.0
1787            };
1788            let bar = crate::strategy::ClosedBar::for_test(
1789                series.clone(),
1790                "EURUSD",
1791                open,
1792                open + Duration::hours(1),
1793                high,
1794                1.0,
1795            );
1796            let day_value = project_bar(&day_projector, &bar);
1797            let session_value = project_bar(&session_projector, &bar);
1798            if hour == 36 {
1799                assert_eq!(day_value, Value::Price(100.0));
1800                assert_eq!(session_value, Value::Price(20.0));
1801            }
1802        }
1803    }
1804
1805    #[test]
1806    fn boundary_gap_uses_first_existing_second() {
1807        let calendar = ConfiguredTradingCalendar::new(
1808            TradingCalendarSpec {
1809                id: "main".into(),
1810                timezone: "America/New_York".into(),
1811                day_boundary: NaiveTime::from_hms_opt(2, 30, 15).unwrap(),
1812                sessions: SessionScheduleSpec::FullDay,
1813                market: MarketScheduleSpec::Continuous,
1814            },
1815            CalendarAdmissionLimits::default(),
1816        )
1817        .unwrap();
1818        let day = calendar
1819            .resolve_trading_day(NaiveDate::from_ymd_opt(2026, 3, 8).unwrap())
1820            .unwrap();
1821        assert_eq!(
1822            day.start_utc.time(),
1823            NaiveTime::from_hms_opt(7, 0, 0).unwrap()
1824        );
1825    }
1826}