diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e952141..84e786c 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1232,8 +1232,12 @@ where let mut desired_targets = BTreeMap::new(); let mut diagnostics = Vec::new(); for (symbol, weight) in target_weights { - let price = - self.rebalance_valuation_price_with_overrides(date, symbol, data, valuation_prices)?; + let price = self.rebalance_valuation_price_with_overrides( + date, + symbol, + data, + valuation_prices, + )?; let raw_qty = ((equity * weight) / price).floor() as u32; desired_targets.insert( symbol.clone(), @@ -1486,7 +1490,10 @@ where symbols.extend(target_quantities.keys().cloned()); for symbol in &symbols { - let current_qty = portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0); + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); let target_qty = target_quantities.get(symbol).copied().unwrap_or(0); if current_qty <= target_qty { continue; @@ -1529,7 +1536,10 @@ where } for symbol in &symbols { - let current_qty = portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0); + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); let target_qty = target_quantities.get(symbol).copied().unwrap_or(0); if target_qty <= current_qty { continue; diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 6ddae2b..6505a7e 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -13,7 +13,7 @@ use crate::events::{ 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::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time}; use crate::strategy::{Strategy, StrategyContext}; #[derive(Debug, Error)] @@ -274,6 +274,21 @@ where ProcessEventKind::BeforeTrading, "before_trading", )?; + let _ = collect_scheduled_decisions( + &mut self.strategy, + &scheduler, + execution_date, + ScheduleStage::BeforeTrading, + &schedule_rules, + decision_date, + decision_index, + &self.data, + &portfolio, + &pre_open_orders, + &mut process_events, + &mut self.process_event_bus, + default_stage_time(ScheduleStage::BeforeTrading), + )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -315,6 +330,7 @@ where &pre_open_orders, &mut process_events, &mut self.process_event_bus, + default_stage_time(ScheduleStage::OpenAuction), )?; auction_decision.merge_from(self.strategy.open_auction(&StrategyContext { execution_date, @@ -417,6 +433,7 @@ where &on_day_open_orders, &mut process_events, &mut self.process_event_bus, + default_stage_time(ScheduleStage::OnDay), )?); publish_phase_event( &mut self.strategy, @@ -512,6 +529,21 @@ where ProcessEventKind::AfterTrading, "after_trading", )?; + let _ = collect_scheduled_decisions( + &mut self.strategy, + &scheduler, + execution_date, + ScheduleStage::AfterTrading, + &schedule_rules, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_trade_open_orders, + &mut process_events, + &mut self.process_event_bus, + default_stage_time(ScheduleStage::AfterTrading), + )?; let mut close_report = self.broker.after_trading(execution_date); publish_process_events( &mut self.strategy, @@ -583,6 +615,21 @@ where ProcessEventKind::Settlement, "settlement", )?; + let _ = collect_scheduled_decisions( + &mut self.strategy, + &scheduler, + execution_date, + ScheduleStage::Settlement, + &schedule_rules, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_close_open_orders, + &mut process_events, + &mut self.process_event_bus, + default_stage_time(ScheduleStage::Settlement), + )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -1094,9 +1141,10 @@ fn collect_scheduled_decisions( open_orders: &[crate::strategy::OpenOrderView], process_events: &mut Vec, process_event_bus: &mut ProcessEventBus, + current_time: Option, ) -> Result { let mut combined = crate::strategy::StrategyDecision::default(); - for rule in scheduler.triggered_rules(execution_date, stage, rules) { + for rule in scheduler.triggered_rules_at(execution_date, stage, current_time, rules) { publish_phase_event( strategy, process_event_bus, @@ -1214,8 +1262,11 @@ fn publish_process_events( fn stage_label(stage: ScheduleStage) -> &'static str { match stage { + ScheduleStage::BeforeTrading => "before_trading", ScheduleStage::OpenAuction => "open_auction", ScheduleStage::OnDay => "on_day", + ScheduleStage::AfterTrading => "after_trading", + ScheduleStage::Settlement => "settlement", } } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 7f126a7..e40b9e2 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -41,7 +41,9 @@ pub use platform_expr_strategy::{ }; pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; -pub use scheduler::{ScheduleFrequency, ScheduleRule, ScheduleStage, Scheduler}; +pub use scheduler::{ + ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, +}; pub use strategy::{ CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy, OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 0b49da6..85f9e1e 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -8,7 +8,9 @@ use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; use crate::engine::BacktestError; use crate::events::OrderSide; use crate::portfolio::PortfolioState; -use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler}; +use crate::scheduler::{ + ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, +}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -25,48 +27,48 @@ pub enum PlatformScheduleFrequency { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlatformRebalanceSchedule { pub frequency: PlatformScheduleFrequency, + pub time_rule: Option, } impl PlatformRebalanceSchedule { - fn as_schedule_rule(&self) -> ScheduleRule { - match self.frequency { + fn as_schedule_rule(&self, stage: ScheduleStage) -> ScheduleRule { + let rule = match self.frequency { PlatformScheduleFrequency::Weekly { weekday: Some(weekday), .. - } => ScheduleRule::weekly_by_weekday( - "platform_periodic_rebalance", - weekday, - ScheduleStage::OnDay, - ), + } => ScheduleRule::weekly_by_weekday("platform_periodic_rebalance", weekday, stage), PlatformScheduleFrequency::Weekly { tradingday: Some(tradingday), .. - } => ScheduleRule::weekly_by_tradingday( - "platform_periodic_rebalance", - tradingday, - ScheduleStage::OnDay, - ), - PlatformScheduleFrequency::Monthly { tradingday } => ScheduleRule::monthly( - "platform_periodic_rebalance", - tradingday, - ScheduleStage::OnDay, - ), + } => { + ScheduleRule::weekly_by_tradingday("platform_periodic_rebalance", tradingday, stage) + } + PlatformScheduleFrequency::Monthly { tradingday } => { + ScheduleRule::monthly("platform_periodic_rebalance", tradingday, stage) + } PlatformScheduleFrequency::Weekly { weekday: None, tradingday: None, - } => ScheduleRule::weekly_by_weekday( - "platform_periodic_rebalance", - 1, - ScheduleStage::OnDay, - ), + } => ScheduleRule::weekly_by_weekday("platform_periodic_rebalance", 1, stage), + }; + if let Some(time_rule) = self.time_rule.clone() { + rule.with_time_rule(time_rule) + } else { + rule } } - fn matches(&self, calendar: &crate::calendar::TradingCalendar, date: NaiveDate) -> bool { + fn matches( + &self, + calendar: &crate::calendar::TradingCalendar, + date: NaiveDate, + stage: ScheduleStage, + current_time: Option, + ) -> bool { let scheduler = Scheduler::new(calendar); - let rule = self.as_schedule_rule(); + let rule = self.as_schedule_rule(stage); scheduler - .triggered_rules(date, ScheduleStage::OnDay, std::slice::from_ref(&rule)) + .triggered_rules_at(date, stage, current_time, std::slice::from_ref(&rule)) .into_iter() .next() .is_some() @@ -2617,10 +2619,16 @@ impl PlatformExprStrategy { calendar: &crate::calendar::TradingCalendar, date: NaiveDate, ) -> bool { + let stage = match self.config.explicit_action_stage { + PlatformExplicitActionStage::OpenAuction => ScheduleStage::OpenAuction, + PlatformExplicitActionStage::OnDay => ScheduleStage::OnDay, + }; self.config .explicit_action_schedule .as_ref() - .is_none_or(|schedule| schedule.matches(calendar, date)) + .is_none_or(|schedule| { + schedule.matches(calendar, date, stage, default_stage_time(stage)) + }) } fn stock_passes_expr( @@ -3013,7 +3021,12 @@ impl Strategy for PlatformExprStrategy { }; let periodic_rebalance = if self.config.rotation_enabled { if let Some(schedule) = &self.config.rebalance_schedule { - schedule.matches(ctx.data.calendar(), date) + schedule.matches( + ctx.data.calendar(), + date, + ScheduleStage::OnDay, + default_stage_time(ScheduleStage::OnDay), + ) } else { ctx.decision_index % self.config.refresh_rate == 0 } @@ -3224,7 +3237,7 @@ impl Strategy for PlatformExprStrategy { mod tests { use std::collections::BTreeMap; - use chrono::NaiveDate; + use chrono::{NaiveDate, NaiveTime}; use super::{ PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, @@ -3233,8 +3246,8 @@ mod tests { }; use crate::{ BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, Strategy, - StrategyContext, TradingCalendar, + Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage, + ScheduleTimeRule, Strategy, StrategyContext, TradingCalendar, default_stage_time, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -3259,9 +3272,20 @@ mod tests { weekday: Some(5), tradingday: None, }, + time_rule: None, }; - assert!(schedule.matches(&calendar, d(2025, 1, 31))); - assert!(!schedule.matches(&calendar, d(2025, 2, 3))); + assert!(schedule.matches( + &calendar, + d(2025, 1, 31), + ScheduleStage::OnDay, + default_stage_time(ScheduleStage::OnDay), + )); + assert!(!schedule.matches( + &calendar, + d(2025, 2, 3), + ScheduleStage::OnDay, + default_stage_time(ScheduleStage::OnDay), + )); } #[test] @@ -3269,9 +3293,44 @@ mod tests { let calendar = sample_calendar(); let schedule = PlatformRebalanceSchedule { frequency: PlatformScheduleFrequency::Monthly { tradingday: 1 }, + time_rule: None, }; - assert!(schedule.matches(&calendar, d(2025, 2, 3))); - assert!(!schedule.matches(&calendar, d(2025, 2, 4))); + assert!(schedule.matches( + &calendar, + d(2025, 2, 3), + ScheduleStage::OnDay, + default_stage_time(ScheduleStage::OnDay), + )); + assert!(!schedule.matches( + &calendar, + d(2025, 2, 4), + ScheduleStage::OnDay, + default_stage_time(ScheduleStage::OnDay), + )); + } + + #[test] + fn platform_rebalance_schedule_matches_physical_time() { + let calendar = sample_calendar(); + let schedule = PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Weekly { + weekday: Some(5), + tradingday: None, + }, + time_rule: Some(ScheduleTimeRule::physical_time(10, 18)), + }; + assert!(schedule.matches( + &calendar, + d(2025, 1, 31), + ScheduleStage::OnDay, + Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()), + )); + assert!(!schedule.matches( + &calendar, + d(2025, 1, 31), + ScheduleStage::OnDay, + Some(NaiveTime::from_hms_opt(10, 17, 0).unwrap()), + )); } #[test] @@ -3572,15 +3631,13 @@ mod tests { cfg.benchmark_short_ma_days = 1; cfg.benchmark_long_ma_days = 1; cfg.explicit_actions = vec![PlatformTradeAction::TargetPortfolioSmart { - target_weights_expr: - "{\"000001.SZ\": 0.30, \"000002.SZ\": 0.20}".to_string(), + target_weights_expr: "{\"000001.SZ\": 0.30, \"000002.SZ\": 0.20}".to_string(), order_prices_expr: Some( "{\"000001.SZ\": signal_open * 1.01, \"000002.SZ\": benchmark_open / 100.0}" .to_string(), ), valuation_prices_expr: Some( - "{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}" - .to_string(), + "{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}".to_string(), ), when_expr: Some("benchmark_close > 0".to_string()), reason: "platform_target_portfolio_smart".to_string(), @@ -3704,6 +3761,7 @@ mod tests { weekday: Some(1), tradingday: None, }, + time_rule: None, }); cfg.benchmark_short_ma_days = 1; cfg.benchmark_long_ma_days = 1; @@ -3817,6 +3875,7 @@ mod tests { weekday: Some(5), tradingday: None, }, + time_rule: None, }); cfg.benchmark_short_ma_days = 1; cfg.benchmark_long_ma_days = 1; diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 3d7a1b6..0b8146f 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -142,7 +142,11 @@ impl Position { } pub fn set_dividend_receivable(&mut self, value: f64) { - self.dividend_receivable = if value.is_finite() { value.max(0.0) } else { 0.0 }; + self.dividend_receivable = if value.is_finite() { + value.max(0.0) + } else { + 0.0 + }; } pub fn holding_return(&self, price: f64) -> Option { @@ -227,11 +231,12 @@ impl Position { self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 { 0.0 } else { - adjusted_old_quantity * (self.last_price - (self.day_start_price / self.day_split_ratio)) + adjusted_old_quantity + * (self.last_price - (self.day_start_price / self.day_split_ratio)) + self.day_dividend_cash }; - self.trading_pnl = (self.day_trade_quantity_delta as f64 * self.last_price) - - self.day_trade_cost; + self.trading_pnl = + (self.day_trade_quantity_delta as f64 * self.last_price) - self.day_trade_cost; } } @@ -466,8 +471,11 @@ impl PortfolioState { #[cfg(test)] mod tests { use super::*; - use crate::data::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, PriceField}; use crate::Instrument; + use crate::data::{ + BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, + PriceField, + }; use std::collections::BTreeMap; #[test] @@ -494,44 +502,134 @@ mod tests { let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); let mut portfolio = PortfolioState::new(10_000.0); - portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0); - portfolio.update_prices( - prev_date, - &DataSet::from_components( - vec![Instrument { - symbol: "000001.SZ".to_string(), - name: "Test".to_string(), - board: "SZ".to_string(), - round_lot: 100, - listed_at: None, - delisted_at: None, - status: "active".to_string(), - }], - vec![ - DailyMarketSnapshot { - date: prev_date, + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 100, 10.0); + portfolio + .update_prices( + prev_date, + &DataSet::from_components( + vec![Instrument { symbol: "000001.SZ".to_string(), - timestamp: None, - day_open: 10.0, - open: 10.0, - high: 10.0, - low: 10.0, - close: 10.0, - last_price: 10.0, - bid1: 9.99, - ask1: 10.01, - prev_close: 9.8, + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![ + DailyMarketSnapshot { + date: prev_date, + symbol: "000001.SZ".to_string(), + timestamp: None, + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.8, + volume: 1000, + tick_volume: 1000, + bid1_volume: 1000, + ask1_volume: 1000, + trading_phase: None, + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: None, + day_open: 10.5, + open: 10.5, + high: 10.5, + low: 10.5, + close: 10.5, + last_price: 10.5, + bid1: 10.49, + ask1: 10.51, + prev_close: 10.0, + volume: 1000, + tick_volume: 1000, + bid1_volume: 1000, + ask1_volume: 1000, + trading_phase: None, + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + 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, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 999.0, volume: 1000, - tick_volume: 1000, - bid1_volume: 1000, - ask1_volume: 1000, - trading_phase: None, - paused: false, - upper_limit: 11.0, - lower_limit: 9.0, - price_tick: 0.01, - }, - DailyMarketSnapshot { + }], + ) + .expect("dataset"), + PriceField::Close, + ) + .expect("prev close"); + portfolio.begin_trading_day(); + portfolio.add_cash_receivable(CashReceivable { + symbol: "000001.SZ".to_string(), + ex_date: prev_date, + payable_date: date.succ_opt().unwrap(), + amount: 25.0, + reason: "cash_dividend".to_string(), + }); + portfolio + .position_mut_if_exists("000001.SZ") + .expect("position") + .apply_cash_dividend(0.2); + portfolio + .position_mut_if_exists("000001.SZ") + .expect("position") + .record_trade_cost(5.0); + portfolio + .update_prices( + date, + &DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { date, symbol: "000001.SZ".to_string(), timestamp: None, @@ -553,127 +651,41 @@ mod tests { upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, - }, - ], - vec![DailyFactorSnapshot { - date, - symbol: "000001.SZ".to_string(), - market_cap_bn: 50.0, - free_float_cap_bn: 45.0, - pe_ttm: 10.0, - turnover_ratio: Some(1.0), - effective_turnover_ratio: Some(1.0), - extra_factors: BTreeMap::new(), - }], - vec![CandidateEligibility { - date, - 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, - benchmark: "000852.SH".to_string(), - open: 1000.0, - close: 1000.0, - prev_close: 999.0, - volume: 1000, - }], + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + 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, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 999.0, + volume: 1000, + }], + ) + .expect("dataset"), + PriceField::Close, ) - .expect("dataset"), - PriceField::Close, - ) - .expect("prev close"); - portfolio.begin_trading_day(); - portfolio.add_cash_receivable(CashReceivable { - symbol: "000001.SZ".to_string(), - ex_date: prev_date, - payable_date: date.succ_opt().unwrap(), - amount: 25.0, - reason: "cash_dividend".to_string(), - }); - portfolio - .position_mut_if_exists("000001.SZ") - .expect("position") - .apply_cash_dividend(0.2); - portfolio - .position_mut_if_exists("000001.SZ") - .expect("position") - .record_trade_cost(5.0); - portfolio.update_prices( - date, - &DataSet::from_components( - vec![Instrument { - symbol: "000001.SZ".to_string(), - name: "Test".to_string(), - board: "SZ".to_string(), - round_lot: 100, - listed_at: None, - delisted_at: None, - status: "active".to_string(), - }], - vec![DailyMarketSnapshot { - date, - symbol: "000001.SZ".to_string(), - timestamp: None, - day_open: 10.5, - open: 10.5, - high: 10.5, - low: 10.5, - close: 10.5, - last_price: 10.5, - bid1: 10.49, - ask1: 10.51, - prev_close: 10.0, - volume: 1000, - tick_volume: 1000, - bid1_volume: 1000, - ask1_volume: 1000, - trading_phase: None, - paused: false, - upper_limit: 11.0, - lower_limit: 9.0, - price_tick: 0.01, - }], - vec![DailyFactorSnapshot { - date, - symbol: "000001.SZ".to_string(), - market_cap_bn: 50.0, - free_float_cap_bn: 45.0, - pe_ttm: 10.0, - turnover_ratio: Some(1.0), - effective_turnover_ratio: Some(1.0), - extra_factors: BTreeMap::new(), - }], - vec![CandidateEligibility { - date, - 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, - benchmark: "000852.SH".to_string(), - open: 1000.0, - close: 1000.0, - prev_close: 999.0, - volume: 1000, - }], - ) - .expect("dataset"), - PriceField::Close, - ) - .expect("close"); + .expect("close"); let position = portfolio.position("000001.SZ").expect("position"); assert!((position.dividend_receivable - 25.0).abs() < 1e-6); diff --git a/crates/fidc-core/src/scheduler.rs b/crates/fidc-core/src/scheduler.rs index 3ef58aa..869cfcb 100644 --- a/crates/fidc-core/src/scheduler.rs +++ b/crates/fidc-core/src/scheduler.rs @@ -1,11 +1,57 @@ -use chrono::{Datelike, NaiveDate}; +use chrono::{Datelike, NaiveDate, NaiveTime, Timelike}; use crate::calendar::TradingCalendar; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScheduleStage { + BeforeTrading, OpenAuction, 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)] @@ -25,6 +71,7 @@ pub struct ScheduleRule { pub name: String, pub stage: ScheduleStage, pub frequency: ScheduleFrequency, + pub time_rule: Option, } impl ScheduleRule { @@ -33,6 +80,7 @@ impl ScheduleRule { name: name.into(), stage, frequency: ScheduleFrequency::Daily, + time_rule: None, } } @@ -44,6 +92,7 @@ impl ScheduleRule { weekday: Some(weekday), tradingday: None, }, + time_rule: None, } } @@ -59,6 +108,7 @@ impl ScheduleRule { weekday: None, tradingday: Some(tradingday), }, + time_rule: None, } } @@ -67,8 +117,14 @@ impl ScheduleRule { 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> { @@ -85,10 +141,24 @@ impl<'a> Scheduler<'a> { 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)) + .filter(|rule| { + rule.stage == stage + && self.matches(date, rule) + && self.matches_time(stage, current_time, rule.time_rule.as_ref()) + }) .collect() } @@ -129,6 +199,33 @@ impl<'a> Scheduler<'a> { }) .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::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 { @@ -145,9 +242,9 @@ fn nth_date_in_period(period: &[NaiveDate], nth: i32) -> Option { #[cfg(test)] mod tests { - use chrono::NaiveDate; + use chrono::{NaiveDate, NaiveTime}; - use super::{ScheduleRule, ScheduleStage, Scheduler}; + use super::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler}; use crate::calendar::TradingCalendar; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -198,4 +295,80 @@ mod tests { 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()); + } } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 70be213..b3676f0 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -100,7 +100,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "rebalance.weekly / rebalance.monthly".to_string(), - detail: "支持按交易周或交易月调仓,例如 rebalance.weekly(weekday=5).at([\"10:18\"])、rebalance.weekly(tradingday=-1).at([\"10:18\"])、rebalance.monthly(tradingday=1).at([\"10:18\"])。当前这些调度规则会在 on_day 阶段触发。".to_string(), + detail: "支持按交易周或交易月调仓,例如 rebalance.weekly(weekday=5).at([\"10:18\"])、rebalance.weekly(tradingday=-1).at([\"10:18\"])、rebalance.monthly(tradingday=1).at([\"10:18\"])。`.at([...])` 的最后一个时刻会编进分钟级 schedule/time_rule;当前平台把 on_day 近似到 10:18,把 open_auction 近似到 09:31。".to_string(), }, ManualSection { title: "selection.market_cap_band / selection.limit / ordering.rank_by / ordering.rank_expr".to_string(), @@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "trading.rotation / order.* / cancel.*".to_string(), - detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily() / trading.schedule.weekly(weekday=5) / trading.schedule.weekly(tradingday=-1) / trading.schedule.monthly(tradingday=1) 指定触发频率,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。其中 order.target_shares(...) 对应 rqalpha 的 order_to,order.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), + detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。其中 order.target_shares(...) 对应 rqalpha 的 order_to,order.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), }, ManualSection { title: "when / unless / else".to_string(), diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index a4df8a2..f635dc6 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -6,8 +6,8 @@ use chrono::NaiveDate; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - Instrument, OrderIntent, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, Strategy, - StrategyContext, StrategyDecision, + Instrument, OrderIntent, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, + ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -147,9 +147,14 @@ impl Strategy for ScheduledProbeStrategy { 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), + ScheduleRule::daily("daily_before_trading", ScheduleStage::BeforeTrading) + .with_time_rule(ScheduleTimeRule::before_trading()), + ScheduleRule::daily("daily_market_open", ScheduleStage::OpenAuction) + .with_time_rule(ScheduleTimeRule::market_open(0, 0)), + ScheduleRule::weekly_by_weekday("friday_on_day", 5, ScheduleStage::OnDay) + .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), + ScheduleRule::monthly("first_trading_day_on_day", 1, ScheduleStage::OnDay) + .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), ] } @@ -898,24 +903,27 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() { assert_eq!( log.borrow().as_slice(), [ - "scheduled:daily_auction:2025-01-30", + "scheduled:daily_before_trading:2025-01-30", + "scheduled:daily_market_open:2025-01-30", "scheduled:first_trading_day_on_day:2025-01-30", - "scheduled:daily_auction:2025-01-31", + "scheduled:daily_before_trading:2025-01-31", + "scheduled:daily_market_open:2025-01-31", "scheduled:friday_on_day:2025-01-31", - "scheduled:daily_auction:2025-02-03", + "scheduled:daily_before_trading:2025-02-03", + "scheduled:daily_market_open:2025-02-03", "scheduled:first_trading_day_on_day:2025-02-03", ] ); let process_log = process_log.borrow(); assert!( - process_log - .iter() - .any(|item| { item == "PreScheduled:scheduled:daily_auction:open_auction:pre" }) + process_log.iter().any(|item| { + item == "PreScheduled:scheduled:daily_before_trading:before_trading:pre" + }) ); assert!( process_log .iter() - .any(|item| { item == "PostScheduled:scheduled:daily_auction:open_auction:post" }) + .any(|item| { item == "PostScheduled:scheduled:daily_market_open:open_auction:post" }) ); assert!( process_log @@ -1154,9 +1162,9 @@ fn engine_dispatches_process_events_to_external_bus_listeners() { let external_log = external_log.borrow(); assert!( - external_log - .iter() - .any(|item| { item == "PreScheduled:scheduled:daily_auction:open_auction:pre" }) + external_log.iter().any(|item| { + item == "PreScheduled:scheduled:daily_before_trading:before_trading:pre" + }) ); assert!( external_log diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 547f580..0d597e2 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -317,7 +317,10 @@ fn broker_executes_target_shares_like_order_to() { ) .expect("broker execution"); - assert_eq!(portfolio.position("000002.SZ").map(|pos| pos.quantity), Some(200)); + assert_eq!( + portfolio.position("000002.SZ").map(|pos| pos.quantity), + Some(200) + ); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Sell); assert_eq!(report.fill_events[0].quantity, 100); @@ -500,7 +503,13 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() { assert_eq!(report.fill_events[1].symbol, "000002.SZ"); assert_eq!(report.fill_events[1].side, fidc_core::OrderSide::Buy); assert_eq!(report.fill_events[1].quantity, 100); - assert_eq!(portfolio.position("000001.SZ").map(|pos| pos.quantity).unwrap_or(0), 0); + assert_eq!( + portfolio + .position("000001.SZ") + .map(|pos| pos.quantity) + .unwrap_or(0), + 0 + ); assert_eq!( portfolio.position("000002.SZ").map(|pos| pos.quantity), Some(100) diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index e66c12f..0be3b9f 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -20,11 +20,11 @@ current alignment pass. ### Phase 2: Scheduling and execution surface -- [ ] minute-level `time_rule` semantics like `market_open`, `market_close`, +- [x] minute-level `time_rule` semantics like `market_open`, `market_close`, `physical_time` - [ ] finer `1m` / `tick` strategy execution entrypoints beyond `open_auction` and `on_day` -- [ ] scheduled actions evaluated against explicit intraday times +- [x] scheduled actions evaluated against explicit intraday times ### Phase 3: Universe and subscription model @@ -57,4 +57,4 @@ current alignment pass. ## Current Step -Active implementation target: Phase 2, minute-level time_rule semantics. +Active implementation target: Phase 3, dynamic universe and subscription model.