From 9399a61b466b2b5495d8c6fb5a555e3fac071839 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 13:34:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=9B=9E=E6=B5=8B=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E8=BE=B9=E7=95=8C=E7=8A=B6=E6=80=81=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 196 ++++++++++++++++++++++--- crates/fidc-core/src/lib.rs | 3 +- crates/fidc-core/tests/engine_hooks.rs | 72 ++++++++- 3 files changed, 246 insertions(+), 25 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 090090a..6b99072 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -105,6 +105,79 @@ pub struct BacktestResult { pub holdings_summary: Vec, pub daily_holdings: Vec, pub metrics: BacktestMetrics, + pub terminal_audit: BacktestTerminalAudit, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BacktestTerminalStatus { + Clean, + CompletedWithPendingState, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BacktestTerminalOpenOrder { + pub asset_class: BacktestTerminalAssetClass, + pub order_id: u64, + pub symbol: String, + pub side: String, + pub requested_quantity: u32, + pub filled_quantity: u32, + pub remaining_quantity: u32, + pub limit_price: f64, + pub reason: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BacktestTerminalAssetClass { + Stock, + Futures, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BacktestTerminalAudit { + pub status: BacktestTerminalStatus, + pub last_execution_date: Option, + pub stock_open_order_count: usize, + pub futures_open_order_count: usize, + pub pending_cash_flow_count: usize, + pub pending_cash_flow_net_amount: f64, + pub cash_receivable_count: usize, + pub cash_receivable_total_amount: f64, + pub earliest_deferred_cash_date: Option, + pub open_order_samples: Vec, + pub omitted_open_order_count: usize, +} + +impl Default for BacktestTerminalAudit { + fn default() -> Self { + Self { + status: BacktestTerminalStatus::Clean, + last_execution_date: None, + stock_open_order_count: 0, + futures_open_order_count: 0, + pending_cash_flow_count: 0, + pending_cash_flow_net_amount: 0.0, + cash_receivable_count: 0, + cash_receivable_total_amount: 0.0, + earliest_deferred_cash_date: None, + open_order_samples: Vec::new(), + omitted_open_order_count: 0, + } + } +} + +impl BacktestTerminalAudit { + pub fn is_clean(&self) -> bool { + self.status == BacktestTerminalStatus::Clean + } + + pub fn open_order_count(&self) -> usize { + self.stock_open_order_count + self.futures_open_order_count + } } #[derive(Debug, Clone)] @@ -186,6 +259,7 @@ pub struct AnalyzerReport { pub equity_curve: Vec, pub benchmark_series: Vec, pub metrics: BacktestMetrics, + pub terminal_audit: BacktestTerminalAudit, } impl BacktestResult { @@ -228,6 +302,7 @@ impl BacktestResult { equity_curve: self.equity_curve.clone(), benchmark_series: self.benchmark_series.clone(), metrics: self.metrics.clone(), + terminal_audit: self.terminal_audit.clone(), } } @@ -1065,28 +1140,111 @@ where fn open_order_views(&self) -> Vec { let mut views = self.broker.open_order_views(); - views.extend( - self.futures_open_orders - .iter() - .map(|order| crate::strategy::OpenOrderView { - order_id: order.order_id, - symbol: order.intent.symbol.clone(), - side: order.intent.side(), - requested_quantity: order.requested_quantity, - filled_quantity: order.filled_quantity, - remaining_quantity: order.remaining_quantity, - unfilled_quantity: order.remaining_quantity, - status: OrderStatus::Pending, - avg_price: 0.0, - transaction_cost: 0.0, - limit_price: order.limit_price, - reason: order.reason.clone(), - }), - ); + views.extend(self.futures_open_order_views()); views.sort_by_key(|order| order.order_id); views } + fn futures_open_order_views(&self) -> Vec { + self.futures_open_orders + .iter() + .map(|order| crate::strategy::OpenOrderView { + order_id: order.order_id, + symbol: order.intent.symbol.clone(), + side: order.intent.side(), + requested_quantity: order.requested_quantity, + filled_quantity: order.filled_quantity, + remaining_quantity: order.remaining_quantity, + unfilled_quantity: order.remaining_quantity, + status: OrderStatus::Pending, + avg_price: 0.0, + transaction_cost: 0.0, + limit_price: order.limit_price, + reason: order.reason.clone(), + }) + .collect() + } + + fn terminal_audit( + &self, + portfolio: &PortfolioState, + last_execution_date: Option, + ) -> BacktestTerminalAudit { + const OPEN_ORDER_SAMPLE_LIMIT: usize = 20; + + let stock_open_orders = self.broker.open_order_views(); + let futures_open_orders = self.futures_open_order_views(); + let stock_open_order_count = stock_open_orders.len(); + let futures_open_order_count = futures_open_orders.len(); + let open_order_count = stock_open_order_count + futures_open_order_count; + let pending_cash_flow_count = portfolio.pending_cash_flows().len(); + let cash_receivable_count = portfolio.cash_receivables().len(); + let pending_cash_flow_net_amount = portfolio + .pending_cash_flows() + .iter() + .map(|flow| flow.amount) + .sum(); + let cash_receivable_total_amount = portfolio + .cash_receivables() + .iter() + .map(|receivable| receivable.amount) + .sum(); + let earliest_deferred_cash_date = portfolio + .pending_cash_flows() + .iter() + .map(|flow| flow.payable_date) + .chain( + portfolio + .cash_receivables() + .iter() + .map(|receivable| receivable.payable_date), + ) + .min(); + let open_order_samples = stock_open_orders + .iter() + .map(|order| (BacktestTerminalAssetClass::Stock, order)) + .chain( + futures_open_orders + .iter() + .map(|order| (BacktestTerminalAssetClass::Futures, order)), + ) + .take(OPEN_ORDER_SAMPLE_LIMIT) + .map(|(asset_class, order)| BacktestTerminalOpenOrder { + asset_class, + order_id: order.order_id, + symbol: order.symbol.clone(), + side: order.side.as_str().to_string(), + requested_quantity: order.requested_quantity, + filled_quantity: order.filled_quantity, + remaining_quantity: order.remaining_quantity, + limit_price: order.limit_price, + reason: order.reason.clone(), + }) + .collect::>(); + let status = if open_order_count == 0 + && pending_cash_flow_count == 0 + && cash_receivable_count == 0 + { + BacktestTerminalStatus::Clean + } else { + BacktestTerminalStatus::CompletedWithPendingState + }; + + BacktestTerminalAudit { + status, + last_execution_date, + stock_open_order_count, + futures_open_order_count, + pending_cash_flow_count, + pending_cash_flow_net_amount, + cash_receivable_count, + cash_receivable_total_amount, + earliest_deferred_cash_date, + omitted_open_order_count: open_order_count.saturating_sub(open_order_samples.len()), + open_order_samples, + } + } + fn has_open_orders(&self) -> bool { self.broker.has_open_orders() || !self.futures_open_orders.is_empty() } @@ -1840,6 +1998,7 @@ where holdings_summary: Vec::new(), daily_holdings: Vec::new(), metrics: BacktestMetrics::default(), + terminal_audit: BacktestTerminalAudit::default(), }; let mut stock_equity_by_date = BTreeMap::::new(); let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total(); @@ -3086,6 +3245,7 @@ where if let Some(last_date) = execution_dates.last().copied() { result.holdings_summary = portfolio.holdings_summary(last_date); } + result.terminal_audit = self.terminal_audit(&portfolio, execution_dates.last().copied()); result.metrics = compute_backtest_metrics( &result.equity_curve, &result.fills, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 957054c..723f687 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -37,7 +37,8 @@ pub use data::{ pub use engine::{ AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, - BacktestResult, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, + BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder, + BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, }; 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 e2db4e3..3a1162e 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -6,12 +6,13 @@ use std::sync::{Arc, Mutex}; use chrono::{NaiveDate, NaiveDateTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader, - BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, - ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ExecutionQuoteRequest, - FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, - FuturesOrderIntent, FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig, - Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, - NumericFactorMap, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, + BacktestTerminalAssetClass, BacktestTerminalStatus, BenchmarkSnapshot, BrokerSimulator, + CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, + DailyMarketSnapshot, DataSet, ExecutionQuoteRequest, FuturesAccountState, + FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, + FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig, Instrument, + IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, NumericFactorMap, + OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformTradeAction, PortfolioState, PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, @@ -1683,6 +1684,61 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() { .expect("long futures position"); assert_eq!(position.quantity, 2); assert!((position.contract_multiplier - 300.0).abs() < 1e-6); + assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean); + assert_eq!(result.terminal_audit.open_order_count(), 0); +} + +#[test] +fn engine_reports_pending_futures_order_at_backtest_boundary() { + let date = d(2025, 1, 2); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + two_day_futures_data(), + FuturesLimitOrderStrategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_futures_initial_cash(1_000_000.0); + + let result = engine.run().expect("backtest succeeds"); + + assert!(result.fills.is_empty()); + assert_eq!( + result.terminal_audit.status, + BacktestTerminalStatus::CompletedWithPendingState + ); + assert_eq!(result.terminal_audit.last_execution_date, Some(date)); + assert_eq!(result.terminal_audit.stock_open_order_count, 0); + assert_eq!(result.terminal_audit.futures_open_order_count, 1); + assert_eq!(result.terminal_audit.open_order_count(), 1); + assert_eq!(result.terminal_audit.omitted_open_order_count, 0); + assert_eq!(result.terminal_audit.open_order_samples.len(), 1); + assert_eq!( + result.terminal_audit.open_order_samples[0].asset_class, + BacktestTerminalAssetClass::Futures + ); + assert_eq!(result.terminal_audit.open_order_samples[0].symbol, "IF2501"); + assert_eq!( + result.terminal_audit.open_order_samples[0].remaining_quantity, + 2 + ); + assert!( + result + .order_events + .iter() + .any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Pending }) + ); } #[test] @@ -2895,6 +2951,8 @@ fn engine_applies_account_cash_flow_and_financing_intents() { assert!(result.process_events.iter().any(|event| { event.kind == ProcessEventKind::AccountManagementFee && event.detail.contains("fee=42.00") })); + assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean); + assert_eq!(result.terminal_audit.pending_cash_flow_count, 0); } #[test] @@ -3066,6 +3124,8 @@ fn engine_rejects_pending_limit_orders_at_market_close() { assert!(result.process_events.iter().any(|event| { event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate })); + assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean); + assert_eq!(result.terminal_audit.stock_open_order_count, 0); } #[test]