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}