From 34e16520fab4cce43cbc2c7a07f2d3ac8e3e9efb Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 22 Apr 2026 22:15:55 -0700 Subject: [PATCH] Add daily strategy lifecycle hooks --- crates/fidc-core/src/engine.rs | 28 ++- crates/fidc-core/src/strategy.rs | 9 + crates/fidc-core/tests/engine_hooks.rs | 229 +++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 crates/fidc-core/tests/engine_hooks.rs diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 976ac82..e7048df 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -190,10 +190,22 @@ where )?; self.extend_result(&mut result, delisting_report); - let decision = execution_idx + let decision_slot = execution_idx .checked_sub(self.config.decision_lag_trading_days) - .map(|decision_idx| { - let decision_date = execution_dates[decision_idx]; + .map(|decision_idx| (decision_idx, execution_dates[decision_idx])); + let (decision_index, decision_date) = + decision_slot.unwrap_or((execution_idx, execution_date)); + let daily_context = StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio: &portfolio, + }; + self.strategy.before_trading(&daily_context)?; + + let decision = decision_slot + .map(|(decision_idx, decision_date)| { self.strategy.on_day(&StrategyContext { execution_date, decision_date, @@ -215,6 +227,16 @@ where portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; + let post_trade_context = StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio: &portfolio, + }; + self.strategy.after_trading(&post_trade_context)?; + self.strategy.on_settlement(&post_trade_context)?; + let benchmark = self.data .benchmark(execution_date) diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 87d3203..a93fcb1 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -15,7 +15,16 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe pub trait Strategy { fn name(&self) -> &str; + fn before_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { + Ok(()) + } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result; + fn after_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { + Ok(()) + } + fn on_settlement(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { + Ok(()) + } } pub struct StrategyContext<'a> { diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs new file mode 100644 index 0000000..e2088bb --- /dev/null +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -0,0 +1,229 @@ +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::rc::Rc; + +use chrono::NaiveDate; +use fidc_core::{ + BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, + ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, + Instrument, PriceField, Strategy, StrategyContext, StrategyDecision, +}; + +fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") +} + +struct HookProbeStrategy { + log: Rc>>, +} + +impl Strategy for HookProbeStrategy { + fn name(&self) -> &str { + "hook-probe" + } + + fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), fidc_core::BacktestError> { + self.log + .borrow_mut() + .push(format!("before:{}", ctx.execution_date)); + Ok(()) + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + self.log + .borrow_mut() + .push(format!("on_day:{}", ctx.execution_date)); + Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: Vec::new(), + notes: Vec::new(), + diagnostics: Vec::new(), + }) + } + + fn after_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), fidc_core::BacktestError> { + self.log + .borrow_mut() + .push(format!("after:{}", ctx.execution_date)); + Ok(()) + } + + fn on_settlement(&mut self, ctx: &StrategyContext<'_>) -> Result<(), fidc_core::BacktestError> { + self.log + .borrow_mut() + .push(format!("settlement:{}", ctx.execution_date)); + Ok(()) + } +} + +#[test] +fn engine_runs_strategy_hooks_in_daily_order() { + let date1 = d(2025, 1, 2); + let date2 = d(2025, 1, 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-02 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".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-03 10:18:00".to_string()), + day_open: 10.1, + open: 10.1, + high: 10.1, + low: 10.1, + 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("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + 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(), + }, + ], + 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, + }, + ], + 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, + }, + ], + ) + .expect("dataset"); + + let log = Rc::new(RefCell::new(Vec::new())); + let strategy = HookProbeStrategy { log: log.clone() }; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + 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(date2), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ); + + engine.run().expect("backtest succeeds"); + + assert_eq!( + log.borrow().as_slice(), + [ + "before:2025-01-02", + "on_day:2025-01-02", + "after:2025-01-02", + "settlement:2025-01-02", + "before:2025-01-03", + "on_day:2025-01-03", + "after:2025-01-03", + "settlement:2025-01-03", + ] + ); +}