Add process event stream for backtests

This commit is contained in:
boris
2026-04-23 01:58:40 -07:00
parent e5fe1f0432
commit 23ba74909d
6 changed files with 384 additions and 11 deletions

View File

@@ -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<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
pub process_events: Vec<ProcessEvent>,
pub diagnostics: Vec<String>,
}
@@ -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<String>,
) {
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(())
}

View File

@@ -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<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
pub process_events: Vec<ProcessEvent>,
pub holdings_summary: Vec<HoldingSummary>,
pub daily_holdings: Vec<HoldingSummary>,
pub metrics: BacktestMetrics,
@@ -82,6 +86,7 @@ pub struct BacktestDayProgress {
pub orders: Vec<OrderEvent>,
pub fills: Vec<FillEvent>,
pub holdings: Vec<HoldingSummary>,
pub process_events: Vec<ProcessEvent>,
}
pub struct BacktestEngine<S, C, R> {
@@ -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::<Vec<_>>()
.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<S: Strategy>(
Ok(combined)
}
fn push_phase_event(
events: &mut Vec<ProcessEvent>,
date: NaiveDate,
kind: ProcessEventKind,
detail: impl Into<String>,
) {
events.push(ProcessEvent {
date,
kind,
order_id: None,
symbol: None,
side: None,
detail: detail.into(),
});
}
mod date_format {
use chrono::NaiveDate;
use serde::Serializer;

View File

@@ -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<u64>,
#[serde(default)]
pub symbol: Option<String>,
#[serde(default)]
pub side: Option<OrderSide>,
pub detail: String,
}

View File

@@ -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};

View File

@@ -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<_>>(),
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]

View File

@@ -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]