diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index fe5f4f1..502cf8d 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -9,6 +9,7 @@ use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; +use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler}; use crate::strategy::{Strategy, StrategyContext}; #[derive(Debug, Error)] @@ -124,6 +125,8 @@ where F: FnMut(&BacktestDayProgress), { let mut portfolio = PortfolioState::new(self.config.initial_cash); + let scheduler_calendar = self.data.calendar().clone(); + let scheduler = Scheduler::new(&scheduler_calendar); let execution_dates = self .data .calendar() @@ -202,8 +205,17 @@ where data: &self.data, portfolio: &portfolio, }; + let schedule_rules = self.strategy.schedule_rules(); self.strategy.before_trading(&daily_context)?; - let auction_decision = self.strategy.open_auction(&daily_context)?; + let mut auction_decision = collect_scheduled_decisions( + &mut self.strategy, + &scheduler, + execution_date, + ScheduleStage::OpenAuction, + &schedule_rules, + &daily_context, + )?; + auction_decision.merge_from(self.strategy.open_auction(&daily_context)?); let mut report = self.broker.execute( execution_date, &mut portfolio, @@ -211,7 +223,7 @@ where &auction_decision, )?; - let decision = decision_slot + let mut decision = decision_slot .map(|(decision_idx, decision_date)| { self.strategy.on_day(&StrategyContext { execution_date, @@ -223,6 +235,20 @@ where }) .transpose()? .unwrap_or_default(); + decision.merge_from(collect_scheduled_decisions( + &mut self.strategy, + &scheduler, + execution_date, + ScheduleStage::OnDay, + &schedule_rules, + &StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio: &portfolio, + }, + )?); let intraday_report = self.broker @@ -635,6 +661,21 @@ where } } +fn collect_scheduled_decisions( + strategy: &mut S, + scheduler: &Scheduler<'_>, + execution_date: NaiveDate, + stage: ScheduleStage, + rules: &[ScheduleRule], + ctx: &StrategyContext<'_>, +) -> Result { + let mut combined = crate::strategy::StrategyDecision::default(); + for rule in scheduler.triggered_rules(execution_date, stage, rules) { + combined.merge_from(strategy.on_scheduled(ctx, rule)?); + } + Ok(combined) +} + mod date_format { use chrono::NaiveDate; use serde::Serializer; diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index de0ed9d..9c739bc 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod metrics; pub mod platform_expr_strategy; pub mod portfolio; pub mod rules; +pub mod scheduler; pub mod strategy; pub mod strategy_ai; pub mod universe; @@ -31,6 +32,7 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use platform_expr_strategy::{PlatformExprStrategy, PlatformExprStrategyConfig}; pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; +pub use scheduler::{ScheduleFrequency, ScheduleRule, ScheduleStage, Scheduler}; pub use strategy::{ CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy, OrderIntent, Strategy, StrategyContext, StrategyDecision, diff --git a/crates/fidc-core/src/scheduler.rs b/crates/fidc-core/src/scheduler.rs new file mode 100644 index 0000000..3ef58aa --- /dev/null +++ b/crates/fidc-core/src/scheduler.rs @@ -0,0 +1,201 @@ +use chrono::{Datelike, NaiveDate}; + +use crate::calendar::TradingCalendar; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScheduleStage { + OpenAuction, + OnDay, +} + +#[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, +} + +impl ScheduleRule { + pub fn daily(name: impl Into, stage: ScheduleStage) -> Self { + Self { + name: name.into(), + stage, + frequency: ScheduleFrequency::Daily, + } + } + + 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, + }, + } + } + + 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), + }, + } + } + + pub fn monthly(name: impl Into, tradingday: i32, stage: ScheduleStage) -> Self { + Self { + name: name.into(), + stage, + frequency: ScheduleFrequency::Monthly { tradingday }, + } + } +} + +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> { + rules + .iter() + .filter(|rule| rule.stage == stage && self.matches(date, rule)) + .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 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; + + use super::{ScheduleRule, ScheduleStage, 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")); + } +} diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index fb926ca..0966355 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -11,10 +11,21 @@ use crate::data::{DataSet, PriceField}; use crate::engine::BacktestError; use crate::events::OrderSide; use crate::portfolio::PortfolioState; +use crate::scheduler::ScheduleRule; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; pub trait Strategy { fn name(&self) -> &str; + fn schedule_rules(&self) -> Vec { + Vec::new() + } + fn on_scheduled( + &mut self, + _ctx: &StrategyContext<'_>, + _rule: &ScheduleRule, + ) -> Result { + Ok(StrategyDecision::default()) + } fn before_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { Ok(()) } @@ -51,6 +62,26 @@ pub struct StrategyDecision { pub diagnostics: Vec, } +impl StrategyDecision { + pub fn merge_from(&mut self, mut other: StrategyDecision) { + self.rebalance |= other.rebalance; + self.target_weights.append(&mut other.target_weights); + self.exit_symbols.append(&mut other.exit_symbols); + self.order_intents.append(&mut other.order_intents); + self.notes.append(&mut other.notes); + self.diagnostics.append(&mut other.diagnostics); + } + + pub fn is_empty(&self) -> bool { + !self.rebalance + && self.target_weights.is_empty() + && self.exit_symbols.is_empty() + && self.order_intents.is_empty() + && self.notes.is_empty() + && self.diagnostics.is_empty() + } +} + #[derive(Debug, Clone)] pub enum OrderIntent { TargetValue { diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 562048b..e4b5a50 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -6,7 +6,8 @@ use chrono::NaiveDate; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - Instrument, PriceField, Strategy, StrategyContext, StrategyDecision, + Instrument, PriceField, ScheduleRule, ScheduleStage, Strategy, StrategyContext, + StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -115,6 +116,42 @@ impl Strategy for AuctionOrderStrategy { } } +struct ScheduledProbeStrategy { + log: Rc>>, +} + +impl Strategy for ScheduledProbeStrategy { + fn name(&self) -> &str { + "scheduled-probe" + } + + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("daily_auction", ScheduleStage::OpenAuction), + ScheduleRule::weekly_by_weekday("friday_on_day", 5, ScheduleStage::OnDay), + ScheduleRule::monthly("first_trading_day_on_day", 1, ScheduleStage::OnDay), + ] + } + + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + rule: &ScheduleRule, + ) -> Result { + self.log + .borrow_mut() + .push(format!("scheduled:{}:{}", rule.name, ctx.execution_date)); + Ok(StrategyDecision::default()) + } + + fn on_day( + &mut self, + _ctx: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision::default()) + } +} + #[test] fn engine_runs_strategy_hooks_in_daily_order() { let date1 = d(2025, 1, 2); @@ -381,3 +418,221 @@ fn engine_executes_open_auction_decisions_before_on_day() { assert_eq!(result.fills[0].reason, "auction_buy"); assert_eq!(result.fills[0].quantity, 100); } + +#[test] +fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() { + let date1 = d(2025, 1, 30); + let date2 = d(2025, 1, 31); + let date3 = d(2025, 2, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Anchor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![ + DailyMarketSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-30 09:25:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("open_auction".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: date2, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-31 09:25:00".to_string()), + day_open: 10.1, + open: 10.1, + high: 10.2, + low: 10.0, + close: 10.1, + last_price: 10.1, + bid1: 10.1, + ask1: 10.1, + prev_close: 10.0, + volume: 110_000, + tick_volume: 110_000, + bid1_volume: 110_000, + ask1_volume: 110_000, + trading_phase: Some("open_auction".to_string()), + paused: false, + upper_limit: 11.1, + lower_limit: 9.1, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: date3, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-02-03 09:25:00".to_string()), + day_open: 10.2, + open: 10.2, + high: 10.3, + low: 10.1, + close: 10.2, + last_price: 10.2, + bid1: 10.2, + ask1: 10.2, + prev_close: 10.1, + volume: 120_000, + tick_volume: 120_000, + bid1_volume: 120_000, + ask1_volume: 120_000, + trading_phase: Some("open_auction".to_string()), + paused: false, + upper_limit: 11.2, + lower_limit: 9.2, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: date2, + symbol: "000001.SZ".to_string(), + market_cap_bn: 21.0, + free_float_cap_bn: 19.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: date3, + symbol: "000001.SZ".to_string(), + market_cap_bn: 22.0, + free_float_cap_bn: 20.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date: date1, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: date2, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: date3, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![ + BenchmarkSnapshot { + date: date1, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: date2, + benchmark: "000300.SH".to_string(), + open: 101.0, + close: 101.0, + prev_close: 100.0, + volume: 1_100_000, + }, + BenchmarkSnapshot { + date: date3, + benchmark: "000300.SH".to_string(), + open: 102.0, + close: 102.0, + prev_close: 101.0, + volume: 1_200_000, + }, + ], + ) + .expect("dataset"); + + let log = Rc::new(RefCell::new(Vec::new())); + let strategy = ScheduledProbeStrategy { log: log.clone() }; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::DayOpen, + ); + let mut engine = BacktestEngine::new( + data, + strategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date1), + end_date: Some(date3), + decision_lag_trading_days: 0, + execution_price_field: PriceField::DayOpen, + }, + ); + + engine.run().expect("backtest run"); + + assert_eq!( + log.borrow().as_slice(), + [ + "scheduled:daily_auction:2025-01-30", + "scheduled:first_trading_day_on_day:2025-01-30", + "scheduled:daily_auction:2025-01-31", + "scheduled:friday_on_day:2025-01-31", + "scheduled:daily_auction:2025-02-03", + "scheduled:first_trading_day_on_day:2025-02-03", + ] + ); +}