diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index d04849c..25f374b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -5,7 +5,10 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::cost::CostModel; use crate::data::{DataSet, IntradayExecutionQuote, PriceField}; use crate::engine::BacktestError; -use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; +use crate::events::{ + AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, + ProcessEventKind, +}; use crate::portfolio::PortfolioState; use crate::rules::EquityRuleHooks; use crate::strategy::{OrderIntent, StrategyDecision}; @@ -16,6 +19,7 @@ pub struct BrokerExecutionReport { pub fill_events: Vec, pub position_events: Vec, pub account_events: Vec, + pub process_events: Vec, pub diagnostics: Vec, } @@ -440,6 +444,25 @@ where order_id } + fn emit_order_process_event( + report: &mut BrokerExecutionReport, + date: NaiveDate, + kind: ProcessEventKind, + order_id: u64, + symbol: &str, + side: OrderSide, + detail: impl Into, + ) { + report.process_events.push(ProcessEvent { + date, + kind, + order_id: Some(order_id), + symbol: Some(symbol.to_string()), + side: Some(side), + detail: detail.into(), + }); + } + fn target_quantities( &self, date: NaiveDate, @@ -881,6 +904,16 @@ where return Ok(()); }; + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderPendingNew, + order_id, + symbol, + OrderSide::Sell, + format!("requested_quantity={requested_qty} reason={reason}"), + ); + let rule = self.rules.can_sell( date, snapshot, @@ -889,6 +922,7 @@ where self.execution_price_field, ); if !rule.allowed { + let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let status = match rule.reason.as_deref() { Some("paused") | Some("sell disabled by eligibility flags") @@ -903,11 +937,30 @@ where requested_quantity: requested_qty, filled_quantity: 0, status, - reason: format!("{reason}: {}", rule.reason.unwrap_or_default()), + reason: format!("{reason}: {rule_reason}"), }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Sell, + format!("status={status:?} reason={rule_reason}"), + ); return Ok(()); } + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderCreationPass, + order_id, + symbol, + OrderSide::Sell, + "sell order passed rule checks", + ); + let sellable = position.sellable_qty(date); let mut partial_fill_reason = if sellable < requested_qty { Some("sellable quantity limit".to_string()) @@ -945,6 +998,18 @@ where status: zero_fill_status_for_reason(&limit_reason), reason: format!("{reason}: {limit_reason}"), }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Sell, + format!( + "status={:?} reason={limit_reason}", + zero_fill_status_for_reason(&limit_reason) + ), + ); return Ok(()); } }; @@ -959,6 +1024,15 @@ where status: OrderStatus::Rejected, reason: format!("{reason}: no sellable quantity"), }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Sell, + "status=Rejected reason=no sellable quantity", + ); return Ok(()); } @@ -1031,6 +1105,15 @@ where net_cash_flow: net_cash, reason: reason.to_string(), }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::Trade, + order_id, + symbol, + OrderSide::Sell, + format!("filled_quantity={} price={}", leg.quantity, leg.price), + ); report.position_events.push(PositionEvent { date, symbol: symbol.to_string(), @@ -1097,6 +1180,17 @@ where status, reason: order_reason, }); + if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) { + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Sell, + format!("status={status:?} filled_quantity={filled_qty}"), + ); + } Ok(()) } @@ -1306,10 +1400,21 @@ where let snapshot = data.require_market(date, symbol)?; let candidate = data.require_candidate(date, symbol)?; + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderPendingNew, + order_id, + symbol, + OrderSide::Buy, + format!("requested_quantity={requested_qty} reason={reason}"), + ); + let rule = self .rules .can_buy(date, snapshot, candidate, self.execution_price_field); if !rule.allowed { + let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let status = match rule.reason.as_deref() { Some("paused") | Some("buy disabled by eligibility flags") @@ -1324,11 +1429,30 @@ where requested_quantity: requested_qty, filled_quantity: 0, status, - reason: format!("{reason}: {}", rule.reason.unwrap_or_default()), + reason: format!("{reason}: {rule_reason}"), }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Buy, + format!("status={status:?} reason={rule_reason}"), + ); return Ok(()); } + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderCreationPass, + order_id, + symbol, + OrderSide::Buy, + "buy order passed rule checks", + ); + let mut partial_fill_reason = None; let market_limited_qty = self.market_fillable_quantity( snapshot, @@ -1357,6 +1481,18 @@ where status: zero_fill_status_for_reason(&limit_reason), reason: format!("{reason}: {limit_reason}"), }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Buy, + format!( + "status={:?} reason={limit_reason}", + zero_fill_status_for_reason(&limit_reason) + ), + ); return Ok(()); } }; @@ -1432,6 +1568,20 @@ where .unwrap_or("insufficient cash after fees") ), }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Buy, + format!( + "status=Rejected reason={}", + partial_fill_reason + .as_deref() + .unwrap_or("insufficient cash after fees") + ), + ); return Ok(()); } @@ -1471,6 +1621,15 @@ where net_cash_flow: -cash_out, reason: reason.to_string(), }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::Trade, + order_id, + symbol, + OrderSide::Buy, + format!("filled_quantity={} price={}", leg.quantity, leg.price), + ); report.position_events.push(PositionEvent { date, symbol: symbol.to_string(), @@ -1536,6 +1695,17 @@ where status, reason: order_reason, }); + if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) { + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Buy, + format!("status={status:?} filled_quantity={filled_qty}"), + ); + } Ok(()) } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 502cf8d..b1f507b 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -5,7 +5,10 @@ use thiserror::Error; use crate::broker::{BrokerExecutionReport, BrokerSimulator}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; -use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; +use crate::events::{ + AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, + ProcessEventKind, +}; use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; @@ -59,6 +62,7 @@ pub struct BacktestResult { pub fills: Vec, pub position_events: Vec, pub account_events: Vec, + pub process_events: Vec, pub holdings_summary: Vec, pub daily_holdings: Vec, pub metrics: BacktestMetrics, @@ -82,6 +86,7 @@ pub struct BacktestDayProgress { pub orders: Vec, pub fills: Vec, pub holdings: Vec, + pub process_events: Vec, } pub struct BacktestEngine { @@ -166,6 +171,7 @@ where fills: Vec::new(), position_events: Vec::new(), account_events: Vec::new(), + process_events: Vec::new(), equity_curve: Vec::new(), holdings_summary: Vec::new(), daily_holdings: Vec::new(), @@ -206,7 +212,32 @@ where portfolio: &portfolio, }; let schedule_rules = self.strategy.schedule_rules(); + let mut process_events = Vec::new(); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PreBeforeTrading, + "before_trading:pre", + ); self.strategy.before_trading(&daily_context)?; + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::BeforeTrading, + "before_trading", + ); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PostBeforeTrading, + "before_trading:post", + ); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PreOpenAuction, + "open_auction:pre", + ); let mut auction_decision = collect_scheduled_decisions( &mut self.strategy, &scheduler, @@ -216,13 +247,32 @@ where &daily_context, )?; auction_decision.merge_from(self.strategy.open_auction(&daily_context)?); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::OpenAuction, + "open_auction", + ); let mut report = self.broker.execute( execution_date, &mut portfolio, &self.data, &auction_decision, )?; + process_events.append(&mut report.process_events); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PostOpenAuction, + "open_auction:post", + ); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PreOnDay, + "on_day:pre", + ); let mut decision = decision_slot .map(|(decision_idx, decision_date)| { self.strategy.on_day(&StrategyContext { @@ -249,10 +299,17 @@ where portfolio: &portfolio, }, )?); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::OnDay, + "on_day", + ); - let intraday_report = + let mut intraday_report = self.broker .execute(execution_date, &mut portfolio, &self.data, &decision)?; + process_events.append(&mut intraday_report.process_events); report.order_events.extend(intraday_report.order_events); report.fill_events.extend(intraday_report.fill_events); report @@ -260,6 +317,12 @@ where .extend(intraday_report.position_events); report.account_events.extend(intraday_report.account_events); report.diagnostics.extend(intraday_report.diagnostics); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PostOnDay, + "on_day:post", + ); let daily_fill_count = report.fill_events.len(); let day_orders = report.order_events.clone(); let day_fills = report.fill_events.clone(); @@ -275,8 +338,44 @@ where data: &self.data, portfolio: &portfolio, }; + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PreAfterTrading, + "after_trading:pre", + ); self.strategy.after_trading(&post_trade_context)?; + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::AfterTrading, + "after_trading", + ); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PostAfterTrading, + "after_trading:post", + ); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PreSettlement, + "settlement:pre", + ); self.strategy.on_settlement(&post_trade_context)?; + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::Settlement, + "settlement", + ); + push_phase_event( + &mut process_events, + execution_date, + ProcessEventKind::PostSettlement, + "settlement:post", + ); let benchmark = self.data @@ -296,6 +395,7 @@ where .collect::>() .join(" | "); let holdings_for_day = portfolio.holdings_summary(execution_date); + let day_process_events = process_events.clone(); result.equity_curve.push(DailyEquityPoint { date: execution_date, @@ -335,7 +435,9 @@ where orders: day_orders, fills: day_fills, holdings: holdings_for_day, + process_events: day_process_events, }); + result.process_events.extend(process_events); } if let Some(last_date) = execution_dates.last().copied() { @@ -676,6 +778,22 @@ fn collect_scheduled_decisions( Ok(combined) } +fn push_phase_event( + events: &mut Vec, + date: NaiveDate, + kind: ProcessEventKind, + detail: impl Into, +) { + events.push(ProcessEvent { + date, + kind, + order_id: None, + symbol: None, + side: None, + detail: detail.into(), + }); +} + mod date_format { use chrono::NaiveDate; use serde::Serializer; diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 7919ff5..4c29345 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -89,3 +89,40 @@ pub struct AccountEvent { pub total_equity: f64, pub note: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ProcessEventKind { + PreBeforeTrading, + BeforeTrading, + PostBeforeTrading, + PreOpenAuction, + OpenAuction, + PostOpenAuction, + PreOnDay, + OnDay, + PostOnDay, + PreAfterTrading, + AfterTrading, + PostAfterTrading, + PreSettlement, + Settlement, + PostSettlement, + OrderPendingNew, + OrderCreationPass, + OrderUnsolicitedUpdate, + Trade, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessEvent { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub kind: ProcessEventKind, + #[serde(default)] + pub order_id: Option, + #[serde(default)] + pub symbol: Option, + #[serde(default)] + pub side: Option, + pub detail: String, +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 9c739bc..2eac300 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -26,7 +26,10 @@ pub use engine::{ BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult, DailyEquityPoint, }; -pub use events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; +pub use events::{ + AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, + ProcessEventKind, +}; pub use instrument::Instrument; pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use platform_expr_strategy::{PlatformExprStrategy, PlatformExprStrategyConfig}; diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index e4b5a50..aed8282 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, PriceField, ScheduleRule, ScheduleStage, Strategy, StrategyContext, - StrategyDecision, + Instrument, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, Strategy, + StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -302,7 +302,7 @@ fn engine_runs_strategy_hooks_in_daily_order() { }, ); - engine.run().expect("backtest succeeds"); + let result = engine.run().expect("backtest succeeds"); assert_eq!( log.borrow().as_slice(), @@ -319,6 +319,30 @@ fn engine_runs_strategy_hooks_in_daily_order() { "settlement:2025-01-03", ] ); + assert_eq!(result.process_events.len(), 30); + assert_eq!( + result.process_events[..15] + .iter() + .map(|event| &event.kind) + .collect::>(), + vec![ + &ProcessEventKind::PreBeforeTrading, + &ProcessEventKind::BeforeTrading, + &ProcessEventKind::PostBeforeTrading, + &ProcessEventKind::PreOpenAuction, + &ProcessEventKind::OpenAuction, + &ProcessEventKind::PostOpenAuction, + &ProcessEventKind::PreOnDay, + &ProcessEventKind::OnDay, + &ProcessEventKind::PostOnDay, + &ProcessEventKind::PreAfterTrading, + &ProcessEventKind::AfterTrading, + &ProcessEventKind::PostAfterTrading, + &ProcessEventKind::PreSettlement, + &ProcessEventKind::Settlement, + &ProcessEventKind::PostSettlement, + ] + ); } #[test] diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index b87be8b..71dbde6 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2,8 +2,8 @@ use chrono::NaiveDate; use fidc_core::{ BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, - IntradayExecutionQuote, OrderIntent, PortfolioState, PriceField, SlippageModel, - StrategyDecision, + IntradayExecutionQuote, OrderIntent, PortfolioState, PriceField, ProcessEventKind, + SlippageModel, StrategyDecision, }; use std::collections::{BTreeMap, BTreeSet}; @@ -416,6 +416,11 @@ fn broker_cancels_buy_when_open_hits_upper_limit() { .reason .contains("open at or above upper limit") ); + assert!(report.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderUnsolicitedUpdate + && event.symbol.as_deref() == Some("000002.SZ") + && event.side == Some(fidc_core::OrderSide::Buy) + })); } #[test] @@ -989,6 +994,22 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() { .iter() .any(|item| item.contains("order_split_fill symbol=000002.SZ side=buy")) ); + assert_eq!( + report + .process_events + .iter() + .filter(|event| event.kind == ProcessEventKind::Trade) + .count(), + 2 + ); + assert!(report.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderPendingNew + && event.symbol.as_deref() == Some("000002.SZ") + })); + assert!(report.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderCreationPass + && event.symbol.as_deref() == Some("000002.SZ") + })); } #[test]