增加回测结束边界状态审计
This commit is contained in:
+178
-18
@@ -105,6 +105,79 @@ pub struct BacktestResult {
|
|||||||
pub holdings_summary: Vec<HoldingSummary>,
|
pub holdings_summary: Vec<HoldingSummary>,
|
||||||
pub daily_holdings: Vec<HoldingSummary>,
|
pub daily_holdings: Vec<HoldingSummary>,
|
||||||
pub metrics: BacktestMetrics,
|
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<NaiveDate>,
|
||||||
|
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<NaiveDate>,
|
||||||
|
pub open_order_samples: Vec<BacktestTerminalOpenOrder>,
|
||||||
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -186,6 +259,7 @@ pub struct AnalyzerReport {
|
|||||||
pub equity_curve: Vec<DailyEquityPoint>,
|
pub equity_curve: Vec<DailyEquityPoint>,
|
||||||
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
||||||
pub metrics: BacktestMetrics,
|
pub metrics: BacktestMetrics,
|
||||||
|
pub terminal_audit: BacktestTerminalAudit,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BacktestResult {
|
impl BacktestResult {
|
||||||
@@ -228,6 +302,7 @@ impl BacktestResult {
|
|||||||
equity_curve: self.equity_curve.clone(),
|
equity_curve: self.equity_curve.clone(),
|
||||||
benchmark_series: self.benchmark_series.clone(),
|
benchmark_series: self.benchmark_series.clone(),
|
||||||
metrics: self.metrics.clone(),
|
metrics: self.metrics.clone(),
|
||||||
|
terminal_audit: self.terminal_audit.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1065,28 +1140,111 @@ where
|
|||||||
|
|
||||||
fn open_order_views(&self) -> Vec<crate::strategy::OpenOrderView> {
|
fn open_order_views(&self) -> Vec<crate::strategy::OpenOrderView> {
|
||||||
let mut views = self.broker.open_order_views();
|
let mut views = self.broker.open_order_views();
|
||||||
views.extend(
|
views.extend(self.futures_open_order_views());
|
||||||
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.sort_by_key(|order| order.order_id);
|
views.sort_by_key(|order| order.order_id);
|
||||||
views
|
views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn futures_open_order_views(&self) -> Vec<crate::strategy::OpenOrderView> {
|
||||||
|
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<NaiveDate>,
|
||||||
|
) -> 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::<Vec<_>>();
|
||||||
|
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 {
|
fn has_open_orders(&self) -> bool {
|
||||||
self.broker.has_open_orders() || !self.futures_open_orders.is_empty()
|
self.broker.has_open_orders() || !self.futures_open_orders.is_empty()
|
||||||
}
|
}
|
||||||
@@ -1840,6 +1998,7 @@ where
|
|||||||
holdings_summary: Vec::new(),
|
holdings_summary: Vec::new(),
|
||||||
daily_holdings: Vec::new(),
|
daily_holdings: Vec::new(),
|
||||||
metrics: BacktestMetrics::default(),
|
metrics: BacktestMetrics::default(),
|
||||||
|
terminal_audit: BacktestTerminalAudit::default(),
|
||||||
};
|
};
|
||||||
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
|
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
|
||||||
let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
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() {
|
if let Some(last_date) = execution_dates.last().copied() {
|
||||||
result.holdings_summary = portfolio.holdings_summary(last_date);
|
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.metrics = compute_backtest_metrics(
|
||||||
&result.equity_curve,
|
&result.equity_curve,
|
||||||
&result.fills,
|
&result.fills,
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ pub use data::{
|
|||||||
pub use engine::{
|
pub use engine::{
|
||||||
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||||
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
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 event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ use std::sync::{Arc, Mutex};
|
|||||||
use chrono::{NaiveDate, NaiveDateTime};
|
use chrono::{NaiveDate, NaiveDateTime};
|
||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader,
|
BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader,
|
||||||
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
BacktestTerminalAssetClass, BacktestTerminalStatus, BenchmarkSnapshot, BrokerSimulator,
|
||||||
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ExecutionQuoteRequest,
|
CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot,
|
||||||
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
|
DailyMarketSnapshot, DataSet, ExecutionQuoteRequest, FuturesAccountState,
|
||||||
FuturesOrderIntent, FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig,
|
FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent,
|
||||||
Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType,
|
FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig, Instrument,
|
||||||
NumericFactorMap, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy,
|
IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, NumericFactorMap,
|
||||||
|
OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy,
|
||||||
PlatformExprStrategyConfig, PlatformTradeAction, PortfolioState, PriceField, ProcessEvent,
|
PlatformExprStrategyConfig, PlatformTradeAction, PortfolioState, PriceField, ProcessEvent,
|
||||||
ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy,
|
ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy,
|
||||||
StrategyContext, StrategyDecision,
|
StrategyContext, StrategyDecision,
|
||||||
@@ -1683,6 +1684,61 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() {
|
|||||||
.expect("long futures position");
|
.expect("long futures position");
|
||||||
assert_eq!(position.quantity, 2);
|
assert_eq!(position.quantity, 2);
|
||||||
assert!((position.contract_multiplier - 300.0).abs() < 1e-6);
|
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]
|
#[test]
|
||||||
@@ -2895,6 +2951,8 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
|||||||
assert!(result.process_events.iter().any(|event| {
|
assert!(result.process_events.iter().any(|event| {
|
||||||
event.kind == ProcessEventKind::AccountManagementFee && event.detail.contains("fee=42.00")
|
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]
|
#[test]
|
||||||
@@ -3066,6 +3124,8 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
|
|||||||
assert!(result.process_events.iter().any(|event| {
|
assert!(result.process_events.iter().any(|event| {
|
||||||
event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user