diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 6f61bda..935df29 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -71,6 +71,23 @@ impl Default for FuturesValidationConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessEventRetention { + /// Retain every phase and business event in the returned result. + All, + /// Retain only lifecycle events useful for a durable business audit. + Business, + /// Dispatch events to listeners and the strategy, but do not retain them + /// in the returned result. + None, +} + +impl Default for ProcessEventRetention { + fn default() -> Self { + Self::All + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DailyEquityPoint { #[serde(with = "date_format")] @@ -425,6 +442,7 @@ pub struct BacktestEngine { cash_dividends_enabled: bool, cash_dividend_adjusts_cost_basis: bool, process_event_bus: ProcessEventBus, + process_event_retention: ProcessEventRetention, dynamic_universe: Option>, subscriptions: BTreeSet, futures_account: Option, @@ -455,6 +473,7 @@ impl BacktestEngine { cash_dividends_enabled: true, cash_dividend_adjusts_cost_basis: true, process_event_bus: ProcessEventBus::new(), + process_event_retention: ProcessEventRetention::All, dynamic_universe: None, subscriptions: BTreeSet::new(), futures_account: None, @@ -488,6 +507,11 @@ impl BacktestEngine { self } + pub fn with_process_event_retention(mut self, retention: ProcessEventRetention) -> Self { + self.process_event_retention = retention; + self + } + pub fn with_cash_dividends(mut self, enabled: bool) -> Self { self.cash_dividends_enabled = enabled; self @@ -2118,7 +2142,7 @@ where let holding_count = holdings_for_day.len(); result.daily_holdings.extend(holdings_for_day); let progress_process_start = result.process_events.len(); - result.process_events.append(&mut process_events); + self.retain_process_events(&mut result.process_events, &mut process_events); let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio); let aggregate_total_equity = self.aggregate_total_equity(&portfolio); @@ -3193,7 +3217,7 @@ where let holding_count = holdings_for_day.len(); result.daily_holdings.extend(holdings_for_day); let progress_process_start = result.process_events.len(); - result.process_events.append(&mut process_events); + self.retain_process_events(&mut result.process_events, &mut process_events); let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio); let aggregate_total_equity = self.aggregate_total_equity(&portfolio); @@ -3280,7 +3304,23 @@ where result.fills.append(&mut report.fill_events); result.position_events.append(&mut report.position_events); result.account_events.append(&mut report.account_events); - result.process_events.append(&mut report.process_events); + self.retain_process_events(&mut result.process_events, &mut report.process_events); + } + + fn retain_process_events( + &self, + target: &mut Vec, + incoming: &mut Vec, + ) { + match self.process_event_retention { + ProcessEventRetention::All => target.append(incoming), + ProcessEventRetention::Business => target.extend( + incoming + .drain(..) + .filter(|event| event.kind.is_business_lifecycle()), + ), + ProcessEventRetention::None => incoming.clear(), + } } fn apply_corporate_actions( diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index c53d6c2..8a82765 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -364,6 +364,38 @@ impl ProcessEventKind { Self::AccountManagementFee => "account_management_fee", } } + + /// Returns whether the event is part of the durable business lifecycle + /// audit. Phase boundary events are useful during interactive debugging, + /// but retaining every minute phase marker for a long run is unnecessary. + pub fn is_business_lifecycle(&self) -> bool { + matches!( + *self, + Self::PreScheduled + | Self::PostScheduled + | Self::PreOnDay + | Self::OnDay + | Self::PostOnDay + | Self::OrderPendingNew + | Self::OrderCreationPass + | Self::OrderCreationReject + | Self::OrderPendingCancel + | Self::OrderCancellationPass + | Self::OrderCancellationReject + | Self::OrderPendingUpdate + | Self::OrderUpdatePass + | Self::OrderUpdateReject + | Self::OrderUnsolicitedUpdate + | Self::Trade + | Self::UniverseUpdated + | Self::UniverseSubscribed + | Self::UniverseUnsubscribed + | Self::AccountDepositWithdraw + | Self::AccountFinanceRepay + | Self::AccountManagementFee + | Self::Settlement + ) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -384,7 +416,7 @@ pub struct ProcessEvent { mod tests { use chrono::{NaiveDate, NaiveDateTime}; - use super::{FillEvent, OrderEvent, OrderSide, OrderStatus}; + use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind}; fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent { OrderEvent { @@ -484,4 +516,12 @@ mod tests { "2025-01-02 10:18:03" ); } + + #[test] + fn process_event_business_lifecycle_filter_keeps_audit_events_only() { + assert!(ProcessEventKind::OrderUpdateReject.is_business_lifecycle()); + assert!(ProcessEventKind::Settlement.is_business_lifecycle()); + assert!(!ProcessEventKind::PreMinute.is_business_lifecycle()); + assert!(!ProcessEventKind::PostBar.is_business_lifecycle()); + } } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 9cdd5da..1f82d36 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -39,6 +39,7 @@ pub use engine::{ AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder, BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, + ProcessEventRetention, }; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use events::{ diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 5e1d104..520da17 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -14,8 +14,8 @@ use fidc_core::{ IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, NumericFactorMap, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformTradeAction, PortfolioState, PriceField, ProcessEvent, - ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, - StrategyContext, StrategyDecision, + ProcessEventBus, ProcessEventKind, ProcessEventRetention, ScheduleRule, ScheduleStage, + ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -1199,6 +1199,7 @@ fn engine_runs_strategy_hooks_in_daily_order() { ) .expect("dataset"); + let compact_data = data.clone(); let log = Rc::new(RefCell::new(Vec::new())); let strategy = HookProbeStrategy { log: log.clone() }; let broker = BrokerSimulator::new_with_execution_price( @@ -1238,6 +1239,42 @@ fn engine_runs_strategy_hooks_in_daily_order() { ] ); assert_eq!(result.process_events.len(), 36); + + let compact_strategy = HookProbeStrategy { + log: Rc::new(RefCell::new(Vec::new())), + }; + let compact_broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut compact_engine = BacktestEngine::new( + compact_data, + compact_strategy, + compact_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, + }, + ) + .with_process_event_retention(ProcessEventRetention::Business); + let compact_result = compact_engine.run().expect("compact backtest succeeds"); + assert!(compact_result + .process_events + .iter() + .all(|event| event.kind.is_business_lifecycle())); + assert!(compact_result + .process_events + .iter() + .any(|event| event.kind == ProcessEventKind::OnDay)); + assert!(!compact_result + .process_events + .iter() + .any(|event| event.kind == ProcessEventKind::PreBeforeTrading)); assert_eq!( result.process_events[..18] .iter()