379 lines
12 KiB
Rust
379 lines
12 KiB
Rust
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<u32> {
|
|
match self {
|
|
Self::BeforeTrading => None,
|
|
Self::MinuteOfDay(value) => Some(*value),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ScheduleFrequency {
|
|
Daily,
|
|
Weekly {
|
|
weekday: Option<u32>,
|
|
tradingday: Option<i32>,
|
|
},
|
|
Monthly {
|
|
tradingday: i32,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ScheduleRule {
|
|
pub name: String,
|
|
pub stage: ScheduleStage,
|
|
pub frequency: ScheduleFrequency,
|
|
pub time_rule: Option<ScheduleTimeRule>,
|
|
}
|
|
|
|
impl ScheduleRule {
|
|
pub fn daily(name: impl Into<String>, stage: ScheduleStage) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
stage,
|
|
frequency: ScheduleFrequency::Daily,
|
|
time_rule: None,
|
|
}
|
|
}
|
|
|
|
pub fn weekly_by_weekday(name: impl Into<String>, 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<String>,
|
|
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<String>, 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<NaiveTime>,
|
|
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<NaiveDate> {
|
|
let iso = date.iso_week();
|
|
self.calendar
|
|
.iter()
|
|
.filter(|candidate| candidate.iso_week() == iso)
|
|
.collect()
|
|
}
|
|
|
|
fn month_dates(&self, date: NaiveDate) -> Vec<NaiveDate> {
|
|
self.calendar
|
|
.iter()
|
|
.filter(|candidate| {
|
|
candidate.year() == date.year() && candidate.month() == date.month()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn matches_time(
|
|
&self,
|
|
stage: ScheduleStage,
|
|
current_time: Option<NaiveTime>,
|
|
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<NaiveTime> {
|
|
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<NaiveDate> {
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
assert!(mismatch.is_empty());
|
|
}
|
|
}
|