use chrono::{Datelike, NaiveDate, NaiveTime, Timelike}; use crate::calendar::TradingCalendar; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScheduleStage { BeforeTrading, OpenAuction, Bar, Tick, OnDay, AfterTrading, Settlement, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScheduleTimeRule { BeforeTrading, MinuteOfDay(u32), } impl ScheduleTimeRule { pub fn before_trading() -> Self { Self::BeforeTrading } pub fn market_open(hour: i32, minute: i32) -> Self { let mut minutes_since_midnight = 9 * 60 + 31 + hour * 60 + minute; if minutes_since_midnight > 11 * 60 + 30 { minutes_since_midnight += 90; } Self::MinuteOfDay(minutes_since_midnight.max(0) as u32) } pub fn market_close(hour: i32, minute: i32) -> Self { let mut minutes_since_midnight = 15 * 60 - hour * 60 - minute; if minutes_since_midnight < 13 * 60 { minutes_since_midnight -= 90; } Self::MinuteOfDay(minutes_since_midnight.max(0) as u32) } pub fn physical_time(hour: u32, minute: u32) -> Self { Self::MinuteOfDay(hour.saturating_mul(60).saturating_add(minute)) } pub fn from_time(time: NaiveTime) -> Self { Self::physical_time(time.hour(), time.minute()) } pub fn minute_of_day(&self) -> Option { match self { Self::BeforeTrading => None, Self::MinuteOfDay(value) => Some(*value), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScheduleFrequency { Daily, Weekly { weekday: Option, tradingday: Option, }, Monthly { tradingday: i32, }, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ScheduleRule { pub name: String, pub stage: ScheduleStage, pub frequency: ScheduleFrequency, pub time_rule: Option, } impl ScheduleRule { pub fn daily(name: impl Into, stage: ScheduleStage) -> Self { Self { name: name.into(), stage, frequency: ScheduleFrequency::Daily, time_rule: None, } } pub fn weekly_by_weekday(name: impl Into, weekday: u32, stage: ScheduleStage) -> Self { Self { name: name.into(), stage, frequency: ScheduleFrequency::Weekly { weekday: Some(weekday), tradingday: None, }, time_rule: None, } } pub fn weekly_by_tradingday( name: impl Into, tradingday: i32, stage: ScheduleStage, ) -> Self { Self { name: name.into(), stage, frequency: ScheduleFrequency::Weekly { weekday: None, tradingday: Some(tradingday), }, time_rule: None, } } pub fn monthly(name: impl Into, tradingday: i32, stage: ScheduleStage) -> Self { Self { name: name.into(), stage, frequency: ScheduleFrequency::Monthly { tradingday }, time_rule: None, } } pub fn with_time_rule(mut self, time_rule: ScheduleTimeRule) -> Self { self.time_rule = Some(time_rule); self } } pub struct Scheduler<'a> { calendar: &'a TradingCalendar, } impl<'a> Scheduler<'a> { pub fn new(calendar: &'a TradingCalendar) -> Self { Self { calendar } } pub fn triggered_rules<'r>( &self, date: NaiveDate, stage: ScheduleStage, rules: &'r [ScheduleRule], ) -> Vec<&'r ScheduleRule> { self.triggered_rules_at(date, stage, default_stage_time(stage), rules) } pub fn triggered_rules_at<'r>( &self, date: NaiveDate, stage: ScheduleStage, current_time: Option, rules: &'r [ScheduleRule], ) -> Vec<&'r ScheduleRule> { rules .iter() .filter(|rule| { rule.stage == stage && self.matches(date, rule) && self.matches_time(stage, current_time, rule.time_rule.as_ref()) }) .collect() } fn matches(&self, date: NaiveDate, rule: &ScheduleRule) -> bool { match &rule.frequency { ScheduleFrequency::Daily => true, ScheduleFrequency::Weekly { weekday, tradingday, } => { if let Some(weekday) = weekday { return date.weekday().number_from_monday() == *weekday; } tradingday .and_then(|nth| nth_date_in_period(&self.week_dates(date), nth)) .is_some_and(|matched| matched == date) } ScheduleFrequency::Monthly { tradingday } => { nth_date_in_period(&self.month_dates(date), *tradingday) .is_some_and(|matched| matched == date) } } } fn week_dates(&self, date: NaiveDate) -> Vec { let iso = date.iso_week(); self.calendar .iter() .filter(|candidate| candidate.iso_week() == iso) .collect() } fn month_dates(&self, date: NaiveDate) -> Vec { self.calendar .iter() .filter(|candidate| { candidate.year() == date.year() && candidate.month() == date.month() }) .collect() } fn matches_time( &self, stage: ScheduleStage, current_time: Option, time_rule: Option<&ScheduleTimeRule>, ) -> bool { let Some(time_rule) = time_rule else { return true; }; match time_rule { ScheduleTimeRule::BeforeTrading => stage == ScheduleStage::BeforeTrading, ScheduleTimeRule::MinuteOfDay(expected) => current_time .map(|value| value.hour() * 60 + value.minute()) .is_some_and(|current| current == *expected), } } } pub fn default_stage_time(stage: ScheduleStage) -> Option { match stage { ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")), ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")), ScheduleStage::Tick => None, ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")), ScheduleStage::AfterTrading => Some(NaiveTime::from_hms_opt(15, 0, 0).expect("valid time")), ScheduleStage::Settlement => Some(NaiveTime::from_hms_opt(15, 1, 0).expect("valid time")), } } fn nth_date_in_period(period: &[NaiveDate], nth: i32) -> Option { if nth == 0 || period.is_empty() { return None; } if nth > 0 { period.get((nth - 1) as usize).copied() } else { let idx = period.len().checked_sub((-nth) as usize)?; period.get(idx).copied() } } #[cfg(test)] mod tests { use chrono::{NaiveDate, NaiveTime}; use super::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler}; use crate::calendar::TradingCalendar; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } fn sample_calendar() -> TradingCalendar { TradingCalendar::new(vec![ d(2025, 1, 30), d(2025, 1, 31), d(2025, 2, 3), d(2025, 2, 4), d(2025, 2, 7), ]) } #[test] fn scheduler_matches_daily_weekly_and_monthly_rules() { let calendar = sample_calendar(); let scheduler = Scheduler::new(&calendar); let rules = vec![ ScheduleRule::daily("daily", ScheduleStage::OnDay), ScheduleRule::weekly_by_weekday("friday", 5, ScheduleStage::OnDay), ScheduleRule::weekly_by_tradingday( "last_trading_day_of_week", -1, ScheduleStage::OnDay, ), ScheduleRule::monthly("first_trading_day_of_month", 1, ScheduleStage::OnDay), ]; let jan_31 = scheduler .triggered_rules(d(2025, 1, 31), ScheduleStage::OnDay, &rules) .into_iter() .map(|rule| rule.name.as_str()) .collect::>(); assert!(jan_31.contains(&"daily")); assert!(jan_31.contains(&"friday")); assert!(jan_31.contains(&"last_trading_day_of_week")); assert!(!jan_31.contains(&"first_trading_day_of_month")); let feb_3 = scheduler .triggered_rules(d(2025, 2, 3), ScheduleStage::OnDay, &rules) .into_iter() .map(|rule| rule.name.as_str()) .collect::>(); assert!(feb_3.contains(&"daily")); assert!(feb_3.contains(&"first_trading_day_of_month")); assert!(!feb_3.contains(&"friday")); } #[test] fn scheduler_matches_before_trading_and_minute_level_rules() { let calendar = sample_calendar(); let scheduler = Scheduler::new(&calendar); let rules = vec![ ScheduleRule::daily("before_trading", ScheduleStage::BeforeTrading) .with_time_rule(ScheduleTimeRule::before_trading()), ScheduleRule::daily("market_open", ScheduleStage::OpenAuction) .with_time_rule(ScheduleTimeRule::market_open(0, 0)), ScheduleRule::daily("physical_time", ScheduleStage::OnDay) .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), ScheduleRule::daily("market_close", ScheduleStage::AfterTrading) .with_time_rule(ScheduleTimeRule::market_close(0, 0)), ]; let before = scheduler .triggered_rules_at( d(2025, 2, 3), ScheduleStage::BeforeTrading, Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()), &rules, ) .into_iter() .map(|rule| rule.name.as_str()) .collect::>(); assert_eq!(before, vec!["before_trading"]); let market_open = scheduler .triggered_rules_at( d(2025, 2, 3), ScheduleStage::OpenAuction, Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap()), &rules, ) .into_iter() .map(|rule| rule.name.as_str()) .collect::>(); assert_eq!(market_open, vec!["market_open"]); let intraday = scheduler .triggered_rules_at( d(2025, 2, 3), ScheduleStage::OnDay, Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()), &rules, ) .into_iter() .map(|rule| rule.name.as_str()) .collect::>(); assert_eq!(intraday, vec!["physical_time"]); let close = scheduler .triggered_rules_at( d(2025, 2, 3), ScheduleStage::AfterTrading, Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap()), &rules, ) .into_iter() .map(|rule| rule.name.as_str()) .collect::>(); assert_eq!(close, vec!["market_close"]); let mismatch = scheduler .triggered_rules_at( d(2025, 2, 3), ScheduleStage::OnDay, Some(NaiveTime::from_hms_opt(10, 17, 0).unwrap()), &rules, ) .into_iter() .map(|rule| rule.name.as_str()) .collect::>(); assert!(mismatch.is_empty()); } }