6906 lines
254 KiB
Rust
6906 lines
254 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::sync::Arc;
|
|
|
|
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType};
|
|
use crate::cost::CostModel;
|
|
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, IntradayExecutionQuote, PriceField};
|
|
use crate::event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
|
use crate::events::{
|
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
|
ProcessEventKind,
|
|
};
|
|
use crate::futures::{
|
|
FuturesAccountState, FuturesExecutionReport, FuturesOrderIntent, FuturesPositionEffect,
|
|
FuturesTransactionCostModel,
|
|
};
|
|
use crate::metrics::{BacktestMetrics, RiskFreeRateContract, compute_backtest_metrics};
|
|
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
|
use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope};
|
|
use crate::rules::EquityRuleHooks;
|
|
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time};
|
|
use crate::strategy::{
|
|
OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision,
|
|
TargetPortfolioOrderPricing,
|
|
};
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum BacktestError {
|
|
#[error(transparent)]
|
|
Data(#[from] DataSetError),
|
|
#[error("missing {field} price for {symbol} on {date}")]
|
|
MissingPrice {
|
|
date: NaiveDate,
|
|
symbol: String,
|
|
field: &'static str,
|
|
},
|
|
#[error("benchmark snapshot missing for {date}")]
|
|
MissingBenchmark { date: NaiveDate },
|
|
#[error("{0}")]
|
|
Execution(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct BacktestConfig {
|
|
pub initial_cash: f64,
|
|
pub benchmark_code: String,
|
|
pub start_date: Option<NaiveDate>,
|
|
pub end_date: Option<NaiveDate>,
|
|
pub decision_lag_trading_days: usize,
|
|
pub execution_price_field: PriceField,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct FuturesValidationConfig {
|
|
pub enforce_active_instrument: bool,
|
|
pub enforce_trading_phase: bool,
|
|
pub enforce_limit_price_tick: bool,
|
|
pub enforce_price_limits: bool,
|
|
}
|
|
|
|
impl Default for FuturesValidationConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enforce_active_instrument: true,
|
|
enforce_trading_phase: true,
|
|
enforce_limit_price_tick: true,
|
|
enforce_price_limits: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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")]
|
|
pub date: NaiveDate,
|
|
pub cash: f64,
|
|
pub market_value: f64,
|
|
pub total_equity: f64,
|
|
/// External cash flow settled on this trading date (deposit positive,
|
|
/// withdrawal negative). Trading cash movements are excluded.
|
|
#[serde(default)]
|
|
pub external_cash_flow: f64,
|
|
/// Cash-flow-neutral unit NAV after all activity on this date.
|
|
#[serde(default)]
|
|
pub unit_nav: f64,
|
|
pub benchmark_close: f64,
|
|
pub benchmark_prev_close: f64,
|
|
pub notes: String,
|
|
pub diagnostics: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct BacktestResult {
|
|
pub strategy_name: String,
|
|
pub equity_curve: Vec<DailyEquityPoint>,
|
|
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
|
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
|
|
pub order_events: Vec<OrderEvent>,
|
|
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,
|
|
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)]
|
|
pub struct ExecutionQuoteRequest {
|
|
pub date: NaiveDate,
|
|
pub start_time: Option<chrono::NaiveTime>,
|
|
pub end_time: Option<chrono::NaiveTime>,
|
|
pub symbols: BTreeSet<String>,
|
|
}
|
|
|
|
type ExecutionQuoteLoader = Box<
|
|
dyn FnMut(ExecutionQuoteRequest) -> Result<Vec<IntradayExecutionQuote>, BacktestError> + Send,
|
|
>;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct AnalyzerTradeRow {
|
|
#[serde(with = "date_format")]
|
|
pub date: NaiveDate,
|
|
pub order_id: Option<u64>,
|
|
pub symbol: String,
|
|
pub side: OrderSide,
|
|
pub quantity: u32,
|
|
pub price: f64,
|
|
pub gross_amount: f64,
|
|
pub transaction_cost: f64,
|
|
pub net_cash_flow: f64,
|
|
pub reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct AnalyzerPositionRow {
|
|
#[serde(with = "date_format")]
|
|
pub date: NaiveDate,
|
|
pub symbol: String,
|
|
pub quantity: u32,
|
|
pub market_value: f64,
|
|
pub weight: f64,
|
|
pub average_cost: f64,
|
|
pub realized_pnl: f64,
|
|
pub unrealized_pnl: f64,
|
|
pub transaction_cost: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct AnalyzerMonthlyReturnRow {
|
|
pub year: i32,
|
|
pub month: u32,
|
|
pub portfolio_return: f64,
|
|
pub benchmark_return: f64,
|
|
pub excess_return: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct AnalyzerRiskSummary {
|
|
pub total_return: f64,
|
|
pub annual_return: f64,
|
|
pub benchmark_cumulative_return: f64,
|
|
pub excess_cumulative_return: f64,
|
|
pub alpha: f64,
|
|
pub beta: f64,
|
|
pub sharpe: f64,
|
|
pub sortino: f64,
|
|
pub information_ratio: f64,
|
|
pub tracking_error: f64,
|
|
pub volatility: f64,
|
|
pub max_drawdown: f64,
|
|
pub max_drawdown_duration_days: usize,
|
|
pub win_rate: f64,
|
|
pub excess_win_rate: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct AnalyzerReport {
|
|
pub strategy_name: String,
|
|
pub trades: Vec<AnalyzerTradeRow>,
|
|
pub positions: Vec<AnalyzerPositionRow>,
|
|
pub monthly_returns: Vec<AnalyzerMonthlyReturnRow>,
|
|
pub risk_summary: AnalyzerRiskSummary,
|
|
pub equity_curve: Vec<DailyEquityPoint>,
|
|
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
|
pub metrics: BacktestMetrics,
|
|
pub terminal_audit: BacktestTerminalAudit,
|
|
}
|
|
|
|
impl BacktestResult {
|
|
pub fn analyzer_report(&self) -> AnalyzerReport {
|
|
AnalyzerReport {
|
|
strategy_name: self.strategy_name.clone(),
|
|
trades: self
|
|
.fills
|
|
.iter()
|
|
.map(|fill| AnalyzerTradeRow {
|
|
date: fill.date,
|
|
order_id: fill.order_id,
|
|
symbol: fill.symbol.clone(),
|
|
side: fill.side,
|
|
quantity: fill.quantity,
|
|
price: fill.price,
|
|
gross_amount: fill.gross_amount,
|
|
transaction_cost: fill.commission + fill.stamp_tax + fill.transfer_fee,
|
|
net_cash_flow: fill.net_cash_flow,
|
|
reason: fill.reason.clone(),
|
|
})
|
|
.collect(),
|
|
positions: self
|
|
.daily_holdings
|
|
.iter()
|
|
.map(|holding| AnalyzerPositionRow {
|
|
date: holding.date,
|
|
symbol: holding.symbol.clone(),
|
|
quantity: holding.quantity,
|
|
market_value: holding.market_value,
|
|
weight: holding.value_percent,
|
|
average_cost: holding.average_cost,
|
|
realized_pnl: holding.realized_pnl,
|
|
unrealized_pnl: holding.unrealized_pnl,
|
|
transaction_cost: holding.transaction_cost,
|
|
})
|
|
.collect(),
|
|
monthly_returns: self.analyzer_monthly_returns(),
|
|
risk_summary: self.analyzer_risk_summary(),
|
|
equity_curve: self.equity_curve.clone(),
|
|
benchmark_series: self.benchmark_series.clone(),
|
|
metrics: self.metrics.clone(),
|
|
terminal_audit: self.terminal_audit.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn analyzer_report_json(&self) -> Result<String, serde_json::Error> {
|
|
serde_json::to_string_pretty(&self.analyzer_report())
|
|
}
|
|
|
|
pub fn analyzer_monthly_returns(&self) -> Vec<AnalyzerMonthlyReturnRow> {
|
|
let mut month_points = BTreeMap::<(i32, u32), (f64, f64, f64, f64)>::new();
|
|
let mut previous_equity = 1.0;
|
|
let mut previous_benchmark = self
|
|
.equity_curve
|
|
.first()
|
|
.map(|point| point.benchmark_prev_close)
|
|
.unwrap_or_default();
|
|
for point in &self.equity_curve {
|
|
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
|
point.unit_nav
|
|
} else if self.metrics.initial_cash.abs() > f64::EPSILON {
|
|
point.total_equity / self.metrics.initial_cash
|
|
} else {
|
|
1.0
|
|
};
|
|
let key = (point.date.year(), point.date.month());
|
|
month_points
|
|
.entry(key)
|
|
.and_modify(|(_, _, end_equity, end_benchmark)| {
|
|
*end_equity = point_nav;
|
|
*end_benchmark = point.benchmark_close;
|
|
})
|
|
.or_insert((
|
|
previous_equity,
|
|
previous_benchmark,
|
|
point_nav,
|
|
point.benchmark_close,
|
|
));
|
|
previous_equity = point_nav;
|
|
previous_benchmark = point.benchmark_close;
|
|
}
|
|
month_points
|
|
.into_iter()
|
|
.map(
|
|
|((year, month), (start_equity, start_benchmark, end_equity, end_benchmark))| {
|
|
let portfolio_return = analyzer_ratio_change(start_equity, end_equity);
|
|
let benchmark_return = analyzer_ratio_change(start_benchmark, end_benchmark);
|
|
AnalyzerMonthlyReturnRow {
|
|
year,
|
|
month,
|
|
portfolio_return,
|
|
benchmark_return,
|
|
excess_return: portfolio_return - benchmark_return,
|
|
}
|
|
},
|
|
)
|
|
.collect()
|
|
}
|
|
|
|
pub fn analyzer_risk_summary(&self) -> AnalyzerRiskSummary {
|
|
AnalyzerRiskSummary {
|
|
total_return: self.metrics.total_return,
|
|
annual_return: self.metrics.annual_return,
|
|
benchmark_cumulative_return: self.metrics.benchmark_cumulative_return,
|
|
excess_cumulative_return: self.metrics.excess_cumulative_return,
|
|
alpha: self.metrics.alpha,
|
|
beta: self.metrics.beta,
|
|
sharpe: self.metrics.sharpe,
|
|
sortino: self.metrics.sortino,
|
|
information_ratio: self.metrics.information_ratio,
|
|
tracking_error: self.metrics.tracking_error,
|
|
volatility: self.metrics.volatility,
|
|
max_drawdown: self.metrics.max_drawdown,
|
|
max_drawdown_duration_days: self.metrics.max_drawdown_duration_days,
|
|
win_rate: self.metrics.win_rate,
|
|
excess_win_rate: self.metrics.excess_win_rate,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct BacktestDayProgress {
|
|
#[serde(with = "date_format")]
|
|
pub date: NaiveDate,
|
|
pub cash: f64,
|
|
pub market_value: f64,
|
|
pub total_equity: f64,
|
|
#[serde(default)]
|
|
pub external_cash_flow: f64,
|
|
pub unit_nav: f64,
|
|
pub total_return: f64,
|
|
pub benchmark_close: f64,
|
|
pub daily_fill_count: usize,
|
|
pub daily_order_count: usize,
|
|
pub cumulative_trade_count: usize,
|
|
pub holding_count: usize,
|
|
pub notes: String,
|
|
pub diagnostics: String,
|
|
pub orders: Vec<OrderEvent>,
|
|
pub fills: Vec<FillEvent>,
|
|
pub holdings: Vec<HoldingSummary>,
|
|
pub process_events: Vec<ProcessEvent>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct FuturesOpenOrder {
|
|
order_id: u64,
|
|
intent: FuturesOrderIntent,
|
|
requested_quantity: u32,
|
|
filled_quantity: u32,
|
|
remaining_quantity: u32,
|
|
limit_price: f64,
|
|
reason: String,
|
|
}
|
|
|
|
pub struct BacktestEngine<S, C, R> {
|
|
data: DataSet,
|
|
strategy: S,
|
|
broker: BrokerSimulator<C, R>,
|
|
config: BacktestConfig,
|
|
dividend_reinvestment: bool,
|
|
cash_dividends_enabled: bool,
|
|
cash_dividend_adjusts_cost_basis: bool,
|
|
process_event_bus: ProcessEventBus,
|
|
process_event_retention: ProcessEventRetention,
|
|
dynamic_universe: Option<BTreeSet<String>>,
|
|
subscriptions: BTreeSet<String>,
|
|
futures_account: Option<FuturesAccountState>,
|
|
next_futures_order_id: u64,
|
|
futures_open_orders: Vec<FuturesOpenOrder>,
|
|
futures_expirations: BTreeMap<NaiveDate, BTreeMap<String, f64>>,
|
|
futures_settlement_price_mode: String,
|
|
futures_cost_model: FuturesTransactionCostModel,
|
|
futures_validation_config: FuturesValidationConfig,
|
|
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
|
preplanned_decision_quote_symbols_by_date:
|
|
Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
|
execution_quote_request_cache:
|
|
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
|
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
|
}
|
|
|
|
fn backtest_execution_schedule(
|
|
data: &DataSet,
|
|
start_date: Option<NaiveDate>,
|
|
end_date: Option<NaiveDate>,
|
|
decision_lag_trading_days: usize,
|
|
) -> Vec<(NaiveDate, Option<(usize, NaiveDate)>)> {
|
|
let calendar_dates = data
|
|
.calendar()
|
|
.iter()
|
|
.filter(|date| start_date.map(|start| *date >= start).unwrap_or(true))
|
|
.filter(|date| end_date.map(|end| *date <= end).unwrap_or(true))
|
|
.collect::<Vec<_>>();
|
|
let has_decision_inputs = |date: NaiveDate| {
|
|
!data.factor_snapshot_rows_on(date).is_empty()
|
|
&& !data.candidate_snapshot_rows_on(date).is_empty()
|
|
};
|
|
let has_execution_market = |date: NaiveDate| !data.market_snapshot_rows_on(date).is_empty();
|
|
let mut schedule = Vec::new();
|
|
for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() {
|
|
if decision_lag_trading_days == 0 {
|
|
if has_decision_inputs(execution_date) {
|
|
schedule.push((execution_date, Some((calendar_idx, execution_date))));
|
|
}
|
|
continue;
|
|
}
|
|
if !has_execution_market(execution_date) {
|
|
continue;
|
|
}
|
|
let decision_slot = calendar_idx
|
|
.checked_sub(decision_lag_trading_days)
|
|
.map(|decision_idx| (decision_idx, calendar_dates[decision_idx]));
|
|
match decision_slot {
|
|
Some((_, decision_date)) if has_decision_inputs(decision_date) => {
|
|
schedule.push((execution_date, decision_slot));
|
|
}
|
|
None => schedule.push((execution_date, None)),
|
|
_ => {}
|
|
}
|
|
}
|
|
schedule
|
|
}
|
|
|
|
pub fn backtest_execution_dates(
|
|
data: &DataSet,
|
|
start_date: Option<NaiveDate>,
|
|
end_date: Option<NaiveDate>,
|
|
decision_lag_trading_days: usize,
|
|
) -> Vec<NaiveDate> {
|
|
backtest_execution_schedule(data, start_date, end_date, decision_lag_trading_days)
|
|
.into_iter()
|
|
.map(|(execution_date, _)| execution_date)
|
|
.collect()
|
|
}
|
|
|
|
impl<S, C, R> BacktestEngine<S, C, R> {
|
|
pub fn new(
|
|
data: DataSet,
|
|
strategy: S,
|
|
broker: BrokerSimulator<C, R>,
|
|
config: BacktestConfig,
|
|
) -> Self {
|
|
Self {
|
|
data,
|
|
strategy,
|
|
broker,
|
|
config,
|
|
dividend_reinvestment: false,
|
|
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,
|
|
next_futures_order_id: 9_000_000_000,
|
|
futures_open_orders: Vec::new(),
|
|
futures_expirations: BTreeMap::new(),
|
|
futures_settlement_price_mode: "close".to_string(),
|
|
futures_cost_model: FuturesTransactionCostModel::default(),
|
|
futures_validation_config: FuturesValidationConfig::default(),
|
|
execution_quote_loader: None,
|
|
preplanned_decision_quote_symbols_by_date: None,
|
|
execution_quote_request_cache: BTreeSet::new(),
|
|
risk_free_rate_contract: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_risk_free_rate_contract(mut self, contract: RiskFreeRateContract) -> Self {
|
|
self.risk_free_rate_contract = Some(contract);
|
|
self
|
|
}
|
|
|
|
pub fn into_data(self) -> DataSet {
|
|
self.data
|
|
}
|
|
|
|
pub fn with_execution_quote_loader<F>(mut self, loader: F) -> Self
|
|
where
|
|
F: FnMut(ExecutionQuoteRequest) -> Result<Vec<IntradayExecutionQuote>, BacktestError>
|
|
+ Send
|
|
+ 'static,
|
|
{
|
|
self.execution_quote_loader = Some(Box::new(loader));
|
|
self
|
|
}
|
|
|
|
pub fn with_preplanned_decision_quote_symbols_by_date(
|
|
mut self,
|
|
symbols_by_date: Arc<BTreeMap<NaiveDate, BTreeSet<String>>>,
|
|
) -> Self {
|
|
self.preplanned_decision_quote_symbols_by_date = Some(symbols_by_date);
|
|
self
|
|
}
|
|
|
|
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
|
self.dividend_reinvestment = enabled;
|
|
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
|
|
}
|
|
|
|
pub fn with_cash_dividend_cost_basis_adjustment(mut self, enabled: bool) -> Self {
|
|
self.cash_dividend_adjusts_cost_basis = enabled;
|
|
self
|
|
}
|
|
|
|
pub fn with_futures_account(mut self, account: FuturesAccountState) -> Self {
|
|
self.futures_account = Some(account);
|
|
self
|
|
}
|
|
|
|
pub fn with_futures_initial_cash(self, initial_cash: f64) -> Self {
|
|
self.with_futures_account(FuturesAccountState::new(initial_cash))
|
|
}
|
|
|
|
pub fn futures_account(&self) -> Option<&FuturesAccountState> {
|
|
self.futures_account.as_ref()
|
|
}
|
|
|
|
pub fn futures_account_mut(&mut self) -> Option<&mut FuturesAccountState> {
|
|
self.futures_account.as_mut()
|
|
}
|
|
|
|
pub fn with_futures_expiration(
|
|
mut self,
|
|
date: NaiveDate,
|
|
symbol: impl Into<String>,
|
|
settlement_price: f64,
|
|
) -> Self {
|
|
self.futures_expirations
|
|
.entry(date)
|
|
.or_default()
|
|
.insert(symbol.into(), settlement_price);
|
|
self
|
|
}
|
|
|
|
pub fn with_futures_expirations(
|
|
mut self,
|
|
expirations: BTreeMap<NaiveDate, BTreeMap<String, f64>>,
|
|
) -> Self {
|
|
self.futures_expirations = expirations;
|
|
self
|
|
}
|
|
|
|
pub fn with_futures_settlement_price_mode(mut self, mode: impl Into<String>) -> Self {
|
|
self.futures_settlement_price_mode = mode.into();
|
|
self
|
|
}
|
|
|
|
pub fn with_futures_transaction_cost_model(
|
|
mut self,
|
|
cost_model: FuturesTransactionCostModel,
|
|
) -> Self {
|
|
self.futures_cost_model = cost_model;
|
|
self
|
|
}
|
|
|
|
pub fn with_futures_validation_config(mut self, config: FuturesValidationConfig) -> Self {
|
|
self.futures_validation_config = config;
|
|
self
|
|
}
|
|
|
|
pub fn process_event_bus_mut(&mut self) -> &mut ProcessEventBus {
|
|
&mut self.process_event_bus
|
|
}
|
|
|
|
pub fn add_process_listener<F>(&mut self, kind: ProcessEventKind, listener: F)
|
|
where
|
|
F: FnMut(&ProcessEvent) + 'static,
|
|
{
|
|
self.process_event_bus.add_listener(kind, listener);
|
|
}
|
|
|
|
pub fn add_any_process_listener<F>(&mut self, listener: F)
|
|
where
|
|
F: FnMut(&ProcessEvent) + 'static,
|
|
{
|
|
self.process_event_bus.add_any_listener(listener);
|
|
}
|
|
|
|
pub fn install_process_mod<M>(&mut self, module: &mut M)
|
|
where
|
|
M: BacktestProcessMod,
|
|
{
|
|
self.process_event_bus.install_mod(module);
|
|
}
|
|
|
|
pub fn install_process_mod_loader(
|
|
&mut self,
|
|
loader: &mut BacktestProcessModLoader,
|
|
) -> Vec<String> {
|
|
self.process_event_bus.install_mod_loader(loader)
|
|
}
|
|
|
|
pub fn install_enabled_process_mods(
|
|
&mut self,
|
|
loader: &mut BacktestProcessModLoader,
|
|
enabled_names: &[String],
|
|
) -> Vec<String> {
|
|
self.process_event_bus
|
|
.install_enabled_mods(loader, enabled_names)
|
|
}
|
|
}
|
|
|
|
impl<S, C, R> BacktestEngine<S, C, R>
|
|
where
|
|
S: Strategy,
|
|
C: CostModel,
|
|
R: EquityRuleHooks,
|
|
{
|
|
fn ensure_execution_quotes_for_decision(
|
|
&mut self,
|
|
execution_date: NaiveDate,
|
|
order_created_date: NaiveDate,
|
|
portfolio: &PortfolioState,
|
|
open_orders: &[OpenOrderView],
|
|
decision: &StrategyDecision,
|
|
start_time: Option<chrono::NaiveTime>,
|
|
end_time: Option<chrono::NaiveTime>,
|
|
) -> Result<(), BacktestError> {
|
|
if self.execution_quote_loader.is_none() {
|
|
return Ok(());
|
|
}
|
|
let submission_time = start_time.or_else(|| self.broker.intraday_execution_start_time());
|
|
let post_close_window = self.broker.post_close_execution_quote_window_for_order(
|
|
execution_date,
|
|
order_created_date,
|
|
submission_time,
|
|
);
|
|
if self.broker.execution_price_field() != PriceField::Last
|
|
&& !decision_has_algo_execution(decision)
|
|
&& post_close_window.is_none()
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
let caller_start_time = start_time;
|
|
let caller_end_time = end_time;
|
|
let start_time = post_close_window
|
|
.map(|window| window.0)
|
|
.or(caller_start_time)
|
|
.or_else(|| self.broker.intraday_execution_start_time());
|
|
let end_time = post_close_window.map(|window| window.1).or(caller_end_time);
|
|
let mut symbols = execution_quote_symbols_for_decision(decision, portfolio, open_orders);
|
|
self.load_missing_execution_quotes(execution_date, start_time, end_time, &mut symbols)?;
|
|
|
|
if caller_start_time.is_none() && caller_end_time.is_none() {
|
|
for ((intent_start_time, intent_end_time), mut intent_symbols) in
|
|
algo_execution_quote_windows_for_decision(decision, portfolio)
|
|
{
|
|
self.load_missing_execution_quotes(
|
|
execution_date,
|
|
intent_start_time,
|
|
intent_end_time,
|
|
&mut intent_symbols,
|
|
)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn load_missing_execution_quotes(
|
|
&mut self,
|
|
execution_date: NaiveDate,
|
|
start_time: Option<NaiveTime>,
|
|
end_time: Option<NaiveTime>,
|
|
symbols: &mut BTreeSet<String>,
|
|
) -> Result<(), BacktestError> {
|
|
symbols.retain(|symbol| {
|
|
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
|
if self.execution_quote_request_cache.contains(&request_key) {
|
|
return false;
|
|
}
|
|
if start_time.is_none() && end_time.is_none() {
|
|
return true;
|
|
}
|
|
if start_time.is_some() && end_time.is_none() {
|
|
return !has_execution_quote_near_start_time(
|
|
&self.data,
|
|
execution_date,
|
|
symbol,
|
|
start_time.expect("checked start_time"),
|
|
);
|
|
}
|
|
!has_execution_quote_in_window(&self.data, execution_date, symbol, start_time, end_time)
|
|
});
|
|
if symbols.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let requested_symbols = symbols.iter().cloned().collect::<Vec<_>>();
|
|
let request = ExecutionQuoteRequest {
|
|
date: execution_date,
|
|
start_time,
|
|
end_time,
|
|
symbols: std::mem::take(symbols),
|
|
};
|
|
let quotes = self
|
|
.execution_quote_loader
|
|
.as_mut()
|
|
.expect("checked execution quote loader")
|
|
.as_mut()(request)?;
|
|
let requested_symbol_set = requested_symbols.iter().cloned().collect::<BTreeSet<_>>();
|
|
if let Some(quote) = quotes.iter().find(|quote| {
|
|
quote.date != execution_date || !requested_symbol_set.contains("e.symbol)
|
|
}) {
|
|
return Err(BacktestError::Execution(format!(
|
|
"execution quote loader returned a row outside the request: requested_date={} actual_date={} symbol={}",
|
|
execution_date, quote.date, quote.symbol
|
|
)));
|
|
}
|
|
self.data.add_execution_quotes(quotes);
|
|
if start_time.is_none() && end_time.is_none() {
|
|
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
|
|
}
|
|
for symbol in requested_symbols {
|
|
self.execution_quote_request_cache.insert((
|
|
execution_date,
|
|
symbol,
|
|
start_time,
|
|
end_time,
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_full_day_execution_quote_coverage(
|
|
&self,
|
|
execution_date: NaiveDate,
|
|
requested_symbols: &[String],
|
|
) -> Result<(), BacktestError> {
|
|
let mut missing_active = Vec::new();
|
|
let mut paused_with_quotes = Vec::new();
|
|
let mut missing_daily_market = Vec::new();
|
|
for symbol in requested_symbols {
|
|
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
|
|
continue;
|
|
};
|
|
let Some(market) = self.data.market(execution_date, symbol) else {
|
|
missing_daily_market.push(symbol.clone());
|
|
continue;
|
|
};
|
|
let has_quotes = !self
|
|
.data
|
|
.execution_quotes_on(execution_date, symbol)
|
|
.is_empty();
|
|
if market.paused {
|
|
if has_quotes {
|
|
paused_with_quotes.push(symbol.clone());
|
|
}
|
|
continue;
|
|
}
|
|
if market.volume > 0 && !has_quotes {
|
|
missing_active.push(symbol.clone());
|
|
}
|
|
}
|
|
if missing_daily_market.is_empty()
|
|
&& missing_active.is_empty()
|
|
&& paused_with_quotes.is_empty()
|
|
{
|
|
return Ok(());
|
|
}
|
|
Err(BacktestError::Execution(format!(
|
|
"full-minute subscription coverage mismatch on {}: missing_daily_market={:?}, missing_active_minute_bars={:?}, paused_with_minute_bars={:?}",
|
|
execution_date, missing_daily_market, missing_active, paused_with_quotes
|
|
)))
|
|
}
|
|
|
|
fn ensure_execution_quotes_for_portfolio_times(
|
|
&mut self,
|
|
execution_date: NaiveDate,
|
|
portfolio: &PortfolioState,
|
|
quote_times: &[NaiveTime],
|
|
) -> Result<(), BacktestError> {
|
|
if self.execution_quote_loader.is_none() || quote_times.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let base_symbols = portfolio
|
|
.positions()
|
|
.keys()
|
|
.cloned()
|
|
.collect::<BTreeSet<_>>();
|
|
if base_symbols.is_empty() {
|
|
return Ok(());
|
|
}
|
|
for quote_time in quote_times {
|
|
let mut symbols = base_symbols.clone();
|
|
self.load_missing_execution_quotes(
|
|
execution_date,
|
|
Some(*quote_time),
|
|
None,
|
|
&mut symbols,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_execution_quotes_for_symbols_at_times(
|
|
&mut self,
|
|
execution_date: NaiveDate,
|
|
symbols: &BTreeSet<String>,
|
|
quote_times: &[NaiveTime],
|
|
) -> Result<(), BacktestError> {
|
|
if self.execution_quote_loader.is_none() || quote_times.is_empty() || symbols.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let base_symbols = symbols
|
|
.iter()
|
|
.filter(|symbol| !symbol.trim().is_empty())
|
|
.cloned()
|
|
.collect::<BTreeSet<_>>();
|
|
if base_symbols.is_empty() {
|
|
return Ok(());
|
|
}
|
|
for quote_time in quote_times {
|
|
let mut symbols = base_symbols.clone();
|
|
self.load_missing_execution_quotes(
|
|
execution_date,
|
|
Some(*quote_time),
|
|
None,
|
|
&mut symbols,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_strategy_directives(
|
|
&mut self,
|
|
execution_date: NaiveDate,
|
|
decision_date: NaiveDate,
|
|
decision_index: usize,
|
|
portfolio: &mut PortfolioState,
|
|
open_orders: &[crate::strategy::OpenOrderView],
|
|
process_events: &mut Vec<ProcessEvent>,
|
|
decision: &mut crate::strategy::StrategyDecision,
|
|
directive_report: &mut BrokerExecutionReport,
|
|
) -> Result<(), BacktestError> {
|
|
if decision.order_intents.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut retained = Vec::with_capacity(decision.order_intents.len());
|
|
for intent in decision.order_intents.drain(..) {
|
|
match intent {
|
|
crate::strategy::OrderIntent::UpdateUniverse { symbols, reason } => {
|
|
let symbol_count = symbols.len();
|
|
self.dynamic_universe = Some(symbols.clone());
|
|
decision
|
|
.diagnostics
|
|
.push(format!("dynamic_universe_updated count={symbol_count}"));
|
|
publish_custom_process_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
portfolio,
|
|
self.futures_account.as_ref(),
|
|
open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
process_events,
|
|
ProcessEvent {
|
|
date: execution_date,
|
|
kind: ProcessEventKind::UniverseUpdated,
|
|
order_id: None,
|
|
symbol: (symbol_count == 1)
|
|
.then(|| symbols.iter().next().cloned())
|
|
.flatten(),
|
|
side: None,
|
|
detail: format!(
|
|
"reason={reason} count={symbol_count} symbols={}",
|
|
symbols.iter().cloned().collect::<Vec<_>>().join(",")
|
|
),
|
|
},
|
|
)?;
|
|
}
|
|
crate::strategy::OrderIntent::Subscribe { symbols, reason } => {
|
|
let mut added = Vec::new();
|
|
for symbol in symbols {
|
|
if self.subscriptions.insert(symbol.clone()) {
|
|
added.push(symbol);
|
|
}
|
|
}
|
|
if !added.is_empty() {
|
|
decision.diagnostics.push(format!(
|
|
"subscriptions_added count={} total={}",
|
|
added.len(),
|
|
self.subscriptions.len()
|
|
));
|
|
publish_custom_process_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
portfolio,
|
|
self.futures_account.as_ref(),
|
|
open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
process_events,
|
|
ProcessEvent {
|
|
date: execution_date,
|
|
kind: ProcessEventKind::UniverseSubscribed,
|
|
order_id: None,
|
|
symbol: (added.len() == 1).then(|| added[0].clone()),
|
|
side: None,
|
|
detail: format!(
|
|
"reason={reason} count={} symbols={}",
|
|
added.len(),
|
|
added.join(",")
|
|
),
|
|
},
|
|
)?;
|
|
}
|
|
}
|
|
crate::strategy::OrderIntent::Unsubscribe { symbols, reason } => {
|
|
let mut removed = Vec::new();
|
|
for symbol in symbols {
|
|
if self.subscriptions.remove(&symbol) {
|
|
removed.push(symbol);
|
|
}
|
|
}
|
|
if !removed.is_empty() {
|
|
decision.diagnostics.push(format!(
|
|
"subscriptions_removed count={} total={}",
|
|
removed.len(),
|
|
self.subscriptions.len()
|
|
));
|
|
publish_custom_process_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
portfolio,
|
|
self.futures_account.as_ref(),
|
|
open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
process_events,
|
|
ProcessEvent {
|
|
date: execution_date,
|
|
kind: ProcessEventKind::UniverseUnsubscribed,
|
|
order_id: None,
|
|
symbol: (removed.len() == 1).then(|| removed[0].clone()),
|
|
side: None,
|
|
detail: format!(
|
|
"reason={reason} count={} symbols={}",
|
|
removed.len(),
|
|
removed.join(",")
|
|
),
|
|
},
|
|
)?;
|
|
}
|
|
}
|
|
crate::strategy::OrderIntent::DepositWithdraw {
|
|
amount,
|
|
receiving_days,
|
|
reason,
|
|
} => {
|
|
let cash_before = portfolio.cash();
|
|
if receiving_days == 0 {
|
|
portfolio
|
|
.deposit_withdraw(amount)
|
|
.map_err(BacktestError::Execution)?;
|
|
directive_report.account_events.push(AccountEvent {
|
|
date: execution_date,
|
|
cash_before,
|
|
cash_after: portfolio.cash(),
|
|
total_equity: portfolio.total_equity(),
|
|
note: format!("deposit_withdraw amount={amount:.2} reason={reason}"),
|
|
});
|
|
} else {
|
|
let payable_date = self
|
|
.data
|
|
.next_trading_date(execution_date, receiving_days)
|
|
.ok_or_else(|| {
|
|
BacktestError::Execution(format!(
|
|
"no trading date for deposit_withdraw receiving_days={receiving_days} from {execution_date}"
|
|
))
|
|
})?;
|
|
portfolio
|
|
.schedule_deposit_withdraw(payable_date, amount, reason.clone())
|
|
.map_err(BacktestError::Execution)?;
|
|
directive_report.account_events.push(AccountEvent {
|
|
date: execution_date,
|
|
cash_before,
|
|
cash_after: portfolio.cash(),
|
|
total_equity: portfolio.total_equity(),
|
|
note: format!(
|
|
"deposit_withdraw_scheduled amount={amount:.2} payable_date={payable_date} reason={reason}"
|
|
),
|
|
});
|
|
}
|
|
decision.diagnostics.push(format!(
|
|
"account_deposit_withdraw amount={amount:.2} receiving_days={receiving_days}"
|
|
));
|
|
publish_custom_process_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&*portfolio,
|
|
self.futures_account.as_ref(),
|
|
open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
process_events,
|
|
ProcessEvent {
|
|
date: execution_date,
|
|
kind: ProcessEventKind::AccountDepositWithdraw,
|
|
order_id: None,
|
|
symbol: None,
|
|
side: None,
|
|
detail: format!(
|
|
"reason={reason} amount={amount:.2} receiving_days={receiving_days} cash_before={cash_before:.2} cash_after={:.2}",
|
|
portfolio.cash()
|
|
),
|
|
},
|
|
)?;
|
|
}
|
|
crate::strategy::OrderIntent::FinanceRepay { amount, reason } => {
|
|
let cash_before = portfolio.cash();
|
|
let liabilities_before = portfolio.cash_liabilities();
|
|
portfolio
|
|
.finance_repay(amount)
|
|
.map_err(BacktestError::Execution)?;
|
|
directive_report.account_events.push(AccountEvent {
|
|
date: execution_date,
|
|
cash_before,
|
|
cash_after: portfolio.cash(),
|
|
total_equity: portfolio.total_equity(),
|
|
note: format!(
|
|
"finance_repay amount={amount:.2} liabilities_before={liabilities_before:.2} liabilities_after={:.2} reason={reason}",
|
|
portfolio.cash_liabilities()
|
|
),
|
|
});
|
|
decision.diagnostics.push(format!(
|
|
"account_finance_repay amount={amount:.2} liabilities={:.2}",
|
|
portfolio.cash_liabilities()
|
|
));
|
|
publish_custom_process_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&*portfolio,
|
|
self.futures_account.as_ref(),
|
|
open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
process_events,
|
|
ProcessEvent {
|
|
date: execution_date,
|
|
kind: ProcessEventKind::AccountFinanceRepay,
|
|
order_id: None,
|
|
symbol: None,
|
|
side: None,
|
|
detail: format!(
|
|
"reason={reason} amount={amount:.2} cash_before={cash_before:.2} cash_after={:.2} liabilities_before={liabilities_before:.2} liabilities_after={:.2}",
|
|
portfolio.cash(),
|
|
portfolio.cash_liabilities()
|
|
),
|
|
},
|
|
)?;
|
|
}
|
|
crate::strategy::OrderIntent::SetManagementFeeRate { rate, reason } => {
|
|
portfolio
|
|
.set_management_fee_rate(rate)
|
|
.map_err(BacktestError::Execution)?;
|
|
decision
|
|
.diagnostics
|
|
.push(format!("account_management_fee_rate rate={rate:.6}"));
|
|
publish_custom_process_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&*portfolio,
|
|
self.futures_account.as_ref(),
|
|
open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
process_events,
|
|
ProcessEvent {
|
|
date: execution_date,
|
|
kind: ProcessEventKind::AccountManagementFee,
|
|
order_id: None,
|
|
symbol: None,
|
|
side: None,
|
|
detail: format!(
|
|
"reason={reason} rate={rate:.6} management_fees={:.2}",
|
|
portfolio.management_fees()
|
|
),
|
|
},
|
|
)?;
|
|
}
|
|
crate::strategy::OrderIntent::CancelOrder { order_id, reason } => {
|
|
let report = self.cancel_futures_open_order(execution_date, order_id, &reason);
|
|
if report.order_events.is_empty() && report.process_events.is_empty() {
|
|
retained
|
|
.push(crate::strategy::OrderIntent::CancelOrder { order_id, reason });
|
|
} else {
|
|
merge_futures_report(directive_report, report);
|
|
}
|
|
}
|
|
crate::strategy::OrderIntent::ModifyOrder {
|
|
order_id,
|
|
new_total_quantity,
|
|
new_limit_price,
|
|
reason,
|
|
} => retained.push(crate::strategy::OrderIntent::ModifyOrder {
|
|
order_id,
|
|
new_total_quantity,
|
|
new_limit_price,
|
|
reason,
|
|
}),
|
|
crate::strategy::OrderIntent::CancelSymbol { symbol, reason } => {
|
|
let report = self.cancel_futures_open_orders_for_symbol(
|
|
execution_date,
|
|
&symbol,
|
|
&reason,
|
|
);
|
|
if report.order_events.is_empty() && report.process_events.is_empty() {
|
|
retained
|
|
.push(crate::strategy::OrderIntent::CancelSymbol { symbol, reason });
|
|
} else {
|
|
merge_futures_report(directive_report, report);
|
|
}
|
|
}
|
|
crate::strategy::OrderIntent::CancelAll { reason } => {
|
|
let report = self.cancel_all_futures_open_orders(execution_date, &reason);
|
|
let has_stock_open_orders = !self.broker.open_order_views().is_empty();
|
|
if has_stock_open_orders || report.order_events.is_empty() {
|
|
retained.push(crate::strategy::OrderIntent::CancelAll {
|
|
reason: reason.clone(),
|
|
});
|
|
}
|
|
merge_futures_report(directive_report, report);
|
|
}
|
|
crate::strategy::OrderIntent::Futures { intent } => {
|
|
let order_id = self.next_futures_order_id;
|
|
self.next_futures_order_id += 1;
|
|
let report = self.submit_futures_order(execution_date, order_id, intent, false);
|
|
decision.diagnostics.push(format!(
|
|
"futures_order order_id={order_id} events={}",
|
|
report.order_events.len()
|
|
));
|
|
merge_futures_report(directive_report, report);
|
|
}
|
|
other => retained.push(other),
|
|
}
|
|
}
|
|
decision.order_intents = retained;
|
|
Ok(())
|
|
}
|
|
|
|
fn open_order_views(&self) -> Vec<crate::strategy::OpenOrderView> {
|
|
let mut views = self.broker.open_order_views();
|
|
views.extend(self.futures_open_order_views());
|
|
views.sort_by_key(|order| order.order_id);
|
|
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 {
|
|
self.broker.has_open_orders() || !self.futures_open_orders.is_empty()
|
|
}
|
|
|
|
fn aggregate_initial_cash(&self) -> f64 {
|
|
self.config.initial_cash
|
|
+ self
|
|
.futures_account
|
|
.as_ref()
|
|
.map(FuturesAccountState::starting_cash)
|
|
.unwrap_or(0.0)
|
|
}
|
|
|
|
fn aggregate_cash(&self, portfolio: &PortfolioState) -> f64 {
|
|
portfolio.cash()
|
|
+ self
|
|
.futures_account
|
|
.as_ref()
|
|
.map(FuturesAccountState::cash)
|
|
.unwrap_or(0.0)
|
|
}
|
|
|
|
fn aggregate_market_value(&self, portfolio: &PortfolioState) -> f64 {
|
|
portfolio.market_value()
|
|
+ self
|
|
.futures_account
|
|
.as_ref()
|
|
.map(FuturesAccountState::position_equity)
|
|
.unwrap_or(0.0)
|
|
}
|
|
|
|
fn aggregate_total_equity(&self, portfolio: &PortfolioState) -> f64 {
|
|
portfolio.total_equity()
|
|
+ self
|
|
.futures_account
|
|
.as_ref()
|
|
.map(FuturesAccountState::total_value)
|
|
.unwrap_or(0.0)
|
|
}
|
|
|
|
fn aggregate_unit_net_value(&self, portfolio: &PortfolioState) -> Result<f64, BacktestError> {
|
|
if self.futures_account.is_none() {
|
|
return Ok(portfolio.unit_net_value());
|
|
}
|
|
if portfolio.external_cash_flow_total().abs() > 1e-9 {
|
|
return Err(BacktestError::Execution(
|
|
"mixed stock/futures external cash flows require an aggregate unit ledger"
|
|
.to_string(),
|
|
));
|
|
}
|
|
let initial_cash = self.aggregate_initial_cash();
|
|
if !initial_cash.is_finite() || initial_cash <= 0.0 {
|
|
return Err(BacktestError::Execution(
|
|
"aggregate initial cash must be positive for stock/futures NAV".to_string(),
|
|
));
|
|
}
|
|
Ok(self.aggregate_total_equity(portfolio) / initial_cash)
|
|
}
|
|
|
|
fn submit_futures_order(
|
|
&mut self,
|
|
date: NaiveDate,
|
|
order_id: u64,
|
|
intent: FuturesOrderIntent,
|
|
from_pending: bool,
|
|
) -> FuturesExecutionReport {
|
|
let Some(_) = self.futures_account.as_ref() else {
|
|
return self.reject_futures_order(
|
|
date,
|
|
order_id,
|
|
intent,
|
|
"futures account is not enabled".to_string(),
|
|
);
|
|
};
|
|
|
|
let original_requested = intent.quantity;
|
|
let mut intent = self.resolve_futures_trading_parameters(date, intent);
|
|
if let Some(reason) = self.validate_futures_submission(date, &intent) {
|
|
return self.reject_futures_order(date, order_id, intent, reason);
|
|
}
|
|
|
|
let fill = self.resolve_futures_fill(date, &intent);
|
|
let Some((execution_price, fill_quantity)) = fill else {
|
|
if intent.allow_pending || intent.limit_price.is_some() {
|
|
return self.queue_futures_order(
|
|
date,
|
|
order_id,
|
|
intent,
|
|
original_requested,
|
|
0,
|
|
from_pending,
|
|
"limit not matched or no executable futures price",
|
|
);
|
|
}
|
|
return self.reject_futures_order(
|
|
date,
|
|
order_id,
|
|
intent,
|
|
"missing executable futures price".to_string(),
|
|
);
|
|
};
|
|
if fill_quantity == 0 {
|
|
if intent.allow_pending || intent.limit_price.is_some() {
|
|
return self.queue_futures_order(
|
|
date,
|
|
order_id,
|
|
intent,
|
|
original_requested,
|
|
0,
|
|
from_pending,
|
|
"futures liquidity unavailable",
|
|
);
|
|
}
|
|
return self.reject_futures_order(
|
|
date,
|
|
order_id,
|
|
intent,
|
|
"futures liquidity unavailable".to_string(),
|
|
);
|
|
}
|
|
|
|
let remaining = original_requested.saturating_sub(fill_quantity);
|
|
intent.price = execution_price;
|
|
intent.quantity = fill_quantity;
|
|
intent = self.resolve_futures_transaction_cost(date, intent);
|
|
let mut report = self
|
|
.futures_account
|
|
.as_mut()
|
|
.expect("checked futures account")
|
|
.execute_order(date, Some(order_id), intent.clone());
|
|
|
|
if remaining > 0 && (intent.allow_pending || intent.limit_price.is_some()) {
|
|
for event in &mut report.order_events {
|
|
if event.order_id == Some(order_id) {
|
|
event.requested_quantity = original_requested;
|
|
event.filled_quantity = fill_quantity;
|
|
event.status = OrderStatus::PartiallyFilled;
|
|
}
|
|
}
|
|
let mut remaining_intent = intent.clone();
|
|
remaining_intent.quantity = remaining;
|
|
remaining_intent.transaction_cost = 0.0;
|
|
let queued = self.queue_futures_order(
|
|
date,
|
|
order_id,
|
|
remaining_intent,
|
|
original_requested,
|
|
fill_quantity,
|
|
true,
|
|
"partial fill remaining quantity pending",
|
|
);
|
|
report.order_events.extend(queued.order_events);
|
|
report.process_events.extend(queued.process_events);
|
|
report.diagnostics.extend(queued.diagnostics);
|
|
} else if remaining > 0 {
|
|
for event in &mut report.order_events {
|
|
if event.order_id == Some(order_id) {
|
|
event.requested_quantity = original_requested;
|
|
event.filled_quantity = fill_quantity;
|
|
event.status = OrderStatus::PartiallyFilled;
|
|
event.reason.push_str(": remaining quantity canceled");
|
|
}
|
|
}
|
|
}
|
|
report
|
|
}
|
|
|
|
fn process_futures_open_orders(&mut self, date: NaiveDate) -> BrokerExecutionReport {
|
|
let pending = std::mem::take(&mut self.futures_open_orders);
|
|
let mut combined = BrokerExecutionReport::default();
|
|
for mut order in pending {
|
|
order.intent.quantity = order.remaining_quantity;
|
|
let report = self.submit_futures_order(date, order.order_id, order.intent, true);
|
|
merge_futures_report(&mut combined, report);
|
|
}
|
|
combined
|
|
}
|
|
|
|
fn queue_futures_order(
|
|
&mut self,
|
|
date: NaiveDate,
|
|
order_id: u64,
|
|
intent: FuturesOrderIntent,
|
|
requested_quantity: u32,
|
|
filled_quantity: u32,
|
|
_from_pending: bool,
|
|
reason: &str,
|
|
) -> FuturesExecutionReport {
|
|
let mut report = FuturesExecutionReport::default();
|
|
let side = intent.side();
|
|
let limit_price = intent.limit_price.unwrap_or(intent.price);
|
|
self.futures_open_orders.push(FuturesOpenOrder {
|
|
order_id,
|
|
requested_quantity,
|
|
filled_quantity,
|
|
remaining_quantity: intent.quantity,
|
|
limit_price,
|
|
reason: format!("{}: {reason}", intent.reason),
|
|
intent,
|
|
});
|
|
report.order_events.push(OrderEvent {
|
|
date,
|
|
decision_date: None,
|
|
order_created_date: None,
|
|
execution_date: None,
|
|
order_id: Some(order_id),
|
|
symbol: self
|
|
.futures_open_orders
|
|
.last()
|
|
.map(|order| order.intent.symbol.clone())
|
|
.unwrap_or_default(),
|
|
side,
|
|
requested_quantity,
|
|
filled_quantity,
|
|
status: OrderStatus::Pending,
|
|
reason: reason.to_string(),
|
|
});
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::OrderCreationPass,
|
|
order_id: Some(order_id),
|
|
symbol: self
|
|
.futures_open_orders
|
|
.last()
|
|
.map(|order| order.intent.symbol.clone()),
|
|
side: Some(side),
|
|
detail: format!("futures pending limit_price={limit_price:.6} reason={reason}"),
|
|
});
|
|
report
|
|
}
|
|
|
|
fn reject_futures_order(
|
|
&self,
|
|
date: NaiveDate,
|
|
order_id: u64,
|
|
intent: FuturesOrderIntent,
|
|
reason: String,
|
|
) -> FuturesExecutionReport {
|
|
let side = intent.side();
|
|
let mut report = FuturesExecutionReport::default();
|
|
report.order_events.push(OrderEvent {
|
|
date,
|
|
decision_date: None,
|
|
order_created_date: None,
|
|
execution_date: None,
|
|
order_id: Some(order_id),
|
|
symbol: intent.symbol.clone(),
|
|
side,
|
|
requested_quantity: intent.quantity,
|
|
filled_quantity: 0,
|
|
status: OrderStatus::Rejected,
|
|
reason: format!(
|
|
"{}: {reason} direction={} effect={}",
|
|
intent.reason,
|
|
intent.direction.as_str(),
|
|
intent.effect.as_str()
|
|
),
|
|
});
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::OrderCreationReject,
|
|
order_id: Some(order_id),
|
|
symbol: Some(intent.symbol),
|
|
side: Some(side),
|
|
detail: reason,
|
|
});
|
|
report
|
|
}
|
|
|
|
fn validate_futures_submission(
|
|
&self,
|
|
date: NaiveDate,
|
|
intent: &FuturesOrderIntent,
|
|
) -> Option<String> {
|
|
if intent.quantity == 0 {
|
|
return Some("zero futures quantity".to_string());
|
|
}
|
|
if !intent.spec.is_resolved() {
|
|
return Some(format!(
|
|
"missing futures trading parameters symbol={} date={date}",
|
|
intent.symbol
|
|
));
|
|
}
|
|
if self.futures_validation_config.enforce_active_instrument {
|
|
if let Some(instrument) = self.data.instrument(&intent.symbol) {
|
|
if !instrument.is_active_on(date) {
|
|
return Some(format!(
|
|
"inactive futures instrument symbol={} date={date}",
|
|
intent.symbol
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if self.futures_validation_config.enforce_trading_phase {
|
|
if let Some(snapshot) = self.data.market(date, &intent.symbol) {
|
|
if snapshot.paused {
|
|
return Some(format!(
|
|
"paused futures instrument symbol={}",
|
|
intent.symbol
|
|
));
|
|
}
|
|
if !futures_trading_phase_allows_orders(snapshot.trading_phase.as_deref()) {
|
|
return Some(format!(
|
|
"futures trading phase does not allow orders symbol={} phase={}",
|
|
intent.symbol,
|
|
snapshot.trading_phase.as_deref().unwrap_or("")
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if let Some(limit_price) = intent.limit_price {
|
|
if !limit_price.is_finite() || limit_price <= 0.0 {
|
|
return Some("invalid futures limit price".to_string());
|
|
}
|
|
if self.futures_validation_config.enforce_limit_price_tick {
|
|
let tick = self.futures_price_tick(date, &intent.symbol);
|
|
if !price_is_tick_aligned(limit_price, tick) {
|
|
return Some(format!(
|
|
"futures limit price not aligned to tick symbol={} price={limit_price:.6} tick={tick:.6}",
|
|
intent.symbol
|
|
));
|
|
}
|
|
}
|
|
if self.futures_validation_config.enforce_price_limits {
|
|
if let Some(snapshot) = self.data.market(date, &intent.symbol) {
|
|
if snapshot.upper_limit.is_finite()
|
|
&& snapshot.upper_limit > 0.0
|
|
&& limit_price > snapshot.upper_limit + 1e-9
|
|
{
|
|
return Some(format!(
|
|
"futures limit price above upper limit symbol={} price={limit_price:.6} upper={:.6}",
|
|
intent.symbol, snapshot.upper_limit
|
|
));
|
|
}
|
|
if snapshot.lower_limit.is_finite()
|
|
&& snapshot.lower_limit > 0.0
|
|
&& limit_price < snapshot.lower_limit - 1e-9
|
|
{
|
|
return Some(format!(
|
|
"futures limit price below lower limit symbol={} price={limit_price:.6} lower={:.6}",
|
|
intent.symbol, snapshot.lower_limit
|
|
));
|
|
}
|
|
}
|
|
}
|
|
for order in &self.futures_open_orders {
|
|
if order.intent.symbol != intent.symbol || order.intent.side() == intent.side() {
|
|
continue;
|
|
}
|
|
let existing_limit = order.limit_price;
|
|
let crosses = match intent.side() {
|
|
OrderSide::Buy => limit_price >= existing_limit,
|
|
OrderSide::Sell => limit_price <= existing_limit,
|
|
};
|
|
if crosses {
|
|
return Some(format!(
|
|
"self-trade risk with futures open order {}",
|
|
order.order_id
|
|
));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn futures_price_tick(&self, date: NaiveDate, symbol: &str) -> f64 {
|
|
self.data
|
|
.futures_trading_parameter(date, symbol)
|
|
.map(|params| params.price_tick)
|
|
.filter(|tick| tick.is_finite() && *tick > 0.0)
|
|
.or_else(|| {
|
|
self.data
|
|
.market(date, symbol)
|
|
.map(|snapshot| snapshot.effective_price_tick())
|
|
})
|
|
.unwrap_or(1.0)
|
|
.max(1e-9)
|
|
}
|
|
|
|
fn resolve_futures_trading_parameters(
|
|
&self,
|
|
date: NaiveDate,
|
|
mut intent: FuturesOrderIntent,
|
|
) -> FuturesOrderIntent {
|
|
if let Some(params) = self.data.futures_trading_parameter(date, &intent.symbol) {
|
|
intent.spec = params.spec();
|
|
}
|
|
intent
|
|
}
|
|
|
|
fn resolve_futures_transaction_cost(
|
|
&self,
|
|
date: NaiveDate,
|
|
mut intent: FuturesOrderIntent,
|
|
) -> FuturesOrderIntent {
|
|
if intent.transaction_cost > 0.0 {
|
|
return intent;
|
|
}
|
|
if let Some(params) = self.data.futures_trading_parameter(date, &intent.symbol) {
|
|
let close_today_quantity = self.futures_close_today_quantity(&intent);
|
|
intent.transaction_cost = self.futures_cost_model.calculate(
|
|
params,
|
|
intent.effect,
|
|
intent.price,
|
|
intent.quantity,
|
|
close_today_quantity,
|
|
);
|
|
}
|
|
intent
|
|
}
|
|
|
|
fn futures_close_today_quantity(&self, intent: &FuturesOrderIntent) -> u32 {
|
|
match intent.effect {
|
|
FuturesPositionEffect::Open | FuturesPositionEffect::CloseYesterday => 0,
|
|
FuturesPositionEffect::CloseToday => intent.quantity,
|
|
FuturesPositionEffect::Close => self
|
|
.futures_account
|
|
.as_ref()
|
|
.and_then(|account| account.position(&intent.symbol, intent.direction))
|
|
.map(|position| intent.quantity.saturating_sub(position.old_quantity))
|
|
.unwrap_or(0),
|
|
}
|
|
}
|
|
|
|
fn resolve_futures_fill(
|
|
&self,
|
|
date: NaiveDate,
|
|
intent: &FuturesOrderIntent,
|
|
) -> Option<(f64, u32)> {
|
|
if self.broker.execution_price_field() == PriceField::Last {
|
|
if let Some(fill) = self.resolve_futures_intraday_fill(date, intent) {
|
|
return Some(fill);
|
|
}
|
|
}
|
|
if let Some(snapshot) = self.data.market(date, &intent.symbol) {
|
|
if snapshot.paused {
|
|
return None;
|
|
}
|
|
let price = match self.broker.execution_price_field() {
|
|
PriceField::DayOpen => snapshot.day_open,
|
|
PriceField::Open => snapshot.open,
|
|
PriceField::Close => snapshot.close,
|
|
PriceField::Last => match intent.side() {
|
|
OrderSide::Buy => snapshot.buy_price(PriceField::Last),
|
|
OrderSide::Sell => snapshot.sell_price(PriceField::Last),
|
|
},
|
|
};
|
|
if !self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price) {
|
|
return None;
|
|
}
|
|
return Some((price, intent.quantity));
|
|
}
|
|
if intent.price.is_finite() && intent.price > 0.0 {
|
|
if futures_limit_satisfied(intent.side(), intent.price, intent.limit_price) {
|
|
return Some((intent.price, intent.quantity));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn resolve_futures_intraday_fill(
|
|
&self,
|
|
date: NaiveDate,
|
|
intent: &FuturesOrderIntent,
|
|
) -> Option<(f64, u32)> {
|
|
let snapshot = self.data.market(date, &intent.symbol);
|
|
if matches!(
|
|
self.broker.matching_type(),
|
|
MatchingType::MinuteBestCounterparty
|
|
) {
|
|
let depth = self.data.order_book_depth_on(date, &intent.symbol);
|
|
if !depth.is_empty() {
|
|
return self.resolve_futures_depth_fill(date, intent, snapshot);
|
|
}
|
|
}
|
|
let quotes = self.data.execution_quotes_on(date, &intent.symbol);
|
|
for quote in quotes {
|
|
let price = match self.broker.matching_type() {
|
|
MatchingType::MinuteBestOwn => match intent.side() {
|
|
OrderSide::Buy => {
|
|
if quote.bid1.is_finite() && quote.bid1 > 0.0 {
|
|
quote.bid1
|
|
} else {
|
|
quote.last_price
|
|
}
|
|
}
|
|
OrderSide::Sell => {
|
|
if quote.ask1.is_finite() && quote.ask1 > 0.0 {
|
|
quote.ask1
|
|
} else {
|
|
quote.last_price
|
|
}
|
|
}
|
|
},
|
|
MatchingType::MinuteBestCounterparty => match intent.side() {
|
|
OrderSide::Buy => quote.buy_price().unwrap_or(quote.last_price),
|
|
OrderSide::Sell => quote.sell_price().unwrap_or(quote.last_price),
|
|
},
|
|
_ => quote.last_price,
|
|
};
|
|
if let Some(snapshot) = snapshot {
|
|
if !self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price)
|
|
{
|
|
continue;
|
|
}
|
|
} else if !futures_limit_satisfied(intent.side(), price, intent.limit_price) {
|
|
continue;
|
|
}
|
|
let top_level_quantity = match intent.side() {
|
|
OrderSide::Buy => quote.ask1_volume,
|
|
OrderSide::Sell => quote.bid1_volume,
|
|
}
|
|
.max(quote.volume_delta)
|
|
.min(u32::MAX as u64) as u32;
|
|
let fill_quantity = if top_level_quantity == 0 {
|
|
intent.quantity
|
|
} else {
|
|
intent.quantity.min(top_level_quantity)
|
|
};
|
|
if price.is_finite() && price > 0.0 && fill_quantity > 0 {
|
|
return Some((price, fill_quantity));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn resolve_futures_depth_fill(
|
|
&self,
|
|
date: NaiveDate,
|
|
intent: &FuturesOrderIntent,
|
|
snapshot: Option<&crate::data::DailyMarketSnapshot>,
|
|
) -> Option<(f64, u32)> {
|
|
let depth = self.data.order_book_depth_on(date, &intent.symbol);
|
|
let mut cursor = 0usize;
|
|
while cursor < depth.len() {
|
|
let timestamp = depth[cursor].timestamp;
|
|
let start = cursor;
|
|
while cursor < depth.len() && depth[cursor].timestamp == timestamp {
|
|
cursor += 1;
|
|
}
|
|
let mut levels = depth[start..cursor].iter().collect::<Vec<_>>();
|
|
levels.sort_by(|left, right| left.level.cmp(&right.level));
|
|
|
|
let mut filled_quantity = 0_u32;
|
|
let mut gross_amount = 0.0_f64;
|
|
for level in levels {
|
|
let Some(price) = level.executable_price(intent.side()) else {
|
|
continue;
|
|
};
|
|
let can_trade = if let Some(snapshot) = snapshot {
|
|
self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price)
|
|
} else {
|
|
futures_limit_satisfied(intent.side(), price, intent.limit_price)
|
|
};
|
|
if !can_trade {
|
|
if intent.limit_price.is_some() {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
let available_quantity =
|
|
level.executable_volume(intent.side()).min(u32::MAX as u64) as u32;
|
|
if available_quantity == 0 {
|
|
continue;
|
|
}
|
|
let remaining = intent.quantity.saturating_sub(filled_quantity);
|
|
if remaining == 0 {
|
|
break;
|
|
}
|
|
let take_quantity = remaining.min(available_quantity);
|
|
gross_amount += price * take_quantity as f64;
|
|
filled_quantity += take_quantity;
|
|
if filled_quantity >= intent.quantity {
|
|
break;
|
|
}
|
|
}
|
|
if filled_quantity > 0 {
|
|
return Some((gross_amount / filled_quantity as f64, filled_quantity));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn futures_price_can_trade(
|
|
&self,
|
|
snapshot: &crate::data::DailyMarketSnapshot,
|
|
side: OrderSide,
|
|
price: f64,
|
|
limit_price: Option<f64>,
|
|
) -> bool {
|
|
if !price.is_finite() || price <= 0.0 {
|
|
return false;
|
|
}
|
|
if !futures_limit_satisfied(side, price, limit_price) {
|
|
return false;
|
|
}
|
|
match side {
|
|
OrderSide::Buy => !snapshot.is_at_upper_limit_price(price),
|
|
OrderSide::Sell => !snapshot.is_at_lower_limit_price(price),
|
|
}
|
|
}
|
|
|
|
fn cancel_futures_open_order(
|
|
&mut self,
|
|
date: NaiveDate,
|
|
order_id: u64,
|
|
reason: &str,
|
|
) -> FuturesExecutionReport {
|
|
let Some(index) = self
|
|
.futures_open_orders
|
|
.iter()
|
|
.position(|order| order.order_id == order_id)
|
|
else {
|
|
return FuturesExecutionReport::default();
|
|
};
|
|
let order = self.futures_open_orders.remove(index);
|
|
futures_cancel_report(date, order, reason)
|
|
}
|
|
|
|
fn cancel_futures_open_orders_for_symbol(
|
|
&mut self,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
reason: &str,
|
|
) -> FuturesExecutionReport {
|
|
let mut report = FuturesExecutionReport::default();
|
|
let mut retained = Vec::with_capacity(self.futures_open_orders.len());
|
|
let mut canceled = Vec::new();
|
|
for order in self.futures_open_orders.drain(..) {
|
|
if order.intent.symbol == symbol {
|
|
canceled.push(order);
|
|
} else {
|
|
retained.push(order);
|
|
}
|
|
}
|
|
self.futures_open_orders = retained;
|
|
for order in canceled {
|
|
merge_futures_execution_report(&mut report, futures_cancel_report(date, order, reason));
|
|
}
|
|
report
|
|
}
|
|
|
|
fn cancel_all_futures_open_orders(
|
|
&mut self,
|
|
date: NaiveDate,
|
|
reason: &str,
|
|
) -> FuturesExecutionReport {
|
|
let mut report = FuturesExecutionReport::default();
|
|
for order in std::mem::take(&mut self.futures_open_orders) {
|
|
merge_futures_execution_report(&mut report, futures_cancel_report(date, order, reason));
|
|
}
|
|
report
|
|
}
|
|
|
|
pub fn run(&mut self) -> Result<BacktestResult, BacktestError> {
|
|
self.run_with_progress_options(false, false, |_| {})
|
|
}
|
|
|
|
pub fn run_with_progress<F>(&mut self, on_progress: F) -> Result<BacktestResult, BacktestError>
|
|
where
|
|
F: FnMut(&BacktestDayProgress),
|
|
{
|
|
self.run_with_progress_options(true, true, on_progress)
|
|
}
|
|
|
|
pub fn run_with_progress_options<F>(
|
|
&mut self,
|
|
include_progress_details: bool,
|
|
include_progress_diagnostics: bool,
|
|
mut on_progress: F,
|
|
) -> Result<BacktestResult, BacktestError>
|
|
where
|
|
F: FnMut(&BacktestDayProgress),
|
|
{
|
|
let mut portfolio = PortfolioState::new(self.config.initial_cash);
|
|
self.subscriptions = self.strategy.initial_subscriptions();
|
|
let scheduler_calendar = self.data.calendar().clone();
|
|
let scheduler = Scheduler::new(&scheduler_calendar);
|
|
let execution_schedule = backtest_execution_schedule(
|
|
&self.data,
|
|
self.config.start_date,
|
|
self.config.end_date,
|
|
self.config.decision_lag_trading_days,
|
|
);
|
|
let execution_dates = execution_schedule
|
|
.iter()
|
|
.map(|(execution_date, _)| *execution_date)
|
|
.collect::<Vec<_>>();
|
|
let mut result = BacktestResult {
|
|
strategy_name: self.strategy.name().to_string(),
|
|
benchmark_series: self
|
|
.data
|
|
.benchmark_series()
|
|
.into_iter()
|
|
.filter(|row| {
|
|
self.config
|
|
.start_date
|
|
.map(|start| row.date >= start)
|
|
.unwrap_or(true)
|
|
})
|
|
.filter(|row| {
|
|
self.config
|
|
.end_date
|
|
.map(|end| row.date <= end)
|
|
.unwrap_or(true)
|
|
})
|
|
.collect(),
|
|
risk_decisions: Vec::new(),
|
|
order_events: Vec::new(),
|
|
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(),
|
|
metrics: BacktestMetrics::default(),
|
|
terminal_audit: BacktestTerminalAudit::default(),
|
|
};
|
|
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
|
|
let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
|
|
|
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
|
|
let mut corporate_action_notes = Vec::new();
|
|
portfolio.begin_trading_day();
|
|
if let Some(account) = self.futures_account.as_mut() {
|
|
account.begin_trading_day();
|
|
}
|
|
let pending_cash_flow_report = self.settle_pending_cash_flows(
|
|
execution_date,
|
|
&mut portfolio,
|
|
&mut corporate_action_notes,
|
|
)?;
|
|
self.extend_result(
|
|
&mut result,
|
|
pending_cash_flow_report,
|
|
execution_date,
|
|
execution_date,
|
|
);
|
|
let corporate_action_report = self.apply_corporate_actions(
|
|
execution_date,
|
|
&mut portfolio,
|
|
&mut corporate_action_notes,
|
|
)?;
|
|
self.extend_result(
|
|
&mut result,
|
|
corporate_action_report,
|
|
execution_date,
|
|
execution_date,
|
|
);
|
|
let receivable_report = self.settle_cash_receivables(
|
|
execution_date,
|
|
&mut portfolio,
|
|
&mut corporate_action_notes,
|
|
)?;
|
|
self.extend_result(
|
|
&mut result,
|
|
receivable_report,
|
|
execution_date,
|
|
execution_date,
|
|
);
|
|
let delisting_report = self.audit_unresolved_delisted_positions(
|
|
execution_date,
|
|
&portfolio,
|
|
&mut corporate_action_notes,
|
|
);
|
|
self.extend_result(
|
|
&mut result,
|
|
delisting_report,
|
|
execution_date,
|
|
execution_date,
|
|
);
|
|
let futures_open_order_report = self.process_futures_open_orders(execution_date);
|
|
self.extend_result(
|
|
&mut result,
|
|
futures_open_order_report,
|
|
execution_date,
|
|
execution_date,
|
|
);
|
|
let day_order_start = result.order_events.len();
|
|
let day_fill_start = result.fills.len();
|
|
|
|
let decision_slot = execution_schedule
|
|
.get(execution_idx)
|
|
.and_then(|(_, decision_slot)| *decision_slot);
|
|
let Some((decision_index, decision_date)) = decision_slot else {
|
|
let mut process_events = Vec::new();
|
|
let mut report = BrokerExecutionReport::default();
|
|
portfolio.update_prices_with_options(
|
|
execution_date,
|
|
&self.data,
|
|
PriceField::Close,
|
|
self.broker.same_day_buy_close_mark_at_fill(),
|
|
)?;
|
|
let close_report = self.broker.after_trading(execution_date);
|
|
merge_broker_report(&mut report, close_report);
|
|
let futures_daily_settlement_report = self.settle_futures_daily(execution_date);
|
|
merge_broker_report(&mut report, futures_daily_settlement_report);
|
|
let futures_expiration_report = self.settle_futures_expirations(execution_date);
|
|
merge_broker_report(&mut report, futures_expiration_report);
|
|
|
|
let broker_diagnostics = std::mem::take(&mut report.diagnostics);
|
|
let execution_risk_decisions =
|
|
risk_decisions_from_order_events(&report.order_events);
|
|
self.extend_result(&mut result, report, execution_date, execution_date);
|
|
result.risk_decisions.extend(execution_risk_decisions);
|
|
let daily_fill_count = result.fills.len() - day_fill_start;
|
|
let daily_order_count = result.order_events.len() - day_order_start;
|
|
|
|
let benchmark =
|
|
self.data
|
|
.benchmark(execution_date)
|
|
.ok_or(BacktestError::MissingBenchmark {
|
|
date: execution_date,
|
|
})?;
|
|
let notes = join_text_parts(corporate_action_notes.into_iter());
|
|
let diagnostics = join_text_parts(
|
|
std::iter::once(format!(
|
|
"decision_lag_warmup lag_days={} execution_index={}",
|
|
self.config.decision_lag_trading_days, execution_idx
|
|
))
|
|
.chain(broker_diagnostics.into_iter()),
|
|
);
|
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
|
let holding_start = result.daily_holdings.len();
|
|
let holding_count = holdings_for_day.len();
|
|
result.daily_holdings.extend(holdings_for_day);
|
|
let progress_process_start = result.process_events.len();
|
|
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);
|
|
let unit_nav = self.aggregate_unit_net_value(&portfolio)?;
|
|
let external_cash_flow =
|
|
portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
|
|
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
|
|
|
result.equity_curve.push(DailyEquityPoint {
|
|
date: execution_date,
|
|
cash: aggregate_cash,
|
|
market_value: aggregate_market_value,
|
|
total_equity: aggregate_total_equity,
|
|
external_cash_flow,
|
|
unit_nav,
|
|
benchmark_close: benchmark.close,
|
|
benchmark_prev_close: benchmark.prev_close,
|
|
notes,
|
|
diagnostics,
|
|
});
|
|
let latest = result
|
|
.equity_curve
|
|
.last()
|
|
.expect("equity point pushed for progress event");
|
|
on_progress(&BacktestDayProgress {
|
|
date: execution_date,
|
|
cash: latest.cash,
|
|
market_value: latest.market_value,
|
|
total_equity: latest.total_equity,
|
|
external_cash_flow: latest.external_cash_flow,
|
|
unit_nav: latest.unit_nav,
|
|
total_return: latest.unit_nav - 1.0,
|
|
benchmark_close: latest.benchmark_close,
|
|
daily_fill_count,
|
|
daily_order_count,
|
|
cumulative_trade_count: result.fills.len(),
|
|
holding_count,
|
|
notes: include_progress_diagnostics
|
|
.then(|| latest.notes.clone())
|
|
.unwrap_or_default(),
|
|
diagnostics: include_progress_diagnostics
|
|
.then(|| latest.diagnostics.clone())
|
|
.unwrap_or_default(),
|
|
orders: include_progress_details
|
|
.then(|| result.order_events[day_order_start..].to_vec())
|
|
.unwrap_or_default(),
|
|
fills: include_progress_details
|
|
.then(|| result.fills[day_fill_start..].to_vec())
|
|
.unwrap_or_default(),
|
|
holdings: include_progress_details
|
|
.then(|| result.daily_holdings[holding_start..].to_vec())
|
|
.unwrap_or_default(),
|
|
process_events: include_progress_details
|
|
.then(|| result.process_events[progress_process_start..].to_vec())
|
|
.unwrap_or_default(),
|
|
});
|
|
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
|
|
continue;
|
|
};
|
|
let decision_total_equity = (decision_date < execution_date)
|
|
.then(|| stock_equity_by_date.get(&decision_date).copied())
|
|
.flatten();
|
|
let mut process_events = Vec::new();
|
|
let mut directive_report = BrokerExecutionReport::default();
|
|
let pre_open_orders = self.open_order_views();
|
|
let schedule_rules = self.strategy.schedule_rules();
|
|
let keep_timed_rules_on_coarse_stage =
|
|
self.broker.matching_type() == MatchingType::NextBarOpen;
|
|
let coarse_schedule_rules = schedule_rules
|
|
.iter()
|
|
.filter(|rule| {
|
|
keep_timed_rules_on_coarse_stage || !is_on_day_or_bar_physical_time_rule(rule)
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let intraday_schedule_rules = schedule_rules
|
|
.iter()
|
|
.filter(|rule| {
|
|
rule.stage == ScheduleStage::Minute
|
|
|| (!keep_timed_rules_on_coarse_stage
|
|
&& is_on_day_or_bar_physical_time_rule(rule))
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&pre_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PreBeforeTrading,
|
|
"before_trading:pre",
|
|
)?;
|
|
self.strategy.before_trading(&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data: &self.data,
|
|
portfolio: &portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders: &pre_open_orders,
|
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
|
subscriptions: &self.subscriptions,
|
|
process_events: &process_events,
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(
|
|
decision_date,
|
|
default_stage_time(ScheduleStage::BeforeTrading),
|
|
),
|
|
order_events: result.order_events.as_slice(),
|
|
fills: result.fills.as_slice(),
|
|
})?;
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&pre_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::BeforeTrading,
|
|
"before_trading",
|
|
)?;
|
|
let mut before_trading_decision = collect_scheduled_decisions_for_stage(
|
|
&mut self.strategy,
|
|
&scheduler,
|
|
execution_date,
|
|
ScheduleStage::BeforeTrading,
|
|
&coarse_schedule_rules,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&pre_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut self.process_event_bus,
|
|
result.order_events.as_slice(),
|
|
result.fills.as_slice(),
|
|
)?;
|
|
self.apply_strategy_directives(
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&mut portfolio,
|
|
&pre_open_orders,
|
|
&mut process_events,
|
|
&mut before_trading_decision,
|
|
&mut directive_report,
|
|
)?;
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&pre_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PostBeforeTrading,
|
|
"before_trading:post",
|
|
)?;
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&pre_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PreOpenAuction,
|
|
"open_auction:pre",
|
|
)?;
|
|
let mut auction_decision = collect_scheduled_decisions_for_stage(
|
|
&mut self.strategy,
|
|
&scheduler,
|
|
execution_date,
|
|
ScheduleStage::OpenAuction,
|
|
&coarse_schedule_rules,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&pre_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut self.process_event_bus,
|
|
result.order_events.as_slice(),
|
|
result.fills.as_slice(),
|
|
)?;
|
|
auction_decision.merge_from(self.strategy.open_auction(&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data: &self.data,
|
|
portfolio: &portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders: &pre_open_orders,
|
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
|
subscriptions: &self.subscriptions,
|
|
process_events: &process_events,
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(
|
|
decision_date,
|
|
default_stage_time(ScheduleStage::OpenAuction),
|
|
),
|
|
order_events: result.order_events.as_slice(),
|
|
fills: result.fills.as_slice(),
|
|
})?);
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&pre_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::OpenAuction,
|
|
"open_auction",
|
|
)?;
|
|
self.apply_strategy_directives(
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&mut portfolio,
|
|
&pre_open_orders,
|
|
&mut process_events,
|
|
&mut auction_decision,
|
|
&mut directive_report,
|
|
)?;
|
|
let pre_auction_execution_orders = self.open_order_views();
|
|
self.ensure_execution_quotes_for_decision(
|
|
execution_date,
|
|
decision_date,
|
|
&portfolio,
|
|
&pre_auction_execution_orders,
|
|
&auction_decision,
|
|
None,
|
|
None,
|
|
)?;
|
|
let mut report = self.broker.execute_with_event_dates_and_decision_equity(
|
|
execution_date,
|
|
decision_date,
|
|
decision_date,
|
|
decision_total_equity,
|
|
&mut portfolio,
|
|
&self.data,
|
|
&auction_decision,
|
|
)?;
|
|
let post_auction_open_orders = self.open_order_views();
|
|
publish_process_events(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_auction_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut report.process_events,
|
|
)?;
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_auction_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PostOpenAuction,
|
|
"open_auction:post",
|
|
)?;
|
|
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_auction_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PreOnDay,
|
|
"on_day:pre",
|
|
)?;
|
|
let on_day_open_orders = self.open_order_views();
|
|
let decision_quote_times = self.strategy.decision_quote_times();
|
|
if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
|
|
if let Some(preplanned) = self
|
|
.preplanned_decision_quote_symbols_by_date
|
|
.as_ref()
|
|
.map(Arc::clone)
|
|
{
|
|
let empty_symbols = BTreeSet::new();
|
|
let decision_quote_symbols = preplanned
|
|
.get(&execution_date)
|
|
.unwrap_or(&empty_symbols);
|
|
self.ensure_execution_quotes_for_symbols_at_times(
|
|
execution_date,
|
|
decision_quote_symbols,
|
|
&decision_quote_times,
|
|
)?;
|
|
} else {
|
|
let decision_quote_symbols =
|
|
self.strategy.decision_quote_symbols(&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data: &self.data,
|
|
portfolio: &portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders: &on_day_open_orders,
|
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
|
subscriptions: &self.subscriptions,
|
|
process_events: &process_events,
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(
|
|
decision_date,
|
|
default_stage_time(ScheduleStage::OnDay),
|
|
),
|
|
order_events: result.order_events.as_slice(),
|
|
fills: result.fills.as_slice(),
|
|
})?;
|
|
self.ensure_execution_quotes_for_symbols_at_times(
|
|
execution_date,
|
|
&decision_quote_symbols,
|
|
&decision_quote_times,
|
|
)?;
|
|
}
|
|
}
|
|
self.ensure_execution_quotes_for_portfolio_times(
|
|
execution_date,
|
|
&portfolio,
|
|
&decision_quote_times,
|
|
)?;
|
|
let mut decision = decision_slot
|
|
.map(|(decision_idx, decision_date)| {
|
|
self.strategy.on_day(&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index: decision_idx,
|
|
data: &self.data,
|
|
portfolio: &portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders: &on_day_open_orders,
|
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
|
subscriptions: &self.subscriptions,
|
|
process_events: &process_events,
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(
|
|
decision_date,
|
|
default_stage_time(ScheduleStage::OnDay),
|
|
),
|
|
order_events: result.order_events.as_slice(),
|
|
fills: result.fills.as_slice(),
|
|
})
|
|
})
|
|
.transpose()?
|
|
.unwrap_or_default();
|
|
decision.merge_from(collect_scheduled_decisions_for_stage(
|
|
&mut self.strategy,
|
|
&scheduler,
|
|
execution_date,
|
|
ScheduleStage::OnDay,
|
|
&coarse_schedule_rules,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&on_day_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut self.process_event_bus,
|
|
result.order_events.as_slice(),
|
|
result.fills.as_slice(),
|
|
)?);
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&on_day_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::OnDay,
|
|
"on_day",
|
|
)?;
|
|
let bar_open_orders = self.open_order_views();
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&bar_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PreBar,
|
|
"bar:pre",
|
|
)?;
|
|
decision.merge_from(collect_scheduled_decisions_for_stage(
|
|
&mut self.strategy,
|
|
&scheduler,
|
|
execution_date,
|
|
ScheduleStage::Bar,
|
|
&coarse_schedule_rules,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&bar_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut self.process_event_bus,
|
|
result.order_events.as_slice(),
|
|
result.fills.as_slice(),
|
|
)?);
|
|
decision.merge_from(self.strategy.on_bar(&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data: &self.data,
|
|
portfolio: &portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders: &bar_open_orders,
|
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
|
subscriptions: &self.subscriptions,
|
|
process_events: &process_events,
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(
|
|
decision_date,
|
|
default_stage_time(ScheduleStage::Bar),
|
|
),
|
|
order_events: result.order_events.as_slice(),
|
|
fills: result.fills.as_slice(),
|
|
})?);
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&bar_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::Bar,
|
|
"bar",
|
|
)?;
|
|
self.apply_strategy_directives(
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&mut portfolio,
|
|
&on_day_open_orders,
|
|
&mut process_events,
|
|
&mut decision,
|
|
&mut directive_report,
|
|
)?;
|
|
|
|
let pre_intraday_execution_orders = self.open_order_views();
|
|
self.ensure_execution_quotes_for_decision(
|
|
execution_date,
|
|
decision_date,
|
|
&portfolio,
|
|
&pre_intraday_execution_orders,
|
|
&decision,
|
|
None,
|
|
None,
|
|
)?;
|
|
let mut intraday_report = self.broker.execute_with_event_dates_and_decision_equity(
|
|
execution_date,
|
|
decision_date,
|
|
decision_date,
|
|
decision_total_equity,
|
|
&mut portfolio,
|
|
&self.data,
|
|
&decision,
|
|
)?;
|
|
let post_intraday_open_orders = self.open_order_views();
|
|
publish_process_events(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_intraday_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut intraday_report.process_events,
|
|
)?;
|
|
report.order_events.extend(intraday_report.order_events);
|
|
report.fill_events.extend(intraday_report.fill_events);
|
|
report
|
|
.position_events
|
|
.extend(intraday_report.position_events);
|
|
report.account_events.extend(intraday_report.account_events);
|
|
report.diagnostics.extend(intraday_report.diagnostics);
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_intraday_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PostOnDay,
|
|
"on_day:post",
|
|
)?;
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_intraday_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PostBar,
|
|
"bar:post",
|
|
)?;
|
|
|
|
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) {
|
|
if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() {
|
|
let mut minute_symbols = self.subscriptions.clone();
|
|
self.load_missing_execution_quotes(
|
|
execution_date,
|
|
None,
|
|
None,
|
|
&mut minute_symbols,
|
|
)?;
|
|
}
|
|
// Keep the iterator attached to an O(1) DataSet clone. This
|
|
// preserves the immutable quote snapshot for the day while
|
|
// allowing lazy quote loads and broker state updates on self.
|
|
let quote_data = self.data.clone();
|
|
let mut minute_quotes = quote_data
|
|
.execution_quotes_iter_on_date_for_symbols(
|
|
execution_date,
|
|
(!self.subscriptions.is_empty()).then_some(&self.subscriptions),
|
|
)
|
|
.peekable();
|
|
let requires_minute_callbacks = self.strategy.requires_minute_callbacks();
|
|
let has_minute_process_listeners = self.process_event_bus.has_listeners_for(&[
|
|
ProcessEventKind::PreMinute,
|
|
ProcessEventKind::Minute,
|
|
ProcessEventKind::PostMinute,
|
|
]);
|
|
let minute_all_time_rules = intraday_schedule_rules
|
|
.iter()
|
|
.filter(|rule| rule.stage == ScheduleStage::Minute && rule.time_rule.is_none())
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let minute_schedule_all_times = !minute_all_time_rules.is_empty();
|
|
let mut minute_schedule_timestamps = intraday_schedule_rules
|
|
.iter()
|
|
.filter(|rule| scheduler.is_due_on(decision_date, rule))
|
|
.filter_map(|rule| {
|
|
let minute = rule.time_rule.as_ref()?.minute_of_day()?;
|
|
NaiveTime::from_hms_opt(minute / 60, minute % 60, 0)
|
|
})
|
|
.map(|time| execution_date.and_time(time))
|
|
.collect::<BTreeSet<_>>()
|
|
.into_iter()
|
|
.peekable();
|
|
let mut minute_group = Vec::new();
|
|
// Merge the immutable quote stream with clock events. Equal
|
|
// timestamps form one event; scheduled callbacks run before
|
|
// `on_minute` below.
|
|
loop {
|
|
let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp);
|
|
let next_schedule_timestamp = minute_schedule_timestamps.peek().copied();
|
|
let Some(minute_timestamp) =
|
|
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp)
|
|
else {
|
|
break;
|
|
};
|
|
let minute_time = minute_timestamp.time();
|
|
minute_group.clear();
|
|
while minute_quotes
|
|
.peek()
|
|
.is_some_and(|quote| quote.timestamp == minute_timestamp)
|
|
{
|
|
minute_group.push(
|
|
minute_quotes
|
|
.next()
|
|
.expect("peeked minute quote must be available"),
|
|
);
|
|
}
|
|
let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp);
|
|
if has_specific_schedule {
|
|
minute_schedule_timestamps.next();
|
|
}
|
|
let schedule_candidate = has_specific_schedule
|
|
|| (minute_schedule_all_times && !minute_group.is_empty());
|
|
if !requires_minute_callbacks
|
|
&& !has_minute_process_listeners
|
|
&& !schedule_candidate
|
|
&& !self.has_open_orders()
|
|
{
|
|
continue;
|
|
}
|
|
let minute_open_orders = self.open_order_views();
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&minute_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PreMinute,
|
|
format!("minute:{minute_timestamp}:pre"),
|
|
)?;
|
|
let mut minute_decision = if schedule_candidate {
|
|
let event_rules = if has_specific_schedule {
|
|
intraday_schedule_rules.as_slice()
|
|
} else {
|
|
minute_all_time_rules.as_slice()
|
|
};
|
|
let mut scheduled = StrategyDecision::default();
|
|
for stage in [
|
|
ScheduleStage::OnDay,
|
|
ScheduleStage::Bar,
|
|
ScheduleStage::Minute,
|
|
] {
|
|
scheduled.merge_from(collect_scheduled_decisions(
|
|
&mut self.strategy,
|
|
&scheduler,
|
|
execution_date,
|
|
stage,
|
|
event_rules,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&minute_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut self.process_event_bus,
|
|
Some(minute_time),
|
|
result.order_events.as_slice(),
|
|
result.fills.as_slice(),
|
|
)?);
|
|
}
|
|
scheduled
|
|
} else {
|
|
crate::strategy::StrategyDecision::default()
|
|
};
|
|
if requires_minute_callbacks {
|
|
for "e in &minute_group {
|
|
minute_decision.merge_from(self.strategy.on_minute(
|
|
&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data: &self.data,
|
|
portfolio: &portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders: &minute_open_orders,
|
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
|
subscriptions: &self.subscriptions,
|
|
process_events: &process_events,
|
|
active_process_event: None,
|
|
active_datetime: Some(minute_timestamp),
|
|
order_events: result.order_events.as_slice(),
|
|
fills: result.fills.as_slice(),
|
|
},
|
|
quote,
|
|
)?);
|
|
}
|
|
}
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&minute_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::Minute,
|
|
format!("minute:{minute_timestamp}"),
|
|
)?;
|
|
self.apply_strategy_directives(
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&mut portfolio,
|
|
&minute_open_orders,
|
|
&mut process_events,
|
|
&mut minute_decision,
|
|
&mut directive_report,
|
|
)?;
|
|
let pre_minute_execution_orders = self.open_order_views();
|
|
self.ensure_execution_quotes_for_decision(
|
|
execution_date,
|
|
decision_date,
|
|
&portfolio,
|
|
&pre_minute_execution_orders,
|
|
&minute_decision,
|
|
Some(minute_time),
|
|
Some(minute_time),
|
|
)?;
|
|
let mut minute_report = self
|
|
.broker
|
|
.execute_between_with_event_dates_and_decision_equity(
|
|
execution_date,
|
|
decision_date,
|
|
decision_date,
|
|
decision_total_equity,
|
|
&mut portfolio,
|
|
&self.data,
|
|
&minute_decision,
|
|
Some(minute_time),
|
|
Some(minute_time),
|
|
)?;
|
|
let post_minute_open_orders = self.open_order_views();
|
|
publish_process_events(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_minute_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut minute_report.process_events,
|
|
)?;
|
|
merge_broker_report(&mut report, minute_report);
|
|
decision.notes.append(&mut minute_decision.notes);
|
|
decision
|
|
.diagnostics
|
|
.append(&mut minute_decision.diagnostics);
|
|
decision
|
|
.risk_decisions
|
|
.append(&mut minute_decision.risk_decisions);
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_minute_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PostMinute,
|
|
format!("minute:{minute_timestamp}:post"),
|
|
)?;
|
|
}
|
|
drop(minute_group);
|
|
drop(minute_quotes);
|
|
drop(quote_data);
|
|
self.data.release_execution_quotes_on_date(execution_date);
|
|
}
|
|
|
|
portfolio.update_prices_with_options(
|
|
execution_date,
|
|
&self.data,
|
|
PriceField::Close,
|
|
self.broker.same_day_buy_close_mark_at_fill(),
|
|
)?;
|
|
|
|
let post_trade_open_orders = self.open_order_views();
|
|
let mut broker_diagnostics = std::mem::take(&mut report.diagnostics);
|
|
self.extend_result(
|
|
&mut result,
|
|
std::mem::take(&mut report),
|
|
decision_date,
|
|
execution_date,
|
|
);
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_trade_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PreAfterTrading,
|
|
"after_trading:pre",
|
|
)?;
|
|
self.strategy.after_trading(&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data: &self.data,
|
|
portfolio: &portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders: &post_trade_open_orders,
|
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
|
subscriptions: &self.subscriptions,
|
|
process_events: &process_events,
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(
|
|
decision_date,
|
|
default_stage_time(ScheduleStage::AfterTrading),
|
|
),
|
|
order_events: result.order_events.as_slice(),
|
|
fills: result.fills.as_slice(),
|
|
})?;
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_trade_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::AfterTrading,
|
|
"after_trading",
|
|
)?;
|
|
let mut after_trading_decision = collect_scheduled_decisions_for_stage(
|
|
&mut self.strategy,
|
|
&scheduler,
|
|
execution_date,
|
|
ScheduleStage::AfterTrading,
|
|
&coarse_schedule_rules,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_trade_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut self.process_event_bus,
|
|
result.order_events.as_slice(),
|
|
result.fills.as_slice(),
|
|
)?;
|
|
self.apply_strategy_directives(
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&mut portfolio,
|
|
&post_trade_open_orders,
|
|
&mut process_events,
|
|
&mut after_trading_decision,
|
|
&mut directive_report,
|
|
)?;
|
|
let mut close_report = self.broker.after_trading(execution_date);
|
|
publish_process_events(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_trade_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut close_report.process_events,
|
|
)?;
|
|
report.order_events.extend(close_report.order_events);
|
|
report.fill_events.extend(close_report.fill_events);
|
|
report.position_events.extend(close_report.position_events);
|
|
report.account_events.extend(close_report.account_events);
|
|
report.diagnostics.extend(close_report.diagnostics);
|
|
let post_close_open_orders = self.open_order_views();
|
|
broker_diagnostics.append(&mut report.diagnostics);
|
|
self.extend_result(
|
|
&mut result,
|
|
std::mem::take(&mut report),
|
|
decision_date,
|
|
execution_date,
|
|
);
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_close_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PostAfterTrading,
|
|
"after_trading:post",
|
|
)?;
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_close_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PreSettlement,
|
|
"settlement:pre",
|
|
)?;
|
|
self.strategy.on_settlement(&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data: &self.data,
|
|
portfolio: &portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders: &post_close_open_orders,
|
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
|
subscriptions: &self.subscriptions,
|
|
process_events: &process_events,
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(
|
|
decision_date,
|
|
default_stage_time(ScheduleStage::Settlement),
|
|
),
|
|
order_events: result.order_events.as_slice(),
|
|
fills: result.fills.as_slice(),
|
|
})?;
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_close_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::Settlement,
|
|
"settlement",
|
|
)?;
|
|
let mut settlement_decision = collect_scheduled_decisions_for_stage(
|
|
&mut self.strategy,
|
|
&scheduler,
|
|
execution_date,
|
|
ScheduleStage::Settlement,
|
|
&coarse_schedule_rules,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_close_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
&mut self.process_event_bus,
|
|
result.order_events.as_slice(),
|
|
result.fills.as_slice(),
|
|
)?;
|
|
self.apply_strategy_directives(
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&mut portfolio,
|
|
&post_close_open_orders,
|
|
&mut process_events,
|
|
&mut settlement_decision,
|
|
&mut directive_report,
|
|
)?;
|
|
let futures_daily_settlement_report = self.settle_futures_daily(execution_date);
|
|
merge_broker_report(&mut directive_report, futures_daily_settlement_report);
|
|
let futures_expiration_report = self.settle_futures_expirations(execution_date);
|
|
merge_broker_report(&mut directive_report, futures_expiration_report);
|
|
let management_fee_report = if portfolio.management_fee_rate() <= 0.0 {
|
|
BrokerExecutionReport::default()
|
|
} else {
|
|
// The strategy context needs an immutable view while the
|
|
// engine mutably invokes the strategy. Avoid cloning these
|
|
// potentially large sets unless management fees are enabled.
|
|
let dynamic_universe_snapshot = self.dynamic_universe.clone();
|
|
let subscriptions_snapshot = self.subscriptions.clone();
|
|
self.apply_management_fee(
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&mut portfolio,
|
|
&post_close_open_orders,
|
|
dynamic_universe_snapshot.as_ref(),
|
|
&subscriptions_snapshot,
|
|
&mut process_events,
|
|
result.order_events.as_slice(),
|
|
result.fills.as_slice(),
|
|
)?
|
|
};
|
|
merge_broker_report(&mut directive_report, management_fee_report);
|
|
publish_phase_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&portfolio,
|
|
self.futures_account.as_ref(),
|
|
&post_close_open_orders,
|
|
self.dynamic_universe.as_ref(),
|
|
&self.subscriptions,
|
|
&mut process_events,
|
|
execution_date,
|
|
ProcessEventKind::PostSettlement,
|
|
"settlement:post",
|
|
)?;
|
|
merge_broker_report(&mut report, directive_report);
|
|
broker_diagnostics.append(&mut report.diagnostics);
|
|
self.extend_result(
|
|
&mut result,
|
|
std::mem::take(&mut report),
|
|
decision_date,
|
|
execution_date,
|
|
);
|
|
let daily_fill_count = result.fills.len() - day_fill_start;
|
|
let daily_order_count = result.order_events.len() - day_order_start;
|
|
let execution_risk_decisions =
|
|
risk_decisions_from_order_events(&result.order_events[day_order_start..]);
|
|
result.risk_decisions.extend(decision.risk_decisions);
|
|
result.risk_decisions.extend(execution_risk_decisions);
|
|
|
|
let benchmark =
|
|
self.data
|
|
.benchmark(execution_date)
|
|
.ok_or(BacktestError::MissingBenchmark {
|
|
date: execution_date,
|
|
})?;
|
|
let notes = join_text_parts(
|
|
corporate_action_notes
|
|
.into_iter()
|
|
.chain(decision.notes.into_iter()),
|
|
);
|
|
let diagnostics = join_text_parts(
|
|
decision
|
|
.diagnostics
|
|
.into_iter()
|
|
.chain(broker_diagnostics.into_iter()),
|
|
);
|
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
|
let holding_start = result.daily_holdings.len();
|
|
let holding_count = holdings_for_day.len();
|
|
result.daily_holdings.extend(holdings_for_day);
|
|
let progress_process_start = result.process_events.len();
|
|
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);
|
|
let unit_nav = self.aggregate_unit_net_value(&portfolio)?;
|
|
let external_cash_flow =
|
|
portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
|
|
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
|
|
|
result.equity_curve.push(DailyEquityPoint {
|
|
date: execution_date,
|
|
cash: aggregate_cash,
|
|
market_value: aggregate_market_value,
|
|
total_equity: aggregate_total_equity,
|
|
external_cash_flow,
|
|
unit_nav,
|
|
benchmark_close: benchmark.close,
|
|
benchmark_prev_close: benchmark.prev_close,
|
|
notes,
|
|
diagnostics,
|
|
});
|
|
let latest = result
|
|
.equity_curve
|
|
.last()
|
|
.expect("equity point pushed for progress event");
|
|
on_progress(&BacktestDayProgress {
|
|
date: execution_date,
|
|
cash: latest.cash,
|
|
market_value: latest.market_value,
|
|
total_equity: latest.total_equity,
|
|
external_cash_flow: latest.external_cash_flow,
|
|
unit_nav: latest.unit_nav,
|
|
total_return: latest.unit_nav - 1.0,
|
|
benchmark_close: latest.benchmark_close,
|
|
daily_fill_count,
|
|
daily_order_count,
|
|
cumulative_trade_count: result.fills.len(),
|
|
holding_count,
|
|
notes: include_progress_diagnostics
|
|
.then(|| latest.notes.clone())
|
|
.unwrap_or_default(),
|
|
diagnostics: include_progress_diagnostics
|
|
.then(|| latest.diagnostics.clone())
|
|
.unwrap_or_default(),
|
|
orders: include_progress_details
|
|
.then(|| result.order_events[day_order_start..].to_vec())
|
|
.unwrap_or_default(),
|
|
fills: include_progress_details
|
|
.then(|| result.fills[day_fill_start..].to_vec())
|
|
.unwrap_or_default(),
|
|
holdings: include_progress_details
|
|
.then(|| result.daily_holdings[holding_start..].to_vec())
|
|
.unwrap_or_default(),
|
|
process_events: include_progress_details
|
|
.then(|| result.process_events[progress_process_start..].to_vec())
|
|
.unwrap_or_default(),
|
|
});
|
|
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
|
|
}
|
|
|
|
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,
|
|
&result.daily_holdings,
|
|
&result.account_events,
|
|
self.aggregate_initial_cash(),
|
|
self.risk_free_rate_contract.as_ref(),
|
|
)
|
|
.map_err(BacktestError::Execution)?;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
fn extend_result(
|
|
&self,
|
|
result: &mut BacktestResult,
|
|
mut report: BrokerExecutionReport,
|
|
decision_date: NaiveDate,
|
|
execution_date: NaiveDate,
|
|
) {
|
|
annotate_broker_report_dates(&mut report, decision_date, decision_date, execution_date);
|
|
result.order_events.append(&mut report.order_events);
|
|
result.fills.append(&mut report.fill_events);
|
|
result.position_events.append(&mut report.position_events);
|
|
result.account_events.append(&mut report.account_events);
|
|
self.retain_process_events(&mut result.process_events, &mut report.process_events);
|
|
}
|
|
|
|
fn retain_process_events(
|
|
&self,
|
|
target: &mut Vec<ProcessEvent>,
|
|
incoming: &mut Vec<ProcessEvent>,
|
|
) {
|
|
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(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
notes: &mut Vec<String>,
|
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
|
let mut report = BrokerExecutionReport::default();
|
|
for action in self.data.corporate_actions_on(date) {
|
|
if !action.has_effect() {
|
|
continue;
|
|
}
|
|
let Some(existing_position) = portfolio.position(&action.symbol) else {
|
|
continue;
|
|
};
|
|
if existing_position.quantity == 0 {
|
|
continue;
|
|
}
|
|
|
|
if self.cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON {
|
|
let cash_before = portfolio.cash();
|
|
let (cash_delta, quantity_after, average_cost) = {
|
|
let position = portfolio
|
|
.position_mut_if_exists(&action.symbol)
|
|
.expect("position exists for dividend action");
|
|
let cash_delta = if self.cash_dividend_adjusts_cost_basis {
|
|
position.apply_cash_dividend(action.share_cash)
|
|
} else {
|
|
position.apply_cash_dividend_preserve_cost_basis(action.share_cash)
|
|
};
|
|
(cash_delta, position.quantity, position.average_cost)
|
|
};
|
|
if cash_delta.abs() > f64::EPSILON {
|
|
let payable_date = action.payable_date.unwrap_or(date);
|
|
portfolio.add_cash_receivable(CashReceivable {
|
|
symbol: action.symbol.clone(),
|
|
ex_date: date,
|
|
payable_date,
|
|
amount: cash_delta,
|
|
reason: format!("cash_dividend {:.6}", action.share_cash),
|
|
});
|
|
let note = format!(
|
|
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
|
|
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
|
|
);
|
|
notes.push(note.clone());
|
|
report.account_events.push(AccountEvent {
|
|
date,
|
|
cash_before,
|
|
cash_after: portfolio.cash(),
|
|
total_equity: portfolio.total_equity(),
|
|
note,
|
|
});
|
|
report.position_events.push(PositionEvent {
|
|
date,
|
|
symbol: action.symbol.clone(),
|
|
delta_quantity: 0,
|
|
quantity_after,
|
|
average_cost,
|
|
realized_pnl_delta: 0.0,
|
|
reason: format!("cash_dividend {:.6}", action.share_cash),
|
|
});
|
|
}
|
|
}
|
|
|
|
let split_ratio = action.split_ratio();
|
|
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
|
let (delta_quantity, quantity_after, average_cost) = {
|
|
let position = portfolio
|
|
.position_mut_if_exists(&action.symbol)
|
|
.expect("position exists for split action");
|
|
let delta_quantity = position.apply_split_ratio(split_ratio);
|
|
(delta_quantity, position.quantity, position.average_cost)
|
|
};
|
|
if delta_quantity != 0 {
|
|
let note = format!(
|
|
"stock_split {} ratio={:.6} delta_qty={}",
|
|
action.symbol, split_ratio, delta_quantity
|
|
);
|
|
notes.push(note);
|
|
report.position_events.push(PositionEvent {
|
|
date,
|
|
symbol: action.symbol.clone(),
|
|
delta_quantity,
|
|
quantity_after,
|
|
average_cost,
|
|
realized_pnl_delta: 0.0,
|
|
reason: format!("stock_split {:.6}", split_ratio),
|
|
});
|
|
}
|
|
}
|
|
|
|
if action.has_successor_conversion() {
|
|
let successor_symbol = action
|
|
.successor_symbol
|
|
.as_deref()
|
|
.expect("successor symbol checked");
|
|
let Some(outcome) = portfolio.apply_successor_conversion(
|
|
&action.symbol,
|
|
successor_symbol,
|
|
action.successor_ratio_value(),
|
|
action.successor_cash_value(),
|
|
) else {
|
|
continue;
|
|
};
|
|
let reason = format!(
|
|
"successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}",
|
|
outcome.old_symbol,
|
|
outcome.new_symbol,
|
|
action.successor_ratio_value(),
|
|
action.successor_cash_value()
|
|
);
|
|
notes.push(reason.clone());
|
|
report.position_events.push(PositionEvent {
|
|
date,
|
|
symbol: outcome.old_symbol.clone(),
|
|
delta_quantity: -(outcome.old_quantity as i32),
|
|
quantity_after: 0,
|
|
average_cost: 0.0,
|
|
realized_pnl_delta: 0.0,
|
|
reason: reason.clone(),
|
|
});
|
|
report.position_events.push(PositionEvent {
|
|
date,
|
|
symbol: outcome.new_symbol.clone(),
|
|
delta_quantity: outcome.new_quantity_delta,
|
|
quantity_after: outcome.new_quantity_after,
|
|
average_cost: outcome.new_average_cost_after,
|
|
realized_pnl_delta: 0.0,
|
|
reason: reason.clone(),
|
|
});
|
|
if outcome.cash_delta.abs() > f64::EPSILON {
|
|
let cash_before = portfolio.cash();
|
|
portfolio
|
|
.apply_cash_delta(outcome.cash_delta)
|
|
.map_err(BacktestError::Execution)?;
|
|
report.account_events.push(AccountEvent {
|
|
date,
|
|
cash_before,
|
|
cash_after: portfolio.cash(),
|
|
total_equity: portfolio.total_equity(),
|
|
note: format!("{} cash={:.2}", reason, outcome.cash_delta),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
portfolio.prune_flat_positions();
|
|
Ok(report)
|
|
}
|
|
|
|
fn settle_cash_receivables(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
notes: &mut Vec<String>,
|
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
|
let mut report = BrokerExecutionReport::default();
|
|
let due = portfolio.take_due_cash_receivables(date);
|
|
for receivable in due {
|
|
let cash_before = portfolio.cash();
|
|
portfolio
|
|
.settle_cash_receivable(&receivable)
|
|
.map_err(BacktestError::Execution)?;
|
|
let mut note = format!(
|
|
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
|
|
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
|
|
);
|
|
if self.dividend_reinvestment
|
|
&& receivable.reason.starts_with("cash_dividend")
|
|
&& receivable.amount > 0.0
|
|
{
|
|
let reinvest_price = portfolio
|
|
.position(&receivable.symbol)
|
|
.map(|position| position.last_price)
|
|
.filter(|price| price.is_finite() && *price > 0.0)
|
|
.or_else(|| {
|
|
self.data
|
|
.calendar()
|
|
.previous_day(date)
|
|
.and_then(|prev_date| {
|
|
self.data.price_on_or_before(
|
|
prev_date,
|
|
&receivable.symbol,
|
|
PriceField::Close,
|
|
)
|
|
})
|
|
});
|
|
let round_lot = self
|
|
.data
|
|
.instrument(&receivable.symbol)
|
|
.map(|instrument| instrument.round_lot.max(1))
|
|
.unwrap_or(100);
|
|
if let Some(price) = reinvest_price {
|
|
let raw_quantity = (receivable.amount / price).floor() as u32;
|
|
let reinvest_quantity = (raw_quantity / round_lot) * round_lot;
|
|
if reinvest_quantity > 0 {
|
|
let reinvest_cash = reinvest_quantity as f64 * price;
|
|
let residual_cash = receivable.amount - reinvest_cash;
|
|
portfolio
|
|
.apply_cash_delta(-reinvest_cash)
|
|
.map_err(BacktestError::Execution)?;
|
|
portfolio.position_mut(&receivable.symbol).buy(
|
|
date,
|
|
reinvest_quantity,
|
|
price,
|
|
);
|
|
|
|
note = format!(
|
|
"cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}",
|
|
receivable.symbol,
|
|
receivable.ex_date,
|
|
receivable.payable_date,
|
|
receivable.amount,
|
|
reinvest_quantity,
|
|
price,
|
|
residual_cash
|
|
);
|
|
report.fill_events.push(FillEvent {
|
|
date,
|
|
decision_date: None,
|
|
order_created_date: None,
|
|
execution_date: None,
|
|
execution_start_timestamp: None,
|
|
execution_timestamp: None,
|
|
order_id: None,
|
|
symbol: receivable.symbol.clone(),
|
|
side: OrderSide::Buy,
|
|
quantity: reinvest_quantity,
|
|
price,
|
|
gross_amount: reinvest_cash,
|
|
commission: 0.0,
|
|
stamp_tax: 0.0,
|
|
transfer_fee: 0.0,
|
|
net_cash_flow: -reinvest_cash,
|
|
reason: "dividend_reinvestment".to_string(),
|
|
});
|
|
report.position_events.push(PositionEvent {
|
|
date,
|
|
symbol: receivable.symbol.clone(),
|
|
delta_quantity: reinvest_quantity as i32,
|
|
quantity_after: portfolio
|
|
.position(&receivable.symbol)
|
|
.map(|position| position.quantity)
|
|
.unwrap_or(0),
|
|
average_cost: portfolio
|
|
.position(&receivable.symbol)
|
|
.map(|position| position.average_cost)
|
|
.unwrap_or(0.0),
|
|
realized_pnl_delta: 0.0,
|
|
reason: "dividend_reinvestment".to_string(),
|
|
});
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::Trade,
|
|
order_id: None,
|
|
symbol: Some(receivable.symbol.clone()),
|
|
side: Some(OrderSide::Buy),
|
|
detail: format!(
|
|
"dividend_reinvestment quantity={} price={}",
|
|
reinvest_quantity, price
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
notes.push(note.clone());
|
|
report.account_events.push(AccountEvent {
|
|
date,
|
|
cash_before,
|
|
cash_after: portfolio.cash(),
|
|
total_equity: portfolio.total_equity(),
|
|
note,
|
|
});
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
fn settle_pending_cash_flows(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
notes: &mut Vec<String>,
|
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
|
let mut report = BrokerExecutionReport::default();
|
|
for flow in portfolio
|
|
.settle_pending_cash_flows(date)
|
|
.map_err(BacktestError::Execution)?
|
|
{
|
|
let cash_before = portfolio.cash() - flow.amount;
|
|
let note = format!(
|
|
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
|
|
flow.amount, flow.payable_date, flow.reason
|
|
);
|
|
notes.push(note.clone());
|
|
report.account_events.push(AccountEvent {
|
|
date,
|
|
cash_before,
|
|
cash_after: portfolio.cash(),
|
|
total_equity: portfolio.total_equity(),
|
|
note,
|
|
});
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
fn settle_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport {
|
|
let mut report = BrokerExecutionReport::default();
|
|
let Some(expirations) = self.futures_expirations.remove(&date) else {
|
|
return report;
|
|
};
|
|
let Some(account) = self.futures_account.as_mut() else {
|
|
report.diagnostics.push(format!(
|
|
"futures_expiration_skipped date={date} reason=no_future_account count={}",
|
|
expirations.len()
|
|
));
|
|
return report;
|
|
};
|
|
for (symbol, settlement_price) in expirations {
|
|
let futures_report =
|
|
account.expire_contract(date, &symbol, settlement_price, "data_driven_expiration");
|
|
merge_futures_report(&mut report, futures_report);
|
|
}
|
|
report
|
|
}
|
|
|
|
fn settle_futures_daily(&mut self, date: NaiveDate) -> BrokerExecutionReport {
|
|
let mut report = BrokerExecutionReport::default();
|
|
let Some(account) = self.futures_account.as_mut() else {
|
|
return report;
|
|
};
|
|
let settlement_prices = account
|
|
.positions()
|
|
.values()
|
|
.filter_map(|position| {
|
|
self.data
|
|
.futures_settlement_price(
|
|
date,
|
|
&position.symbol,
|
|
&self.futures_settlement_price_mode,
|
|
)
|
|
.map(|price| (position.symbol.clone(), price))
|
|
})
|
|
.collect::<BTreeMap<_, _>>();
|
|
if settlement_prices.is_empty() {
|
|
return report;
|
|
}
|
|
let cash_before = account.total_cash();
|
|
let cash_delta = account.settle(&settlement_prices);
|
|
report.account_events.push(AccountEvent {
|
|
date,
|
|
cash_before,
|
|
cash_after: account.total_cash(),
|
|
total_equity: account.total_value(),
|
|
note: format!(
|
|
"futures_daily_settlement mode={} cash_delta={cash_delta:.2} symbols={}",
|
|
self.futures_settlement_price_mode,
|
|
settlement_prices
|
|
.keys()
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
),
|
|
});
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::Settlement,
|
|
order_id: None,
|
|
symbol: None,
|
|
side: None,
|
|
detail: format!(
|
|
"futures_daily_settlement mode={} cash_delta={cash_delta:.2} count={}",
|
|
self.futures_settlement_price_mode,
|
|
settlement_prices.len()
|
|
),
|
|
});
|
|
report
|
|
}
|
|
|
|
fn apply_management_fee(
|
|
&mut self,
|
|
execution_date: NaiveDate,
|
|
decision_date: NaiveDate,
|
|
decision_index: usize,
|
|
portfolio: &mut PortfolioState,
|
|
open_orders: &[crate::strategy::OpenOrderView],
|
|
dynamic_universe: Option<&BTreeSet<String>>,
|
|
subscriptions: &BTreeSet<String>,
|
|
process_events: &mut Vec<ProcessEvent>,
|
|
order_events: &[OrderEvent],
|
|
fills: &[FillEvent],
|
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
|
let rate = portfolio.management_fee_rate();
|
|
if rate <= 0.0 {
|
|
return Ok(BrokerExecutionReport::default());
|
|
}
|
|
|
|
let fee = self
|
|
.strategy
|
|
.management_fee(
|
|
&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data: &self.data,
|
|
portfolio,
|
|
futures_account: self.futures_account.as_ref(),
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events: process_events.as_slice(),
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(
|
|
decision_date,
|
|
default_stage_time(ScheduleStage::Settlement),
|
|
),
|
|
order_events,
|
|
fills,
|
|
},
|
|
rate,
|
|
)?
|
|
.unwrap_or_else(|| portfolio.default_management_fee());
|
|
if fee <= 0.0 {
|
|
return Ok(BrokerExecutionReport::default());
|
|
}
|
|
|
|
let cash_before = portfolio.cash();
|
|
portfolio
|
|
.apply_management_fee(fee)
|
|
.map_err(BacktestError::Execution)?;
|
|
let mut report = BrokerExecutionReport::default();
|
|
report.account_events.push(AccountEvent {
|
|
date: execution_date,
|
|
cash_before,
|
|
cash_after: portfolio.cash(),
|
|
total_equity: portfolio.total_equity(),
|
|
note: format!("management_fee rate={rate:.6} fee={fee:.2}"),
|
|
});
|
|
publish_custom_process_event(
|
|
&mut self.strategy,
|
|
&mut self.process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
&self.data,
|
|
&*portfolio,
|
|
self.futures_account.as_ref(),
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events,
|
|
ProcessEvent {
|
|
date: execution_date,
|
|
kind: ProcessEventKind::AccountManagementFee,
|
|
order_id: None,
|
|
symbol: None,
|
|
side: None,
|
|
detail: format!(
|
|
"rate={rate:.6} fee={fee:.2} cash_before={cash_before:.2} cash_after={:.2} management_fees={:.2}",
|
|
portfolio.cash(),
|
|
portfolio.management_fees()
|
|
),
|
|
},
|
|
)?;
|
|
Ok(report)
|
|
}
|
|
|
|
fn audit_unresolved_delisted_positions(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &PortfolioState,
|
|
notes: &mut Vec<String>,
|
|
) -> BrokerExecutionReport {
|
|
let report = BrokerExecutionReport::default();
|
|
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
|
|
for symbol in symbols {
|
|
let Some(position) = portfolio.position(&symbol) else {
|
|
continue;
|
|
};
|
|
if position.quantity == 0 {
|
|
continue;
|
|
}
|
|
let Some(instrument) = self.data.instrument(&symbol) else {
|
|
continue;
|
|
};
|
|
let is_unresolved = instrument.is_delisted_on_or_before(date)
|
|
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
|
&& instrument.delisted_at.is_none()
|
|
&& self.data.market(date, &symbol).is_none());
|
|
if !is_unresolved {
|
|
continue;
|
|
}
|
|
let effective_delisted_at = instrument
|
|
.delisted_at
|
|
.or_else(|| self.data.calendar().previous_day(date))
|
|
.unwrap_or(date);
|
|
let reason = format!(
|
|
concat!(
|
|
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
|
|
"settlement_action=missing valuation_policy=zero no_order=true"
|
|
),
|
|
symbol, position.quantity, effective_delisted_at, instrument.status
|
|
);
|
|
if instrument.delisted_at == Some(date) || instrument.delisted_at.is_none() {
|
|
notes.push(reason.clone());
|
|
}
|
|
}
|
|
report
|
|
}
|
|
}
|
|
|
|
fn has_execution_quote_in_window(
|
|
data: &DataSet,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
start_time: Option<chrono::NaiveTime>,
|
|
end_time: Option<chrono::NaiveTime>,
|
|
) -> bool {
|
|
let start_cursor = start_time.map(|time| date.and_time(time));
|
|
let end_cursor = end_time.map(|time| date.and_time(time));
|
|
if let Some(cursor) = start_cursor
|
|
&& end_cursor.is_none()
|
|
{
|
|
return data
|
|
.execution_quotes_on(date, symbol)
|
|
.iter()
|
|
.any(|quote| quote.timestamp <= cursor);
|
|
}
|
|
data.execution_quotes_on(date, symbol).iter().any(|quote| {
|
|
!start_cursor.is_some_and(|cursor| quote.timestamp < cursor)
|
|
&& !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)
|
|
})
|
|
}
|
|
|
|
fn has_execution_quote_near_start_time(
|
|
data: &DataSet,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
start_time: NaiveTime,
|
|
) -> bool {
|
|
let cursor = date.and_time(start_time);
|
|
let Some(latest) = data
|
|
.execution_quotes_on(date, symbol)
|
|
.iter()
|
|
.filter(|quote| quote.timestamp <= cursor)
|
|
.max_by_key(|quote| quote.timestamp)
|
|
else {
|
|
return false;
|
|
};
|
|
cursor.signed_duration_since(latest.timestamp) <= Duration::seconds(90)
|
|
}
|
|
|
|
fn decision_has_algo_execution(decision: &StrategyDecision) -> bool {
|
|
decision.order_intents.iter().any(|intent| {
|
|
matches!(
|
|
intent.unwrapped(),
|
|
OrderIntent::AlgoValue { .. }
|
|
| OrderIntent::AlgoPercent { .. }
|
|
| OrderIntent::TimedTargetValue { .. }
|
|
| OrderIntent::TargetPortfolioSmart {
|
|
order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { .. }),
|
|
..
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
fn execution_quote_symbols_for_decision(
|
|
decision: &StrategyDecision,
|
|
portfolio: &PortfolioState,
|
|
open_orders: &[OpenOrderView],
|
|
) -> BTreeSet<String> {
|
|
let mut symbols = BTreeSet::new();
|
|
symbols.extend(open_orders.iter().map(|order| order.symbol.clone()));
|
|
if decision.rebalance {
|
|
symbols.extend(portfolio.positions().keys().cloned());
|
|
symbols.extend(decision.target_weights.keys().cloned());
|
|
}
|
|
if !decision.exit_symbols.is_empty() {
|
|
symbols.extend(decision.exit_symbols.iter().cloned());
|
|
}
|
|
|
|
for intent in &decision.order_intents {
|
|
match intent.unwrapped() {
|
|
OrderIntent::Shares { symbol, .. }
|
|
| OrderIntent::LimitShares { symbol, .. }
|
|
| OrderIntent::Lots { symbol, .. }
|
|
| OrderIntent::LimitLots { symbol, .. }
|
|
| OrderIntent::TargetShares { symbol, .. }
|
|
| OrderIntent::LimitTargetShares { symbol, .. }
|
|
| OrderIntent::TargetValue { symbol, .. }
|
|
| OrderIntent::TimedTargetValue { symbol, .. }
|
|
| OrderIntent::LimitTargetValue { symbol, .. }
|
|
| OrderIntent::Value { symbol, .. }
|
|
| OrderIntent::LimitValue { symbol, .. }
|
|
| OrderIntent::Percent { symbol, .. }
|
|
| OrderIntent::LimitPercent { symbol, .. }
|
|
| OrderIntent::TargetPercent { symbol, .. }
|
|
| OrderIntent::LimitTargetPercent { symbol, .. }
|
|
| OrderIntent::AlgoValue { symbol, .. }
|
|
| OrderIntent::AlgoPercent { symbol, .. }
|
|
| OrderIntent::CancelSymbol { symbol, .. } => {
|
|
symbols.insert(symbol.clone());
|
|
}
|
|
OrderIntent::TargetPortfolioSmart { target_weights, .. } => {
|
|
symbols.extend(portfolio.positions().keys().cloned());
|
|
symbols.extend(target_weights.keys().cloned());
|
|
}
|
|
OrderIntent::CancelAll { .. } => {
|
|
symbols.extend(open_orders.iter().map(|order| order.symbol.clone()));
|
|
}
|
|
OrderIntent::ModifyOrder { order_id, .. } => {
|
|
if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id) {
|
|
symbols.insert(order.symbol.clone());
|
|
}
|
|
}
|
|
OrderIntent::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
|
|
OrderIntent::UpdateUniverse { .. }
|
|
| OrderIntent::Subscribe { .. }
|
|
| OrderIntent::Unsubscribe { .. }
|
|
| OrderIntent::DepositWithdraw { .. }
|
|
| OrderIntent::FinanceRepay { .. }
|
|
| OrderIntent::SetManagementFeeRate { .. }
|
|
| OrderIntent::CancelOrder { .. }
|
|
| OrderIntent::Futures { .. } => {}
|
|
}
|
|
}
|
|
|
|
symbols.retain(|symbol| !symbol.trim().is_empty());
|
|
symbols
|
|
}
|
|
|
|
fn algo_execution_quote_windows_for_decision(
|
|
decision: &StrategyDecision,
|
|
portfolio: &PortfolioState,
|
|
) -> BTreeMap<(Option<NaiveTime>, Option<NaiveTime>), BTreeSet<String>> {
|
|
let mut groups = BTreeMap::<(Option<NaiveTime>, Option<NaiveTime>), BTreeSet<String>>::new();
|
|
for intent in &decision.order_intents {
|
|
match intent.unwrapped() {
|
|
OrderIntent::AlgoValue {
|
|
symbol,
|
|
start_time,
|
|
end_time,
|
|
..
|
|
}
|
|
| OrderIntent::AlgoPercent {
|
|
symbol,
|
|
start_time,
|
|
end_time,
|
|
..
|
|
}
|
|
| OrderIntent::TimedTargetValue {
|
|
symbol,
|
|
start_time,
|
|
end_time,
|
|
..
|
|
} => {
|
|
if start_time.is_some() || end_time.is_some() {
|
|
groups
|
|
.entry((*start_time, *end_time))
|
|
.or_default()
|
|
.insert(symbol.clone());
|
|
}
|
|
}
|
|
OrderIntent::TargetPortfolioSmart {
|
|
target_weights,
|
|
order_prices:
|
|
Some(TargetPortfolioOrderPricing::AlgoOrder {
|
|
start_time,
|
|
end_time,
|
|
..
|
|
}),
|
|
..
|
|
} => {
|
|
if start_time.is_some() || end_time.is_some() {
|
|
let symbols = groups.entry((*start_time, *end_time)).or_default();
|
|
symbols.extend(portfolio.positions().keys().cloned());
|
|
symbols.extend(target_weights.keys().cloned());
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
groups
|
|
}
|
|
|
|
fn collect_scheduled_decisions<S: Strategy>(
|
|
strategy: &mut S,
|
|
scheduler: &Scheduler<'_>,
|
|
execution_date: NaiveDate,
|
|
stage: ScheduleStage,
|
|
rules: &[ScheduleRule],
|
|
decision_date: NaiveDate,
|
|
decision_index: usize,
|
|
data: &crate::data::DataSet,
|
|
portfolio: &PortfolioState,
|
|
futures_account: Option<&FuturesAccountState>,
|
|
open_orders: &[crate::strategy::OpenOrderView],
|
|
dynamic_universe: Option<&BTreeSet<String>>,
|
|
subscriptions: &BTreeSet<String>,
|
|
process_events: &mut Vec<ProcessEvent>,
|
|
process_event_bus: &mut ProcessEventBus,
|
|
current_time: Option<chrono::NaiveTime>,
|
|
order_events: &[OrderEvent],
|
|
fills: &[FillEvent],
|
|
) -> Result<crate::strategy::StrategyDecision, BacktestError> {
|
|
let mut combined = crate::strategy::StrategyDecision::default();
|
|
// In lagged modes such as next_bar_open, scheduled callbacks generate
|
|
// signals on the decision date while the broker later matches them on the
|
|
// execution date. Triggering schedules with the execution date would let a
|
|
// T+1 calendar state suppress or create T-day signals.
|
|
for rule in scheduler.triggered_rules_at(decision_date, stage, current_time, rules) {
|
|
let distinct_timed_minute_count = rules
|
|
.iter()
|
|
.filter(|candidate| candidate.stage == stage && candidate.name == rule.name)
|
|
.filter_map(|candidate| candidate.time_rule.as_ref()?.minute_of_day())
|
|
.collect::<BTreeSet<_>>()
|
|
.len();
|
|
publish_phase_event(
|
|
strategy,
|
|
process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data,
|
|
portfolio,
|
|
futures_account,
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events,
|
|
execution_date,
|
|
ProcessEventKind::PreScheduled,
|
|
scheduled_event_detail(
|
|
rule,
|
|
stage,
|
|
current_time,
|
|
distinct_timed_minute_count,
|
|
"pre",
|
|
),
|
|
)?;
|
|
combined.merge_from(strategy.on_scheduled(
|
|
&StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data,
|
|
portfolio,
|
|
futures_account,
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events: process_events.as_slice(),
|
|
active_process_event: None,
|
|
active_datetime: stage_datetime(decision_date, current_time),
|
|
order_events,
|
|
fills,
|
|
},
|
|
rule,
|
|
)?);
|
|
publish_phase_event(
|
|
strategy,
|
|
process_event_bus,
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data,
|
|
portfolio,
|
|
futures_account,
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events,
|
|
execution_date,
|
|
ProcessEventKind::PostScheduled,
|
|
scheduled_event_detail(
|
|
rule,
|
|
stage,
|
|
current_time,
|
|
distinct_timed_minute_count,
|
|
"post",
|
|
),
|
|
)?;
|
|
}
|
|
Ok(combined)
|
|
}
|
|
|
|
fn scheduled_event_detail(
|
|
rule: &ScheduleRule,
|
|
stage: ScheduleStage,
|
|
current_time: Option<chrono::NaiveTime>,
|
|
distinct_timed_minute_count: usize,
|
|
phase: &str,
|
|
) -> String {
|
|
if distinct_timed_minute_count > 1
|
|
&& rule.time_rule.is_some()
|
|
&& let Some(time) = current_time
|
|
{
|
|
return format!(
|
|
"scheduled:{}:{}:{}:{phase}",
|
|
rule.name,
|
|
stage_label(stage),
|
|
time.format("%H:%M")
|
|
);
|
|
}
|
|
format!("scheduled:{}:{}:{phase}", rule.name, stage_label(stage))
|
|
}
|
|
|
|
fn collect_scheduled_decisions_for_stage<S: Strategy>(
|
|
strategy: &mut S,
|
|
scheduler: &Scheduler<'_>,
|
|
execution_date: NaiveDate,
|
|
stage: ScheduleStage,
|
|
rules: &[ScheduleRule],
|
|
decision_date: NaiveDate,
|
|
decision_index: usize,
|
|
data: &crate::data::DataSet,
|
|
portfolio: &PortfolioState,
|
|
futures_account: Option<&FuturesAccountState>,
|
|
open_orders: &[crate::strategy::OpenOrderView],
|
|
dynamic_universe: Option<&BTreeSet<String>>,
|
|
subscriptions: &BTreeSet<String>,
|
|
process_events: &mut Vec<ProcessEvent>,
|
|
process_event_bus: &mut ProcessEventBus,
|
|
order_events: &[OrderEvent],
|
|
fills: &[FillEvent],
|
|
) -> Result<crate::strategy::StrategyDecision, BacktestError> {
|
|
let mut times = BTreeSet::new();
|
|
for rule in rules.iter().filter(|rule| rule.stage == stage) {
|
|
let time = match rule.time_rule.as_ref() {
|
|
Some(crate::scheduler::ScheduleTimeRule::MinuteOfDay(value)) => {
|
|
let hour = value / 60;
|
|
let minute = value % 60;
|
|
Some(NaiveTime::from_hms_opt(hour, minute, 0).ok_or_else(|| {
|
|
BacktestError::Execution(format!(
|
|
"invalid schedule minute-of-day {} for rule {}",
|
|
value, rule.name
|
|
))
|
|
})?)
|
|
}
|
|
Some(crate::scheduler::ScheduleTimeRule::BeforeTrading) | None => {
|
|
default_stage_time(stage)
|
|
}
|
|
};
|
|
times.insert(time);
|
|
}
|
|
let mut combined = crate::strategy::StrategyDecision::default();
|
|
for time in times {
|
|
combined.merge_from(collect_scheduled_decisions(
|
|
strategy,
|
|
scheduler,
|
|
execution_date,
|
|
stage,
|
|
rules,
|
|
decision_date,
|
|
decision_index,
|
|
data,
|
|
portfolio,
|
|
futures_account,
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events,
|
|
process_event_bus,
|
|
time,
|
|
order_events,
|
|
fills,
|
|
)?);
|
|
}
|
|
Ok(combined)
|
|
}
|
|
|
|
fn publish_phase_event<S: Strategy>(
|
|
strategy: &mut S,
|
|
process_event_bus: &mut ProcessEventBus,
|
|
execution_date: NaiveDate,
|
|
decision_date: NaiveDate,
|
|
decision_index: usize,
|
|
data: &crate::data::DataSet,
|
|
portfolio: &PortfolioState,
|
|
futures_account: Option<&FuturesAccountState>,
|
|
open_orders: &[crate::strategy::OpenOrderView],
|
|
dynamic_universe: Option<&BTreeSet<String>>,
|
|
subscriptions: &BTreeSet<String>,
|
|
events: &mut Vec<ProcessEvent>,
|
|
date: NaiveDate,
|
|
kind: ProcessEventKind,
|
|
detail: impl Into<String>,
|
|
) -> Result<(), BacktestError> {
|
|
let event = ProcessEvent {
|
|
date,
|
|
kind,
|
|
order_id: None,
|
|
symbol: None,
|
|
side: None,
|
|
detail: detail.into(),
|
|
};
|
|
process_event_bus.publish(&event);
|
|
let process_events = events.as_slice();
|
|
let event_ctx = StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data,
|
|
portfolio,
|
|
futures_account,
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events,
|
|
active_process_event: Some(&event),
|
|
active_datetime: None,
|
|
order_events: &[],
|
|
fills: &[],
|
|
};
|
|
strategy.on_process_event(&event_ctx, &event)?;
|
|
events.push(event);
|
|
Ok(())
|
|
}
|
|
|
|
fn publish_process_events<S: Strategy>(
|
|
strategy: &mut S,
|
|
process_event_bus: &mut ProcessEventBus,
|
|
execution_date: NaiveDate,
|
|
decision_date: NaiveDate,
|
|
decision_index: usize,
|
|
data: &crate::data::DataSet,
|
|
portfolio: &PortfolioState,
|
|
futures_account: Option<&FuturesAccountState>,
|
|
open_orders: &[crate::strategy::OpenOrderView],
|
|
dynamic_universe: Option<&BTreeSet<String>>,
|
|
subscriptions: &BTreeSet<String>,
|
|
target: &mut Vec<ProcessEvent>,
|
|
incoming: &mut Vec<ProcessEvent>,
|
|
) -> Result<(), BacktestError> {
|
|
for event in incoming.drain(..) {
|
|
process_event_bus.publish(&event);
|
|
let process_events = target.as_slice();
|
|
let event_ctx = StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data,
|
|
portfolio,
|
|
futures_account,
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events,
|
|
active_process_event: Some(&event),
|
|
active_datetime: None,
|
|
order_events: &[],
|
|
fills: &[],
|
|
};
|
|
strategy.on_process_event(&event_ctx, &event)?;
|
|
target.push(event);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn publish_custom_process_event<S: Strategy>(
|
|
strategy: &mut S,
|
|
process_event_bus: &mut ProcessEventBus,
|
|
execution_date: NaiveDate,
|
|
decision_date: NaiveDate,
|
|
decision_index: usize,
|
|
data: &crate::data::DataSet,
|
|
portfolio: &PortfolioState,
|
|
futures_account: Option<&FuturesAccountState>,
|
|
open_orders: &[crate::strategy::OpenOrderView],
|
|
dynamic_universe: Option<&BTreeSet<String>>,
|
|
subscriptions: &BTreeSet<String>,
|
|
target: &mut Vec<ProcessEvent>,
|
|
event: ProcessEvent,
|
|
) -> Result<(), BacktestError> {
|
|
process_event_bus.publish(&event);
|
|
let process_events = target.as_slice();
|
|
let event_ctx = StrategyContext {
|
|
execution_date,
|
|
decision_date,
|
|
decision_index,
|
|
data,
|
|
portfolio,
|
|
futures_account,
|
|
open_orders,
|
|
dynamic_universe,
|
|
subscriptions,
|
|
process_events,
|
|
active_process_event: Some(&event),
|
|
active_datetime: None,
|
|
order_events: &[],
|
|
fills: &[],
|
|
};
|
|
strategy.on_process_event(&event_ctx, &event)?;
|
|
target.push(event);
|
|
Ok(())
|
|
}
|
|
|
|
fn stage_label(stage: ScheduleStage) -> &'static str {
|
|
match stage {
|
|
ScheduleStage::BeforeTrading => "before_trading",
|
|
ScheduleStage::OpenAuction => "open_auction",
|
|
ScheduleStage::Bar => "bar",
|
|
ScheduleStage::Minute => "minute",
|
|
ScheduleStage::OnDay => "on_day",
|
|
ScheduleStage::AfterTrading => "after_trading",
|
|
ScheduleStage::Settlement => "settlement",
|
|
}
|
|
}
|
|
|
|
fn stage_datetime(
|
|
date: NaiveDate,
|
|
time: Option<chrono::NaiveTime>,
|
|
) -> Option<chrono::NaiveDateTime> {
|
|
time.map(|value| date.and_time(value))
|
|
}
|
|
|
|
fn next_minute_event_timestamp(
|
|
quote_timestamp: Option<chrono::NaiveDateTime>,
|
|
schedule_timestamp: Option<chrono::NaiveDateTime>,
|
|
) -> Option<chrono::NaiveDateTime> {
|
|
match (quote_timestamp, schedule_timestamp) {
|
|
(Some(quote), Some(schedule)) => Some(quote.min(schedule)),
|
|
(Some(quote), None) => Some(quote),
|
|
(None, Some(schedule)) => Some(schedule),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
fn is_on_day_or_bar_physical_time_rule(rule: &ScheduleRule) -> bool {
|
|
matches!(rule.stage, ScheduleStage::OnDay | ScheduleStage::Bar)
|
|
&& matches!(
|
|
rule.time_rule,
|
|
Some(crate::scheduler::ScheduleTimeRule::MinuteOfDay(_))
|
|
)
|
|
}
|
|
|
|
fn should_run_minute_events(rules: &[ScheduleRule], subscriptions: &BTreeSet<String>) -> bool {
|
|
!subscriptions.is_empty()
|
|
|| rules.iter().any(|rule| {
|
|
rule.stage == ScheduleStage::Minute || is_on_day_or_bar_physical_time_rule(rule)
|
|
})
|
|
}
|
|
|
|
fn merge_broker_report(target: &mut BrokerExecutionReport, incoming: BrokerExecutionReport) {
|
|
target.order_events.extend(incoming.order_events);
|
|
target.fill_events.extend(incoming.fill_events);
|
|
target.position_events.extend(incoming.position_events);
|
|
target.account_events.extend(incoming.account_events);
|
|
target.process_events.extend(incoming.process_events);
|
|
target.diagnostics.extend(incoming.diagnostics);
|
|
}
|
|
|
|
fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExecutionReport) {
|
|
target.order_events.extend(incoming.order_events);
|
|
target.fill_events.extend(incoming.fill_events);
|
|
target.position_events.extend(incoming.position_events);
|
|
target.account_events.extend(incoming.account_events);
|
|
target.process_events.extend(incoming.process_events);
|
|
target.diagnostics.extend(incoming.diagnostics);
|
|
}
|
|
|
|
fn annotate_broker_report_dates(
|
|
report: &mut BrokerExecutionReport,
|
|
decision_date: NaiveDate,
|
|
order_created_date: NaiveDate,
|
|
execution_date: NaiveDate,
|
|
) {
|
|
for event in &mut report.order_events {
|
|
event.decision_date.get_or_insert(decision_date);
|
|
event.order_created_date.get_or_insert(order_created_date);
|
|
event.execution_date.get_or_insert(execution_date);
|
|
}
|
|
for fill in &mut report.fill_events {
|
|
fill.decision_date.get_or_insert(decision_date);
|
|
fill.order_created_date.get_or_insert(order_created_date);
|
|
fill.execution_date.get_or_insert(execution_date);
|
|
}
|
|
}
|
|
|
|
fn risk_decisions_from_order_events(order_events: &[OrderEvent]) -> Vec<FidcRiskDecisionAudit> {
|
|
order_events
|
|
.iter()
|
|
.filter_map(risk_decision_from_order_event)
|
|
.collect()
|
|
}
|
|
|
|
fn risk_decision_from_order_event(order_event: &OrderEvent) -> Option<FidcRiskDecisionAudit> {
|
|
if !matches!(
|
|
order_event.status,
|
|
OrderStatus::Canceled | OrderStatus::Rejected
|
|
) {
|
|
return None;
|
|
}
|
|
let rule_code = execution_risk_rule_code(&order_event.reason)?;
|
|
Some(FidcRiskDecisionAudit {
|
|
date: order_event.date,
|
|
symbol: order_event.symbol.clone(),
|
|
scope: match order_event.side {
|
|
OrderSide::Buy => RiskCheckScope::Buy,
|
|
OrderSide::Sell => RiskCheckScope::Sell,
|
|
},
|
|
stage: "execution".to_string(),
|
|
accepted: false,
|
|
rule_code: rule_code.to_string(),
|
|
reason: order_event.reason.clone(),
|
|
config_version: Some("inline_risk_policy".to_string()),
|
|
data_epoch: order_event.date.to_string(),
|
|
selection_batch_id: None,
|
|
order_id: order_event.order_id.map(|id| id.to_string()),
|
|
})
|
|
}
|
|
|
|
fn execution_risk_rule_code(reason: &str) -> Option<&'static str> {
|
|
let normalized = normalize_risk_reason(reason);
|
|
let tokens = normalized
|
|
.split('_')
|
|
.filter(|token| !token.is_empty())
|
|
.collect::<BTreeSet<_>>();
|
|
if normalized.contains("same_day_rebuy") || normalized.contains("sell_then_rebuy") {
|
|
return Some("same_day_rebuy");
|
|
}
|
|
if normalized.contains("blacklist") {
|
|
return Some("blacklisted");
|
|
}
|
|
if normalized.contains("star_st") || normalized.contains("st_star") {
|
|
return Some("star_st");
|
|
}
|
|
if tokens.contains("st") {
|
|
return Some("st");
|
|
}
|
|
if normalized.contains("upper_limit")
|
|
|| normalized.contains("limit_up")
|
|
|| normalized.contains("above_upper")
|
|
{
|
|
return Some("upper_limit");
|
|
}
|
|
if normalized.contains("lower_limit")
|
|
|| normalized.contains("limit_down")
|
|
|| normalized.contains("below_lower")
|
|
{
|
|
return Some("lower_limit");
|
|
}
|
|
if normalized.contains("paused")
|
|
|| normalized.contains("suspended")
|
|
|| normalized.contains("suspension")
|
|
{
|
|
return Some("paused");
|
|
}
|
|
if normalized.contains("inactive")
|
|
|| normalized.contains("delisted")
|
|
|| normalized.contains("not_listed")
|
|
{
|
|
return Some("inactive_or_delisted");
|
|
}
|
|
if tokens.contains("kcb") || normalized.contains("science_technology_board") {
|
|
return Some("kcb");
|
|
}
|
|
if tokens.contains("bjse") || normalized.contains("beijing_stock_exchange") {
|
|
return Some("bjse");
|
|
}
|
|
if normalized.contains("one_yuan") {
|
|
return Some("one_yuan");
|
|
}
|
|
if normalized.contains("buy_disabled") || normalized.contains("allow_buy") {
|
|
return Some("buy_disabled");
|
|
}
|
|
if normalized.contains("sell_disabled") || normalized.contains("allow_sell") {
|
|
return Some("sell_disabled");
|
|
}
|
|
if normalized.contains("trade_disabled") {
|
|
return Some("trade_disabled");
|
|
}
|
|
if normalized.contains("no_volume") || normalized.contains("volume_limit") {
|
|
return Some("volume_limit");
|
|
}
|
|
if normalized.contains("liquidity") || normalized.contains("level1") {
|
|
return Some("liquidity_limit");
|
|
}
|
|
if normalized.contains("no_market_data")
|
|
|| normalized.contains("market_data_unavailable")
|
|
|| normalized.contains("no_executable_price")
|
|
|| normalized.contains("missing_price")
|
|
{
|
|
return Some("market_data_unavailable");
|
|
}
|
|
if normalized.contains("sellable_quantity") || normalized.contains("no_sellable_quantity") {
|
|
return Some("sellable_quantity");
|
|
}
|
|
None
|
|
}
|
|
|
|
fn normalize_risk_reason(reason: &str) -> String {
|
|
reason
|
|
.to_ascii_lowercase()
|
|
.chars()
|
|
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
|
|
.collect()
|
|
}
|
|
|
|
fn merge_futures_execution_report(
|
|
target: &mut FuturesExecutionReport,
|
|
incoming: FuturesExecutionReport,
|
|
) {
|
|
target.order_events.extend(incoming.order_events);
|
|
target.fill_events.extend(incoming.fill_events);
|
|
target.position_events.extend(incoming.position_events);
|
|
target.account_events.extend(incoming.account_events);
|
|
target.process_events.extend(incoming.process_events);
|
|
target.diagnostics.extend(incoming.diagnostics);
|
|
}
|
|
|
|
fn analyzer_ratio_change(start: f64, end: f64) -> f64 {
|
|
if start.abs() <= f64::EPSILON {
|
|
0.0
|
|
} else {
|
|
end / start - 1.0
|
|
}
|
|
}
|
|
|
|
fn price_is_tick_aligned(price: f64, tick: f64) -> bool {
|
|
if !price.is_finite() || !tick.is_finite() || tick <= 0.0 {
|
|
return false;
|
|
}
|
|
let ratio = price / tick;
|
|
(ratio - ratio.round()).abs() <= 1e-6
|
|
}
|
|
|
|
fn futures_trading_phase_allows_orders(phase: Option<&str>) -> bool {
|
|
let Some(phase) = phase.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return true;
|
|
};
|
|
matches!(
|
|
phase.to_ascii_lowercase().as_str(),
|
|
"continuous"
|
|
| "trading"
|
|
| "trade"
|
|
| "open_auction"
|
|
| "auction"
|
|
| "call_auction"
|
|
| "opening_auction"
|
|
)
|
|
}
|
|
|
|
fn futures_limit_satisfied(side: OrderSide, price: f64, limit_price: Option<f64>) -> bool {
|
|
let Some(limit_price) = limit_price else {
|
|
return price.is_finite() && price > 0.0;
|
|
};
|
|
if !price.is_finite() || price <= 0.0 || !limit_price.is_finite() || limit_price <= 0.0 {
|
|
return false;
|
|
}
|
|
match side {
|
|
OrderSide::Buy => price <= limit_price + 1e-9,
|
|
OrderSide::Sell => price + 1e-9 >= limit_price,
|
|
}
|
|
}
|
|
|
|
fn join_text_parts<I>(parts: I) -> String
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
let mut iterator = parts.into_iter();
|
|
let Some(first) = iterator.next() else {
|
|
return String::new();
|
|
};
|
|
let mut result = first;
|
|
for part in iterator {
|
|
result.push_str(" | ");
|
|
result.push_str(&part);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn futures_cancel_report(
|
|
date: NaiveDate,
|
|
order: FuturesOpenOrder,
|
|
reason: &str,
|
|
) -> FuturesExecutionReport {
|
|
let mut report = FuturesExecutionReport::default();
|
|
let side = order.intent.side();
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::OrderPendingCancel,
|
|
order_id: Some(order.order_id),
|
|
symbol: Some(order.intent.symbol.clone()),
|
|
side: Some(side),
|
|
detail: format!("reason={reason}"),
|
|
});
|
|
report.order_events.push(OrderEvent {
|
|
date,
|
|
decision_date: None,
|
|
order_created_date: None,
|
|
execution_date: None,
|
|
order_id: Some(order.order_id),
|
|
symbol: order.intent.symbol.clone(),
|
|
side,
|
|
requested_quantity: order.requested_quantity,
|
|
filled_quantity: order.filled_quantity,
|
|
status: OrderStatus::Canceled,
|
|
reason: format!("{reason}: futures order canceled by user"),
|
|
});
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::OrderCancellationPass,
|
|
order_id: Some(order.order_id),
|
|
symbol: Some(order.intent.symbol),
|
|
side: Some(side),
|
|
detail: format!(
|
|
"requested_quantity={} filled_quantity={} remaining_quantity={}",
|
|
order.requested_quantity, order.filled_quantity, order.remaining_quantity
|
|
),
|
|
});
|
|
report
|
|
}
|
|
|
|
mod date_format {
|
|
use chrono::NaiveDate;
|
|
use serde::{Deserialize, Deserializer, Serializer};
|
|
|
|
const FORMAT: &str = "%Y-%m-%d";
|
|
|
|
pub fn serialize<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
serializer.serialize_str(&date.format(FORMAT).to_string())
|
|
}
|
|
|
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let value = String::deserialize(deserializer)?;
|
|
NaiveDate::parse_from_str(&value, FORMAT).map_err(serde::de::Error::custom)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::cell::RefCell;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::rc::Rc;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
|
|
|
use super::{BacktestConfig, BacktestEngine};
|
|
use crate::broker::{BrokerSimulator, MatchingType, SlippageModel};
|
|
use crate::cost::ChinaAShareCostModel;
|
|
use crate::data::{
|
|
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
|
IntradayExecutionQuote, PriceField,
|
|
};
|
|
use crate::events::{OrderSide, OrderStatus};
|
|
use crate::instrument::Instrument;
|
|
use crate::portfolio::PortfolioState;
|
|
use crate::risk_control::{FidcRiskControlConfig, RiskCheckScope};
|
|
use crate::rules::ChinaEquityRuleHooks;
|
|
use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
|
|
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision};
|
|
|
|
const SYMBOL: &str = "000001.SZ";
|
|
|
|
#[test]
|
|
fn join_text_parts_matches_vec_join_contract() {
|
|
assert_eq!(super::join_text_parts(Vec::<String>::new()), "");
|
|
assert_eq!(
|
|
super::join_text_parts(vec!["a".to_string(), "".to_string(), "c".to_string()]),
|
|
"a | | c"
|
|
);
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct BuyWhenDecisionDateStrategy {
|
|
decision_date: NaiveDate,
|
|
}
|
|
|
|
impl Strategy for BuyWhenDecisionDateStrategy {
|
|
fn name(&self) -> &str {
|
|
"buy_when_decision_date"
|
|
}
|
|
|
|
fn on_day(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
if ctx.decision_date == self.decision_date && ctx.portfolio.position(SYMBOL).is_none() {
|
|
return Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 100,
|
|
reason: "test_buy".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
});
|
|
}
|
|
Ok(StrategyDecision::default())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ScheduledBuyStrategy {
|
|
rule: ScheduleRule,
|
|
expected_decision_date: NaiveDate,
|
|
}
|
|
|
|
impl Strategy for ScheduledBuyStrategy {
|
|
fn name(&self) -> &str {
|
|
"scheduled_buy"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
vec![self.rule.clone()]
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
assert_eq!(rule.name, self.rule.name);
|
|
assert_eq!(ctx.decision_date, self.expected_decision_date);
|
|
assert_eq!(
|
|
ctx.current_datetime().map(|value| value.date()),
|
|
Some(self.expected_decision_date)
|
|
);
|
|
if ctx.portfolio.position(SYMBOL).is_none() {
|
|
return Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 100,
|
|
reason: "scheduled_test_buy".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
});
|
|
}
|
|
Ok(StrategyDecision::default())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct MinuteScheduleProbeStrategy {
|
|
rules: Vec<ScheduleRule>,
|
|
observed: Rc<RefCell<Vec<NaiveDateTime>>>,
|
|
}
|
|
|
|
impl Strategy for MinuteScheduleProbeStrategy {
|
|
fn name(&self) -> &str {
|
|
"minute_schedule_probe"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
self.rules.clone()
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
_rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
self.observed
|
|
.borrow_mut()
|
|
.push(ctx.current_datetime().expect("scheduled event time"));
|
|
Ok(StrategyDecision::default())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TimedOnDayBuyStrategy {
|
|
rules: Vec<ScheduleRule>,
|
|
}
|
|
|
|
impl Strategy for TimedOnDayBuyStrategy {
|
|
fn name(&self) -> &str {
|
|
"timed_on_day_buy"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
self.rules.clone()
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
_ctx: &StrategyContext<'_>,
|
|
rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 100,
|
|
reason: rule.name.clone(),
|
|
}],
|
|
notes: vec![format!("note:{}", rule.name)],
|
|
diagnostics: vec![format!("diagnostic:{}", rule.name)],
|
|
..StrategyDecision::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ScheduledBuyOnDecisionDateStrategy {
|
|
rule: ScheduleRule,
|
|
decision_date: NaiveDate,
|
|
}
|
|
|
|
impl Strategy for ScheduledBuyOnDecisionDateStrategy {
|
|
fn name(&self) -> &str {
|
|
"scheduled_buy_on_decision_date"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
vec![self.rule.clone()]
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
assert_eq!(rule.name, self.rule.name);
|
|
if ctx.decision_date != self.decision_date {
|
|
return Ok(StrategyDecision::default());
|
|
}
|
|
Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 100,
|
|
reason: "scheduled_decision_date_buy".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ScheduledTargetPortfolioSmartStrategy {
|
|
rule: ScheduleRule,
|
|
decision_date: NaiveDate,
|
|
target_weights: BTreeMap<String, f64>,
|
|
}
|
|
|
|
impl Strategy for ScheduledTargetPortfolioSmartStrategy {
|
|
fn name(&self) -> &str {
|
|
"scheduled_target_portfolio_smart"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
vec![self.rule.clone()]
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
assert_eq!(rule.name, self.rule.name);
|
|
if ctx.decision_date != self.decision_date {
|
|
return Ok(StrategyDecision::default());
|
|
}
|
|
Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::TargetPortfolioSmart {
|
|
target_weights: self.target_weights.clone(),
|
|
order_prices: None,
|
|
valuation_prices: None,
|
|
reason: "scheduled_target_portfolio_smart".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ScheduledTargetPercentStrategy {
|
|
first_decision_date: NaiveDate,
|
|
second_decision_date: NaiveDate,
|
|
}
|
|
|
|
impl Strategy for ScheduledTargetPercentStrategy {
|
|
fn name(&self) -> &str {
|
|
"scheduled_target_percent"
|
|
}
|
|
|
|
fn on_day(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
let order_intents = if ctx.decision_date == self.first_decision_date {
|
|
vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 1_000,
|
|
reason: "initial_position".to_string(),
|
|
}]
|
|
} else if ctx.decision_date == self.second_decision_date {
|
|
vec![OrderIntent::TargetPercent {
|
|
symbol: SYMBOL.to_string(),
|
|
target_percent: 0.5,
|
|
reason: "frozen_target_percent".to_string(),
|
|
}]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok(StrategyDecision {
|
|
order_intents,
|
|
..StrategyDecision::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ScheduledEligibleUniverseBuyStrategy {
|
|
rule: ScheduleRule,
|
|
expected_decision_date: NaiveDate,
|
|
}
|
|
|
|
impl Strategy for ScheduledEligibleUniverseBuyStrategy {
|
|
fn name(&self) -> &str {
|
|
"scheduled_eligible_universe_buy"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
vec![self.rule.clone()]
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
assert_eq!(rule.name, self.rule.name);
|
|
assert_eq!(ctx.decision_date, self.expected_decision_date);
|
|
let Some(symbol) = ctx
|
|
.eligible_universe_on(ctx.decision_date)
|
|
.first()
|
|
.map(|row| row.symbol.clone())
|
|
else {
|
|
return Ok(StrategyDecision::default());
|
|
};
|
|
Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol,
|
|
quantity: 100,
|
|
reason: "eligible_universe_next_open_buy".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ScheduledDataDateProbeStrategy {
|
|
rule: ScheduleRule,
|
|
expected_decision_date: NaiveDate,
|
|
observed: Rc<RefCell<Vec<String>>>,
|
|
}
|
|
|
|
impl Strategy for ScheduledDataDateProbeStrategy {
|
|
fn name(&self) -> &str {
|
|
"scheduled_data_date_probe"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
vec![self.rule.clone()]
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
assert_eq!(rule.name, self.rule.name);
|
|
if ctx.decision_date != self.expected_decision_date {
|
|
return Ok(StrategyDecision::default());
|
|
}
|
|
let current_close = ctx
|
|
.current_snapshot(SYMBOL)
|
|
.map(|snapshot| format!("{:.2}", snapshot.close))
|
|
.unwrap_or_default();
|
|
let history_close = ctx
|
|
.history_bars(SYMBOL, 2, "1d", "close", true)
|
|
.iter()
|
|
.map(|value| format!("{value:.2}"))
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let suspended = ctx
|
|
.is_suspended(SYMBOL, 1)
|
|
.into_iter()
|
|
.map(|value| if value { "1" } else { "0" })
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
self.observed.borrow_mut().push(format!(
|
|
"signal={};execution={};active={};current={current_close};history={history_close};suspended={suspended}",
|
|
ctx.signal_date(),
|
|
ctx.execution_trade_date(),
|
|
ctx.current_datetime()
|
|
.map(|datetime| datetime.date().to_string())
|
|
.unwrap_or_default()
|
|
));
|
|
Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 100,
|
|
reason: "data_date_probe_buy".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ScheduledRoundTripStrategy {
|
|
rule: ScheduleRule,
|
|
buy_decision_date: NaiveDate,
|
|
sell_decision_date: NaiveDate,
|
|
}
|
|
|
|
impl Strategy for ScheduledRoundTripStrategy {
|
|
fn name(&self) -> &str {
|
|
"scheduled_round_trip"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
vec![self.rule.clone()]
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
assert_eq!(rule.name, self.rule.name);
|
|
assert_eq!(
|
|
ctx.current_datetime().map(|value| value.date()),
|
|
Some(ctx.decision_date)
|
|
);
|
|
if ctx.decision_date == self.buy_decision_date
|
|
&& ctx.portfolio.position(SYMBOL).is_none()
|
|
{
|
|
return Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 100,
|
|
reason: "round_trip_buy".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
});
|
|
}
|
|
if ctx.decision_date == self.sell_decision_date {
|
|
if let Some(position) = ctx.portfolio.position(SYMBOL) {
|
|
return Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: -(position.quantity as i32),
|
|
reason: "round_trip_sell".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
});
|
|
}
|
|
}
|
|
Ok(StrategyDecision::default())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ScheduledSameDayRebuyStrategy {
|
|
rule: ScheduleRule,
|
|
buy_decision_date: NaiveDate,
|
|
rebuy_decision_date: NaiveDate,
|
|
}
|
|
|
|
impl Strategy for ScheduledSameDayRebuyStrategy {
|
|
fn name(&self) -> &str {
|
|
"scheduled_same_day_rebuy"
|
|
}
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
vec![self.rule.clone()]
|
|
}
|
|
|
|
fn on_scheduled(
|
|
&mut self,
|
|
ctx: &StrategyContext<'_>,
|
|
rule: &ScheduleRule,
|
|
) -> Result<StrategyDecision, super::BacktestError> {
|
|
assert_eq!(rule.name, self.rule.name);
|
|
if ctx.decision_date == self.buy_decision_date
|
|
&& ctx.portfolio.position(SYMBOL).is_none()
|
|
{
|
|
return Ok(StrategyDecision {
|
|
order_intents: vec![OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 100,
|
|
reason: "same_day_rebuy_setup_buy".to_string(),
|
|
}],
|
|
..StrategyDecision::default()
|
|
});
|
|
}
|
|
if ctx.decision_date == self.rebuy_decision_date {
|
|
if let Some(position) = ctx.portfolio.position(SYMBOL) {
|
|
return Ok(StrategyDecision {
|
|
order_intents: vec![
|
|
OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: -(position.quantity as i32),
|
|
reason: "same_day_rebuy_sell".to_string(),
|
|
},
|
|
OrderIntent::Shares {
|
|
symbol: SYMBOL.to_string(),
|
|
quantity: 100,
|
|
reason: "same_day_rebuy_buy".to_string(),
|
|
},
|
|
],
|
|
..StrategyDecision::default()
|
|
});
|
|
}
|
|
}
|
|
Ok(StrategyDecision::default())
|
|
}
|
|
}
|
|
|
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
|
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
|
}
|
|
|
|
fn market(date: NaiveDate, open: f64, close: f64) -> DailyMarketSnapshot {
|
|
DailyMarketSnapshot {
|
|
date,
|
|
symbol: SYMBOL.to_string(),
|
|
timestamp: Some(format!("{date} 15:00:00")),
|
|
day_open: open,
|
|
open,
|
|
high: close.max(open) + 1.0,
|
|
low: close.min(open) - 1.0,
|
|
close,
|
|
last_price: close,
|
|
bid1: close - 0.01,
|
|
ask1: close + 0.01,
|
|
prev_close: 10.0,
|
|
volume: 1_000_000,
|
|
minute_volume: 10_000,
|
|
bid1_volume: 10_000,
|
|
ask1_volume: 10_000,
|
|
trading_phase: Some("continuous".to_string()),
|
|
paused: false,
|
|
upper_limit: 200.0,
|
|
lower_limit: 1.0,
|
|
price_tick: 0.01,
|
|
}
|
|
}
|
|
|
|
fn market_with_state(
|
|
date: NaiveDate,
|
|
open: f64,
|
|
close: f64,
|
|
paused: bool,
|
|
upper_limit: f64,
|
|
lower_limit: f64,
|
|
) -> DailyMarketSnapshot {
|
|
DailyMarketSnapshot {
|
|
paused,
|
|
upper_limit,
|
|
lower_limit,
|
|
..market(date, open, close)
|
|
}
|
|
}
|
|
|
|
fn market_with_volume(
|
|
date: NaiveDate,
|
|
open: f64,
|
|
close: f64,
|
|
volume: u64,
|
|
) -> DailyMarketSnapshot {
|
|
DailyMarketSnapshot {
|
|
volume,
|
|
..market(date, open, close)
|
|
}
|
|
}
|
|
|
|
fn factor(date: NaiveDate) -> DailyFactorSnapshot {
|
|
DailyFactorSnapshot {
|
|
date,
|
|
symbol: SYMBOL.to_string(),
|
|
market_cap_bn: 10.0,
|
|
free_float_cap_bn: 8.0,
|
|
pe_ttm: 12.0,
|
|
turnover_ratio: Some(1.0),
|
|
effective_turnover_ratio: Some(1.0),
|
|
adjustment_factor_backward1: None,
|
|
extra_factors: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
fn candidate(date: NaiveDate) -> CandidateEligibility {
|
|
CandidateEligibility {
|
|
date,
|
|
symbol: SYMBOL.to_string(),
|
|
is_st: false,
|
|
is_star_st: false,
|
|
is_new_listing: false,
|
|
is_paused: false,
|
|
allow_buy: true,
|
|
allow_sell: true,
|
|
is_kcb: false,
|
|
is_one_yuan: false,
|
|
risk_level_code: None,
|
|
}
|
|
}
|
|
|
|
fn candidate_with_state(
|
|
date: NaiveDate,
|
|
is_paused: bool,
|
|
allow_buy: bool,
|
|
) -> CandidateEligibility {
|
|
CandidateEligibility {
|
|
is_paused,
|
|
allow_buy,
|
|
..candidate(date)
|
|
}
|
|
}
|
|
|
|
fn candidate_with_sell_state(
|
|
date: NaiveDate,
|
|
is_paused: bool,
|
|
allow_sell: bool,
|
|
) -> CandidateEligibility {
|
|
CandidateEligibility {
|
|
is_paused,
|
|
allow_sell,
|
|
..candidate(date)
|
|
}
|
|
}
|
|
|
|
fn st_candidate(date: NaiveDate) -> CandidateEligibility {
|
|
CandidateEligibility {
|
|
is_st: true,
|
|
..candidate(date)
|
|
}
|
|
}
|
|
|
|
fn star_st_candidate(date: NaiveDate) -> CandidateEligibility {
|
|
CandidateEligibility {
|
|
is_star_st: true,
|
|
..candidate(date)
|
|
}
|
|
}
|
|
|
|
fn one_yuan_candidate(date: NaiveDate) -> CandidateEligibility {
|
|
CandidateEligibility {
|
|
is_one_yuan: true,
|
|
..candidate(date)
|
|
}
|
|
}
|
|
|
|
fn benchmark(date: NaiveDate) -> BenchmarkSnapshot {
|
|
BenchmarkSnapshot {
|
|
date,
|
|
benchmark: "000852.SH".to_string(),
|
|
open: 1000.0,
|
|
close: 1000.0,
|
|
prev_close: 1000.0,
|
|
volume: 1_000_000,
|
|
}
|
|
}
|
|
|
|
fn dataset() -> DataSet {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market(second, 12.0, 99.0),
|
|
candidate(first),
|
|
candidate(second),
|
|
)
|
|
}
|
|
|
|
fn default_instrument() -> Instrument {
|
|
Instrument {
|
|
symbol: SYMBOL.to_string(),
|
|
name: "Test Stock".to_string(),
|
|
board: "SZ".to_string(),
|
|
round_lot: 100,
|
|
listed_at: Some(d(2020, 1, 1)),
|
|
delisted_at: None,
|
|
status: "active".to_string(),
|
|
}
|
|
}
|
|
|
|
fn dataset_with(
|
|
first_market: DailyMarketSnapshot,
|
|
second_market: DailyMarketSnapshot,
|
|
first_candidate: CandidateEligibility,
|
|
second_candidate: CandidateEligibility,
|
|
) -> DataSet {
|
|
dataset_with_instrument(
|
|
default_instrument(),
|
|
first_market,
|
|
second_market,
|
|
first_candidate,
|
|
second_candidate,
|
|
)
|
|
}
|
|
|
|
fn dataset_with_instrument(
|
|
instrument: Instrument,
|
|
first_market: DailyMarketSnapshot,
|
|
second_market: DailyMarketSnapshot,
|
|
first_candidate: CandidateEligibility,
|
|
second_candidate: CandidateEligibility,
|
|
) -> DataSet {
|
|
let first = first_market.date;
|
|
let second = second_market.date;
|
|
DataSet::from_components(
|
|
vec![instrument],
|
|
vec![first_market, second_market],
|
|
vec![factor(first), factor(second)],
|
|
vec![first_candidate, second_candidate],
|
|
vec![benchmark(first), benchmark(second)],
|
|
)
|
|
.expect("dataset")
|
|
}
|
|
|
|
fn dataset_from_market_and_candidates(
|
|
markets: Vec<DailyMarketSnapshot>,
|
|
candidates: Vec<CandidateEligibility>,
|
|
) -> DataSet {
|
|
let factors = markets
|
|
.iter()
|
|
.map(|market| factor(market.date))
|
|
.collect::<Vec<_>>();
|
|
let benchmarks = markets
|
|
.iter()
|
|
.map(|market| benchmark(market.date))
|
|
.collect::<Vec<_>>();
|
|
DataSet::from_components(
|
|
vec![default_instrument()],
|
|
markets,
|
|
factors,
|
|
candidates,
|
|
benchmarks,
|
|
)
|
|
.expect("dataset")
|
|
}
|
|
|
|
#[test]
|
|
fn backtest_execution_dates_match_sparse_lagged_equity_schedule() {
|
|
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6), d(2025, 1, 7)];
|
|
let data = DataSet::from_components(
|
|
vec![default_instrument()],
|
|
dates.iter().map(|date| market(*date, 10.0, 10.0)).collect(),
|
|
vec![factor(dates[0]), factor(dates[2])],
|
|
vec![candidate(dates[0]), candidate(dates[2])],
|
|
dates.iter().map(|date| benchmark(*date)).collect(),
|
|
)
|
|
.expect("sparse lagged dataset");
|
|
|
|
assert_eq!(
|
|
super::backtest_execution_dates(&data, Some(dates[0]), Some(dates[3]), 1,),
|
|
vec![dates[0], dates[1], dates[3]]
|
|
);
|
|
assert_eq!(
|
|
super::backtest_execution_dates(&data, Some(dates[0]), Some(dates[3]), 0,),
|
|
vec![dates[0], dates[2]]
|
|
);
|
|
}
|
|
|
|
fn engine_with_matching(
|
|
matching_type: MatchingType,
|
|
execution_price_field: PriceField,
|
|
decision_lag_trading_days: usize,
|
|
) -> BacktestEngine<BuyWhenDecisionDateStrategy, ChinaAShareCostModel, ChinaEquityRuleHooks>
|
|
{
|
|
let first = d(2025, 1, 2);
|
|
let broker = BrokerSimulator::new_with_execution_price(
|
|
ChinaAShareCostModel::default(),
|
|
ChinaEquityRuleHooks,
|
|
execution_price_field,
|
|
)
|
|
.with_matching_type(matching_type)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false)
|
|
.with_inactive_limit(false);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(d(2025, 1, 3)),
|
|
decision_lag_trading_days,
|
|
execution_price_field,
|
|
};
|
|
|
|
BacktestEngine::new(
|
|
dataset(),
|
|
BuyWhenDecisionDateStrategy {
|
|
decision_date: first,
|
|
},
|
|
broker,
|
|
config,
|
|
)
|
|
}
|
|
|
|
fn run_with_matching(
|
|
matching_type: MatchingType,
|
|
execution_price_field: PriceField,
|
|
decision_lag_trading_days: usize,
|
|
) -> super::BacktestResult {
|
|
engine_with_matching(
|
|
matching_type,
|
|
execution_price_field,
|
|
decision_lag_trading_days,
|
|
)
|
|
.run()
|
|
.expect("backtest run")
|
|
}
|
|
|
|
#[test]
|
|
fn minute_schedules_fire_at_each_declared_time_without_market_rows_at_those_times() {
|
|
let date = d(2026, 6, 1);
|
|
let observed = Rc::new(RefCell::new(Vec::new()));
|
|
let strategy = MinuteScheduleProbeStrategy {
|
|
rules: vec![
|
|
ScheduleRule::daily("first", ScheduleStage::Minute)
|
|
.with_time_rule(ScheduleTimeRule::physical_time(10, 17)),
|
|
ScheduleRule::daily("second", ScheduleStage::Minute)
|
|
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
|
|
],
|
|
observed: Rc::clone(&observed),
|
|
};
|
|
let data = dataset_from_market_and_candidates(
|
|
vec![market(date, 10.0, 10.2)],
|
|
vec![candidate(date)],
|
|
);
|
|
assert!(
|
|
data.execution_quotes_on(date, SYMBOL).is_empty(),
|
|
"the test must prove clock-driven scheduling without minute rows"
|
|
);
|
|
let broker = BrokerSimulator::new_with_execution_price(
|
|
ChinaAShareCostModel::default(),
|
|
ChinaEquityRuleHooks,
|
|
PriceField::Close,
|
|
)
|
|
.with_matching_type(MatchingType::CurrentBarClose)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false)
|
|
.with_inactive_limit(false);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(date),
|
|
end_date: Some(date),
|
|
decision_lag_trading_days: 0,
|
|
execution_price_field: PriceField::Close,
|
|
};
|
|
|
|
BacktestEngine::new(data, strategy, broker, config)
|
|
.run()
|
|
.expect("clock-driven minute schedules");
|
|
|
|
assert_eq!(
|
|
observed.borrow().as_slice(),
|
|
&[
|
|
date.and_time(NaiveTime::from_hms_opt(10, 17, 0).expect("first time")),
|
|
date.and_time(NaiveTime::from_hms_opt(10, 18, 0).expect("second time")),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn physical_on_day_rules_keep_each_actual_submission_time() {
|
|
let date = d(2026, 7, 6);
|
|
let quotes = vec![
|
|
IntradayExecutionQuote {
|
|
date,
|
|
symbol: SYMBOL.to_string(),
|
|
timestamp: date.and_hms_opt(10, 18, 0).expect("morning timestamp"),
|
|
last_price: 11.0,
|
|
bid1: 10.99,
|
|
ask1: 11.0,
|
|
bid1_volume: 10_000,
|
|
ask1_volume: 10_000,
|
|
volume_delta: 10_000,
|
|
amount_delta: 110_000.0,
|
|
trading_phase: Some("continuous_auction".to_string()),
|
|
},
|
|
IntradayExecutionQuote {
|
|
date,
|
|
symbol: SYMBOL.to_string(),
|
|
timestamp: date.and_hms_opt(10, 19, 0).expect("future timestamp"),
|
|
last_price: 99.0,
|
|
bid1: 98.99,
|
|
ask1: 99.0,
|
|
bid1_volume: 10_000,
|
|
ask1_volume: 10_000,
|
|
volume_delta: 10_000,
|
|
amount_delta: 990_000.0,
|
|
trading_phase: Some("continuous_auction".to_string()),
|
|
},
|
|
IntradayExecutionQuote {
|
|
date,
|
|
symbol: SYMBOL.to_string(),
|
|
timestamp: date.and_hms_opt(15, 10, 0).expect("post-close timestamp"),
|
|
last_price: 10.0,
|
|
bid1: 10.0,
|
|
ask1: 10.0,
|
|
bid1_volume: 10_000,
|
|
ask1_volume: 10_000,
|
|
volume_delta: 10_000,
|
|
amount_delta: 100_000.0,
|
|
trading_phase: Some("post_close_fixed_price".to_string()),
|
|
},
|
|
];
|
|
let data = DataSet::from_components_with_actions_and_quotes(
|
|
vec![default_instrument()],
|
|
vec![market(date, 10.0, 10.0)],
|
|
vec![factor(date)],
|
|
vec![candidate(date)],
|
|
vec![benchmark(date)],
|
|
Vec::new(),
|
|
quotes,
|
|
)
|
|
.expect("timed schedule dataset");
|
|
let strategy = TimedOnDayBuyStrategy {
|
|
rules: vec![
|
|
ScheduleRule::daily("morning", ScheduleStage::OnDay)
|
|
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
|
|
ScheduleRule::daily("post_close", ScheduleStage::OnDay)
|
|
.with_time_rule(ScheduleTimeRule::physical_time(15, 10)),
|
|
],
|
|
};
|
|
let broker = BrokerSimulator::new_with_execution_price(
|
|
ChinaAShareCostModel::default(),
|
|
ChinaEquityRuleHooks,
|
|
PriceField::Last,
|
|
)
|
|
.with_matching_type(MatchingType::CurrentBarClose)
|
|
.with_execution_price_field(PriceField::Last)
|
|
.with_intraday_execution_start_time(
|
|
NaiveTime::from_hms_opt(15, 10, 0).expect("runner fallback time"),
|
|
)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false)
|
|
.with_inactive_limit(false);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(date),
|
|
end_date: Some(date),
|
|
decision_lag_trading_days: 0,
|
|
execution_price_field: PriceField::Last,
|
|
};
|
|
|
|
let result = BacktestEngine::new(data, strategy, broker, config)
|
|
.run()
|
|
.expect("timed on-day schedules");
|
|
|
|
assert_eq!(result.fills.len(), 2, "{result:?}");
|
|
assert_eq!(
|
|
result.fills[0].execution_timestamp,
|
|
date.and_hms_opt(10, 18, 0),
|
|
"{:?}",
|
|
result.fills,
|
|
);
|
|
assert_eq!(result.fills[0].price, 11.0);
|
|
assert_eq!(
|
|
result.fills[0].execution_start_timestamp,
|
|
date.and_hms_opt(10, 18, 0)
|
|
);
|
|
assert_eq!(
|
|
result.fills[1].execution_timestamp,
|
|
date.and_hms_opt(15, 10, 0)
|
|
);
|
|
assert_eq!(result.fills[1].price, 10.0);
|
|
assert_eq!(result.fills[0].reason, "morning");
|
|
assert_eq!(result.fills[1].reason, "post_close");
|
|
assert_eq!(
|
|
result.equity_curve[0].notes,
|
|
"note:morning | note:post_close"
|
|
);
|
|
assert_eq!(
|
|
result.equity_curve[0].diagnostics,
|
|
"diagnostic:morning | diagnostic:post_close"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn minute_event_clock_uses_timestamp_order_and_coalesces_equal_heads() {
|
|
let date = d(2026, 6, 1);
|
|
let quote = date.and_hms_opt(10, 18, 0).expect("quote time");
|
|
let earlier_schedule = date.and_hms_opt(10, 17, 0).expect("schedule time");
|
|
|
|
assert_eq!(
|
|
super::next_minute_event_timestamp(Some(quote), Some(earlier_schedule)),
|
|
Some(earlier_schedule)
|
|
);
|
|
assert_eq!(
|
|
super::next_minute_event_timestamp(Some(quote), Some(quote)),
|
|
Some(quote)
|
|
);
|
|
assert_eq!(
|
|
super::next_minute_event_timestamp(None, Some(earlier_schedule)),
|
|
Some(earlier_schedule)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_event_detail_records_actual_time_only_for_timed_rules() {
|
|
let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay)
|
|
.with_time_rule(ScheduleTimeRule::physical_time(10, 18));
|
|
let untimed = ScheduleRule::daily("untimed", ScheduleStage::OnDay);
|
|
|
|
assert_eq!(
|
|
super::scheduled_event_detail(
|
|
&timed,
|
|
ScheduleStage::OnDay,
|
|
NaiveTime::from_hms_opt(10, 18, 0),
|
|
2,
|
|
"pre",
|
|
),
|
|
"scheduled:timed:on_day:10:18:pre"
|
|
);
|
|
assert_eq!(
|
|
super::scheduled_event_detail(
|
|
&untimed,
|
|
ScheduleStage::OnDay,
|
|
NaiveTime::from_hms_opt(15, 0, 0),
|
|
0,
|
|
"post",
|
|
),
|
|
"scheduled:untimed:on_day:post"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_close_order_at_1500_loads_and_uses_post_close_matching_window() {
|
|
let date = d(2026, 7, 6);
|
|
let data = dataset_from_market_and_candidates(
|
|
vec![market(date, 9.5, 10.0)],
|
|
vec![candidate(date)],
|
|
);
|
|
let broker = BrokerSimulator::new_with_execution_price(
|
|
ChinaAShareCostModel::default(),
|
|
ChinaEquityRuleHooks,
|
|
PriceField::Close,
|
|
)
|
|
.with_matching_type(MatchingType::CurrentBarClose)
|
|
.with_intraday_execution_start_time(
|
|
NaiveTime::from_hms_opt(15, 0, 0).expect("valid submission time"),
|
|
)
|
|
.with_slippage_model(SlippageModel::PriceRatio(0.25))
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false)
|
|
.with_inactive_limit(false);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(date),
|
|
end_date: Some(date),
|
|
decision_lag_trading_days: 0,
|
|
execution_price_field: PriceField::Close,
|
|
};
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let captured = Arc::clone(&requests);
|
|
let mut engine = BacktestEngine::new(
|
|
data,
|
|
BuyWhenDecisionDateStrategy {
|
|
decision_date: date,
|
|
},
|
|
broker,
|
|
config,
|
|
)
|
|
.with_execution_quote_loader(move |request| {
|
|
captured
|
|
.lock()
|
|
.expect("request capture lock")
|
|
.push((request.start_time, request.end_time));
|
|
Ok(request
|
|
.symbols
|
|
.into_iter()
|
|
.map(|symbol| IntradayExecutionQuote {
|
|
date: request.date,
|
|
symbol,
|
|
timestamp: request.date.and_hms_opt(15, 5, 0).expect("valid timestamp"),
|
|
last_price: 12.0,
|
|
bid1: 11.99,
|
|
ask1: 12.01,
|
|
bid1_volume: 10_000,
|
|
ask1_volume: 10_000,
|
|
volume_delta: 10_000,
|
|
amount_delta: 120_000.0,
|
|
trading_phase: Some("post_close_fixed_price".to_string()),
|
|
})
|
|
.collect())
|
|
});
|
|
|
|
let result = engine.run().expect("post-close backtest run");
|
|
|
|
assert_eq!(
|
|
requests.lock().expect("request capture lock").as_slice(),
|
|
&[(
|
|
NaiveTime::from_hms_opt(15, 5, 0),
|
|
NaiveTime::from_hms_opt(15, 30, 0),
|
|
)]
|
|
);
|
|
assert_eq!(result.fills.len(), 1, "{result:?}");
|
|
assert_eq!(result.fills[0].price, 10.0);
|
|
assert_eq!(
|
|
result.fills[0].execution_timestamp,
|
|
date.and_hms_opt(15, 5, 0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_progress_keeps_counts_without_event_payload_clones() {
|
|
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
|
|
let mut progress = Vec::new();
|
|
let result = engine
|
|
.run_with_progress_options(false, false, |event| {
|
|
progress.push((
|
|
event.daily_order_count,
|
|
event.daily_fill_count,
|
|
event.orders.len(),
|
|
event.fills.len(),
|
|
event.holdings.len(),
|
|
event.process_events.len(),
|
|
event.notes.len(),
|
|
event.diagnostics.len(),
|
|
));
|
|
})
|
|
.expect("compact progress run");
|
|
assert!(!progress.is_empty());
|
|
assert!(
|
|
progress
|
|
.iter()
|
|
.any(|(orders, fills, ..)| *orders > 0 && *fills > 0)
|
|
);
|
|
assert!(progress.iter().all(
|
|
|(_, _, orders, fills, holdings, process_events, notes, diagnostics)| {
|
|
*orders == 0
|
|
&& *fills == 0
|
|
&& *holdings == 0
|
|
&& *process_events == 0
|
|
&& *notes == 0
|
|
&& *diagnostics == 0
|
|
}
|
|
));
|
|
assert_eq!(
|
|
progress.iter().map(|value| value.0).sum::<usize>(),
|
|
result.order_events.len()
|
|
);
|
|
assert_eq!(
|
|
progress.iter().map(|value| value.1).sum::<usize>(),
|
|
result.fills.len()
|
|
);
|
|
}
|
|
|
|
fn full_day_coverage_engine(
|
|
data: DataSet,
|
|
date: NaiveDate,
|
|
) -> BacktestEngine<BuyWhenDecisionDateStrategy, ChinaAShareCostModel, ChinaEquityRuleHooks>
|
|
{
|
|
let broker = BrokerSimulator::new_with_execution_price(
|
|
ChinaAShareCostModel::default(),
|
|
ChinaEquityRuleHooks,
|
|
PriceField::Last,
|
|
)
|
|
.with_matching_type(MatchingType::MinuteLast)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false);
|
|
BacktestEngine::new(
|
|
data,
|
|
BuyWhenDecisionDateStrategy {
|
|
decision_date: date,
|
|
},
|
|
broker,
|
|
BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(date),
|
|
end_date: Some(date),
|
|
decision_lag_trading_days: 0,
|
|
execution_price_field: PriceField::Last,
|
|
},
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn full_minute_coverage_rejects_missing_active_bars_but_allows_paused_or_zero_volume() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let active = full_day_coverage_engine(dataset(), first);
|
|
let error = active
|
|
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
|
|
.expect_err("active stock with daily volume requires minute bars");
|
|
assert!(
|
|
error.to_string().contains("missing_active_minute_bars"),
|
|
"{error}"
|
|
);
|
|
|
|
let paused_data = dataset_with(
|
|
market_with_state(first, 10.0, 10.0, true, 11.0, 9.0),
|
|
market(second, 10.0, 10.0),
|
|
candidate_with_state(first, true, false),
|
|
candidate(second),
|
|
);
|
|
full_day_coverage_engine(paused_data, first)
|
|
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
|
|
.expect("paused stock may have no minute bars");
|
|
|
|
let zero_volume_data = dataset_with(
|
|
market_with_volume(first, 10.0, 10.0, 0),
|
|
market(second, 10.0, 10.0),
|
|
candidate(first),
|
|
candidate(second),
|
|
);
|
|
full_day_coverage_engine(zero_volume_data, first)
|
|
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
|
|
.expect("zero-volume stock may have no minute bars");
|
|
}
|
|
|
|
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
|
run_scheduled_next_open_with_dataset_and_broker(
|
|
dataset,
|
|
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
|
)
|
|
}
|
|
|
|
fn scheduled_next_open_broker(
|
|
risk_config: FidcRiskControlConfig,
|
|
) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
|
BrokerSimulator::new_with_execution_price(
|
|
ChinaAShareCostModel::default(),
|
|
ChinaEquityRuleHooks,
|
|
PriceField::Open,
|
|
)
|
|
.with_matching_type(MatchingType::NextBarOpen)
|
|
.with_risk_config(risk_config)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false)
|
|
.with_inactive_limit(false)
|
|
}
|
|
|
|
fn run_scheduled_next_open_with_dataset_and_broker(
|
|
dataset: DataSet,
|
|
broker: BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
|
) -> super::BacktestResult {
|
|
let first = d(2025, 1, 2);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(d(2025, 1, 3)),
|
|
decision_lag_trading_days: 1,
|
|
execution_price_field: PriceField::Open,
|
|
};
|
|
|
|
BacktestEngine::new(
|
|
dataset,
|
|
ScheduledBuyStrategy {
|
|
rule: ScheduleRule::weekly_by_weekday("weekly_signal", 4, ScheduleStage::OnDay),
|
|
expected_decision_date: first,
|
|
},
|
|
broker,
|
|
config,
|
|
)
|
|
.run()
|
|
.expect("backtest run")
|
|
}
|
|
|
|
fn run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
|
dataset: DataSet,
|
|
broker: BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
|
) -> super::BacktestResult {
|
|
let first = d(2025, 1, 2);
|
|
let third = d(2025, 1, 6);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(d(2025, 1, 7)),
|
|
decision_lag_trading_days: 1,
|
|
execution_price_field: PriceField::Open,
|
|
};
|
|
|
|
BacktestEngine::new(
|
|
dataset,
|
|
ScheduledRoundTripStrategy {
|
|
rule: ScheduleRule::daily("daily_round_trip", ScheduleStage::OnDay),
|
|
buy_decision_date: first,
|
|
sell_decision_date: third,
|
|
},
|
|
broker,
|
|
config,
|
|
)
|
|
.run()
|
|
.expect("backtest run")
|
|
}
|
|
|
|
fn run_scheduled_same_day_rebuy_next_open_with_dataset_and_broker(
|
|
dataset: DataSet,
|
|
broker: BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
|
) -> super::BacktestResult {
|
|
let first = d(2025, 1, 2);
|
|
let third = d(2025, 1, 6);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(d(2025, 1, 7)),
|
|
decision_lag_trading_days: 1,
|
|
execution_price_field: PriceField::Open,
|
|
};
|
|
|
|
BacktestEngine::new(
|
|
dataset,
|
|
ScheduledSameDayRebuyStrategy {
|
|
rule: ScheduleRule::daily("daily_same_day_rebuy", ScheduleStage::OnDay),
|
|
buy_decision_date: first,
|
|
rebuy_decision_date: third,
|
|
},
|
|
broker,
|
|
config,
|
|
)
|
|
.run()
|
|
.expect("backtest run")
|
|
}
|
|
|
|
fn assert_next_open_canceled_with_reason(result: &super::BacktestResult, reason: &str) {
|
|
let execution_date = d(2025, 1, 3);
|
|
assert!(result.fills.is_empty());
|
|
assert!(result.order_events.iter().any(|event| {
|
|
event.date == execution_date
|
|
&& matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected)
|
|
&& event.reason.contains(reason)
|
|
}));
|
|
}
|
|
|
|
fn assert_round_trip_sell_canceled_with_reason(result: &super::BacktestResult, reason: &str) {
|
|
let execution_date = d(2025, 1, 7);
|
|
assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Buy));
|
|
assert!(result.fills.iter().all(|fill| fill.side != OrderSide::Sell));
|
|
assert!(result.order_events.iter().any(|event| {
|
|
event.date == execution_date
|
|
&& event.side == OrderSide::Sell
|
|
&& matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected)
|
|
&& event.reason.contains(reason)
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn current_bar_close_uses_decision_day_close_for_fill() {
|
|
let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, d(2025, 1, 2));
|
|
assert_eq!(result.fills[0].price, 11.5);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_skips_unavailable_lag_day_and_fills_next_open() {
|
|
let result = run_with_matching(MatchingType::NextBarOpen, PriceField::Open, 1);
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, d(2025, 1, 3));
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
assert!(
|
|
result.equity_curve[0]
|
|
.diagnostics
|
|
.contains("decision_lag_warmup"),
|
|
"{}",
|
|
result.equity_curve[0].diagnostics
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_scheduled_signal_uses_decision_date_not_execution_date() {
|
|
let result = run_scheduled_next_open_with_dataset(dataset());
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, d(2025, 1, 3));
|
|
assert_eq!(result.fills[0].decision_date, Some(d(2025, 1, 2)));
|
|
assert_eq!(result.fills[0].order_created_date, Some(d(2025, 1, 2)));
|
|
assert_eq!(result.fills[0].execution_date, Some(d(2025, 1, 3)));
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_target_portfolio_smart_sizes_with_execution_day_open() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let dataset = DataSet::from_components(
|
|
vec![default_instrument()],
|
|
vec![market(first, 10.0, 10.0), market(second, 12.0, 12.0)],
|
|
vec![factor(first), factor(second)],
|
|
vec![candidate(first), candidate(second)],
|
|
vec![benchmark(first), benchmark(second)],
|
|
)
|
|
.expect("dataset");
|
|
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default());
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(second),
|
|
decision_lag_trading_days: 1,
|
|
execution_price_field: PriceField::Open,
|
|
};
|
|
let mut target_weights = BTreeMap::new();
|
|
target_weights.insert(SYMBOL.to_string(), 1.0);
|
|
|
|
let result = BacktestEngine::new(
|
|
dataset,
|
|
ScheduledTargetPortfolioSmartStrategy {
|
|
rule: ScheduleRule::daily("daily_target_portfolio", ScheduleStage::OnDay),
|
|
decision_date: first,
|
|
target_weights,
|
|
},
|
|
broker,
|
|
config,
|
|
)
|
|
.run()
|
|
.expect("backtest run");
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].decision_date, Some(first));
|
|
assert_eq!(result.fills[0].execution_date, Some(second));
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
assert_eq!(result.fills[0].quantity, 8_300);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_target_percent_freezes_decision_day_equity() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let dataset = dataset_from_market_and_candidates(
|
|
vec![
|
|
market(first, 10.0, 10.0),
|
|
market(second, 10.0, 10.0),
|
|
market(third, 20.0, 20.0),
|
|
],
|
|
vec![candidate(first), candidate(second), candidate(third)],
|
|
);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(third),
|
|
decision_lag_trading_days: 1,
|
|
execution_price_field: PriceField::Open,
|
|
};
|
|
|
|
let result = BacktestEngine::new(
|
|
dataset,
|
|
ScheduledTargetPercentStrategy {
|
|
first_decision_date: first,
|
|
second_decision_date: second,
|
|
},
|
|
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
|
config,
|
|
)
|
|
.run()
|
|
.expect("backtest run");
|
|
|
|
assert_eq!(result.fills.len(), 2, "fills={:?}", result.fills);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].quantity, 1_000);
|
|
assert_eq!(result.fills[1].date, third);
|
|
assert_eq!(result.fills[1].price, 20.0);
|
|
assert_eq!(result.fills[1].quantity, 1_400);
|
|
assert_eq!(result.fills[1].decision_date, Some(second));
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let dataset = DataSet::from_components(
|
|
vec![default_instrument()],
|
|
vec![
|
|
market(first, 10.0, 11.5),
|
|
market(second, 12.0, 13.0),
|
|
market(third, 14.0, 15.0),
|
|
],
|
|
vec![factor(first), factor(second)],
|
|
vec![candidate(first), candidate(second), candidate(third)],
|
|
vec![benchmark(first), benchmark(second), benchmark(third)],
|
|
)
|
|
.expect("dataset");
|
|
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default());
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(third),
|
|
decision_lag_trading_days: 1,
|
|
execution_price_field: PriceField::Open,
|
|
};
|
|
|
|
let result = BacktestEngine::new(
|
|
dataset,
|
|
ScheduledBuyOnDecisionDateStrategy {
|
|
rule: ScheduleRule::daily("daily_signal", ScheduleStage::OnDay),
|
|
decision_date: second,
|
|
},
|
|
broker,
|
|
config,
|
|
)
|
|
.run()
|
|
.expect("backtest run");
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, third);
|
|
assert_eq!(result.fills[0].decision_date, Some(second));
|
|
assert_eq!(result.fills[0].execution_date, Some(third));
|
|
assert_eq!(result.fills[0].price, 14.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_strategy_context_data_helpers_use_decision_date() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let observed = Rc::new(RefCell::new(Vec::new()));
|
|
let dataset = dataset_with(
|
|
market_with_state(first, 10.0, 11.5, true, 11.5, 9.0),
|
|
market_with_state(second, 12.0, 99.0, false, 200.0, 1.0),
|
|
candidate_with_state(first, true, false),
|
|
candidate(second),
|
|
);
|
|
let portfolio = PortfolioState::new(100_000.0);
|
|
let subscriptions = BTreeSet::new();
|
|
let manual_ctx = StrategyContext {
|
|
execution_date: second,
|
|
decision_date: first,
|
|
decision_index: 0,
|
|
data: &dataset,
|
|
portfolio: &portfolio,
|
|
futures_account: None,
|
|
open_orders: &[],
|
|
dynamic_universe: None,
|
|
subscriptions: &subscriptions,
|
|
process_events: &[],
|
|
active_process_event: None,
|
|
active_datetime: Some(second.and_hms_opt(9, 31, 0).unwrap()),
|
|
order_events: &[],
|
|
fills: &[],
|
|
};
|
|
assert_eq!(
|
|
manual_ctx
|
|
.current_snapshot(SYMBOL)
|
|
.map(|snapshot| snapshot.close),
|
|
Some(11.5)
|
|
);
|
|
assert_eq!(
|
|
dataset
|
|
.eligible_universe_on(first)
|
|
.iter()
|
|
.map(|row| row.symbol.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![SYMBOL.to_string()],
|
|
"raw DataSet helper should not apply default selection risk"
|
|
);
|
|
let mut selection_risk_config = FidcRiskControlConfig::default();
|
|
selection_risk_config.static_rules.reject_paused_selection = true;
|
|
assert!(
|
|
dataset
|
|
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
|
|
.is_empty(),
|
|
"explicit raw DataSet selection risk can still filter the universe"
|
|
);
|
|
assert_eq!(
|
|
manual_ctx
|
|
.eligible_universe_on(first)
|
|
.into_iter()
|
|
.map(|row| row.symbol)
|
|
.collect::<Vec<_>>(),
|
|
vec![SYMBOL.to_string()],
|
|
"lagged StrategyContext should not apply signal-day execution risk to universe helpers"
|
|
);
|
|
assert_eq!(
|
|
manual_ctx
|
|
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
|
|
.into_iter()
|
|
.map(|row| row.symbol)
|
|
.collect::<Vec<_>>(),
|
|
vec![SYMBOL.to_string()],
|
|
"lagged StrategyContext should defer configured selection risk on the signal day"
|
|
);
|
|
assert_eq!(
|
|
manual_ctx.history_bars(SYMBOL, 2, "1d", "close", true),
|
|
vec![11.5]
|
|
);
|
|
assert_eq!(manual_ctx.is_suspended(SYMBOL, 1), vec![true]);
|
|
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default());
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(second),
|
|
decision_lag_trading_days: 1,
|
|
execution_price_field: PriceField::Open,
|
|
};
|
|
|
|
let result = BacktestEngine::new(
|
|
dataset.clone(),
|
|
ScheduledDataDateProbeStrategy {
|
|
rule: ScheduleRule::daily("daily_data_date_probe", ScheduleStage::OnDay),
|
|
expected_decision_date: first,
|
|
observed: observed.clone(),
|
|
},
|
|
broker,
|
|
config,
|
|
)
|
|
.run()
|
|
.expect("backtest run");
|
|
|
|
assert_eq!(
|
|
observed.borrow().as_slice(),
|
|
[
|
|
"signal=2025-01-02;execution=2025-01-03;active=2025-01-02;current=11.50;history=11.50;suspended=1"
|
|
]
|
|
);
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_eligible_universe_helper_does_not_block_on_decision_day_risk() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let dataset = dataset_with(
|
|
market_with_state(first, 10.0, 11.5, true, 11.5, 9.0),
|
|
market_with_state(second, 12.0, 99.0, false, 200.0, 1.0),
|
|
candidate_with_state(first, true, false),
|
|
candidate(second),
|
|
);
|
|
let config = BacktestConfig {
|
|
initial_cash: 100_000.0,
|
|
benchmark_code: "000852.SH".to_string(),
|
|
start_date: Some(first),
|
|
end_date: Some(second),
|
|
decision_lag_trading_days: 1,
|
|
execution_price_field: PriceField::Open,
|
|
};
|
|
|
|
let result = BacktestEngine::new(
|
|
dataset,
|
|
ScheduledEligibleUniverseBuyStrategy {
|
|
rule: ScheduleRule::daily("daily_eligible_universe_buy", ScheduleStage::OnDay),
|
|
expected_decision_date: first,
|
|
},
|
|
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
|
config,
|
|
)
|
|
.run()
|
|
.expect("backtest run");
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_ignores_decision_day_paused_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market_with_state(first, 10.0, 11.5, true, 10.0, 9.0),
|
|
market_with_state(second, 12.0, 99.0, false, 200.0, 1.0),
|
|
candidate_with_state(first, true, false),
|
|
candidate(second),
|
|
));
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_ignores_decision_day_upper_limit_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market_with_state(first, 10.0, 10.0, false, 10.0, 1.0),
|
|
market_with_state(second, 12.0, 99.0, false, 200.0, 1.0),
|
|
candidate(first),
|
|
candidate(second),
|
|
));
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_ignores_decision_day_st_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market(second, 12.0, 99.0),
|
|
st_candidate(first),
|
|
candidate(second),
|
|
));
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_ignores_decision_day_star_st_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market(second, 12.0, 99.0),
|
|
star_st_candidate(first),
|
|
candidate(second),
|
|
));
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_rejects_execution_day_paused_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market_with_state(second, 12.0, 99.0, true, 200.0, 1.0),
|
|
candidate(first),
|
|
candidate_with_state(second, true, true),
|
|
));
|
|
|
|
assert_next_open_canceled_with_reason(&result, "paused");
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_rejects_execution_day_upper_limit_buy() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market_with_state(second, 12.0, 99.0, false, 12.0, 1.0),
|
|
candidate(first),
|
|
candidate(second),
|
|
));
|
|
|
|
assert_next_open_canceled_with_reason(&result, "open at or above upper limit");
|
|
let execution_decision = result
|
|
.risk_decisions
|
|
.iter()
|
|
.find(|decision| {
|
|
decision.date == second
|
|
&& decision.symbol == SYMBOL
|
|
&& decision.scope == RiskCheckScope::Buy
|
|
&& decision.stage == "execution"
|
|
&& decision.rule_code == "upper_limit"
|
|
&& !decision.accepted
|
|
})
|
|
.expect("execution-day upper limit buy rejection should be audited");
|
|
assert!(
|
|
execution_decision
|
|
.reason
|
|
.contains("open at or above upper limit"),
|
|
"{}",
|
|
execution_decision.reason
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_uses_open_not_close_for_upper_limit_buy() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market_with_state(second, 11.8, 12.0, false, 12.0, 1.0),
|
|
candidate(first),
|
|
candidate(second),
|
|
));
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
assert_eq!(result.fills[0].date, second);
|
|
assert_eq!(result.fills[0].price, 11.8);
|
|
assert!(
|
|
result
|
|
.order_events
|
|
.iter()
|
|
.all(|event| !event.reason.contains("upper limit")),
|
|
"{:?}",
|
|
result.order_events
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_rejects_execution_day_st_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market(second, 12.0, 99.0),
|
|
candidate(first),
|
|
st_candidate(second),
|
|
));
|
|
|
|
assert_next_open_canceled_with_reason(&result, "st");
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_rejects_execution_day_star_st_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market(second, 12.0, 99.0),
|
|
candidate(first),
|
|
star_st_candidate(second),
|
|
));
|
|
|
|
assert_next_open_canceled_with_reason(&result, "star_st");
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
|
market(first, 10.0, 11.5),
|
|
market(second, 12.0, 99.0),
|
|
candidate(first),
|
|
one_yuan_candidate(second),
|
|
));
|
|
|
|
assert_next_open_canceled_with_reason(&result, "one_yuan");
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_rejects_execution_day_delisted_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let mut instrument = default_instrument();
|
|
instrument.delisted_at = Some(second);
|
|
let result = run_scheduled_next_open_with_dataset(dataset_with_instrument(
|
|
instrument,
|
|
market(first, 10.0, 11.5),
|
|
market(second, 12.0, 99.0),
|
|
candidate(first),
|
|
candidate(second),
|
|
));
|
|
|
|
assert_next_open_canceled_with_reason(&result, "inactive_or_delisted");
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_execution_risk_rejects_blacklisted_buy_on_execution_day() {
|
|
let mut risk_config = FidcRiskControlConfig::default();
|
|
risk_config
|
|
.static_rules
|
|
.blacklisted_symbols
|
|
.insert(SYMBOL.to_string());
|
|
let result = run_scheduled_next_open_with_dataset_and_broker(
|
|
dataset(),
|
|
scheduled_next_open_broker(risk_config),
|
|
);
|
|
|
|
assert_next_open_canceled_with_reason(&result, "blacklisted");
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_sell_risk_ignores_decision_day_paused_and_lower_limit_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let fourth = d(2025, 1, 7);
|
|
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
|
dataset_from_market_and_candidates(
|
|
vec![
|
|
market(first, 10.0, 10.5),
|
|
market(second, 11.0, 11.5),
|
|
market_with_state(third, 9.0, 9.0, true, 20.0, 9.0),
|
|
market(fourth, 12.0, 12.2),
|
|
],
|
|
vec![
|
|
candidate(first),
|
|
candidate(second),
|
|
candidate_with_sell_state(third, true, false),
|
|
candidate(fourth),
|
|
],
|
|
),
|
|
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
|
);
|
|
|
|
let sell_fill = result
|
|
.fills
|
|
.iter()
|
|
.find(|fill| fill.side == OrderSide::Sell)
|
|
.expect("sell should execute on actual execution date");
|
|
assert_eq!(sell_fill.date, fourth);
|
|
assert_eq!(sell_fill.price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_sell_risk_rejects_execution_day_lower_limit_state() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let fourth = d(2025, 1, 7);
|
|
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
|
dataset_from_market_and_candidates(
|
|
vec![
|
|
market(first, 10.0, 10.5),
|
|
market(second, 11.0, 11.5),
|
|
market(third, 12.0, 12.5),
|
|
market_with_state(fourth, 9.0, 9.0, false, 20.0, 9.0),
|
|
],
|
|
vec![
|
|
candidate(first),
|
|
candidate(second),
|
|
candidate(third),
|
|
candidate(fourth),
|
|
],
|
|
),
|
|
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
|
);
|
|
|
|
assert_round_trip_sell_canceled_with_reason(&result, "open at or below lower limit");
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_sell_risk_uses_open_not_close_for_lower_limit_sell() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let fourth = d(2025, 1, 7);
|
|
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
|
dataset_from_market_and_candidates(
|
|
vec![
|
|
market(first, 10.0, 10.5),
|
|
market(second, 11.0, 11.5),
|
|
market(third, 12.0, 12.5),
|
|
market_with_state(fourth, 9.2, 9.0, false, 20.0, 9.0),
|
|
],
|
|
vec![
|
|
candidate(first),
|
|
candidate(second),
|
|
candidate(third),
|
|
candidate(fourth),
|
|
],
|
|
),
|
|
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
|
);
|
|
|
|
let sell_fill = result
|
|
.fills
|
|
.iter()
|
|
.find(|fill| fill.side == OrderSide::Sell)
|
|
.expect("sell should execute when next-open is above lower limit");
|
|
assert_eq!(sell_fill.date, fourth);
|
|
assert_eq!(sell_fill.price, 9.2);
|
|
assert!(
|
|
result
|
|
.order_events
|
|
.iter()
|
|
.all(|event| !event.reason.contains("lower limit")),
|
|
"{:?}",
|
|
result.order_events
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_sell_respects_allow_sell_policy_on_execution_day() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let fourth = d(2025, 1, 7);
|
|
let dataset = dataset_from_market_and_candidates(
|
|
vec![
|
|
market(first, 10.0, 10.5),
|
|
market(second, 11.0, 11.5),
|
|
market(third, 12.0, 12.5),
|
|
market(fourth, 12.0, 12.2),
|
|
],
|
|
vec![
|
|
candidate(first),
|
|
candidate(second),
|
|
candidate(third),
|
|
candidate_with_sell_state(fourth, false, false),
|
|
],
|
|
);
|
|
|
|
let rejected = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
|
dataset.clone(),
|
|
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
|
);
|
|
assert_round_trip_sell_canceled_with_reason(&rejected, "sell_disabled");
|
|
let execution_decision = rejected
|
|
.risk_decisions
|
|
.iter()
|
|
.find(|decision| {
|
|
decision.date == fourth
|
|
&& decision.symbol == SYMBOL
|
|
&& decision.scope == RiskCheckScope::Sell
|
|
&& decision.stage == "execution"
|
|
&& decision.rule_code == "sell_disabled"
|
|
&& !decision.accepted
|
|
})
|
|
.expect("execution-day sell_disabled rejection should be audited");
|
|
assert_eq!(execution_decision.order_id.as_deref(), Some("2"));
|
|
|
|
let mut relaxed_config = FidcRiskControlConfig::default();
|
|
relaxed_config.static_rules.respect_allow_buy_sell = false;
|
|
let relaxed = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
|
dataset,
|
|
scheduled_next_open_broker(relaxed_config),
|
|
);
|
|
let sell_fill = relaxed
|
|
.fills
|
|
.iter()
|
|
.find(|fill| fill.side == OrderSide::Sell)
|
|
.expect("sell should execute when allow_sell policy is disabled");
|
|
assert_eq!(sell_fill.date, fourth);
|
|
assert_eq!(sell_fill.price, 12.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_sell_volume_limit_ignores_decision_day_zero_volume() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let fourth = d(2025, 1, 7);
|
|
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
|
.with_volume_limit(true)
|
|
.with_volume_percent(0.25);
|
|
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
|
dataset_from_market_and_candidates(
|
|
vec![
|
|
market(first, 10.0, 10.5),
|
|
market(second, 11.0, 11.5),
|
|
market_with_volume(third, 12.0, 12.5, 0),
|
|
market(fourth, 12.0, 12.2),
|
|
],
|
|
vec![
|
|
candidate(first),
|
|
candidate(second),
|
|
candidate(third),
|
|
candidate(fourth),
|
|
],
|
|
),
|
|
broker,
|
|
);
|
|
|
|
assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Buy));
|
|
assert!(result.fills.iter().any(|fill| {
|
|
fill.side == OrderSide::Sell && fill.date == fourth && (fill.price - 12.0).abs() < 1e-9
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let fourth = d(2025, 1, 7);
|
|
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
|
.with_volume_limit(true)
|
|
.with_volume_percent(0.25);
|
|
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
|
dataset_from_market_and_candidates(
|
|
vec![
|
|
market(first, 10.0, 10.5),
|
|
market(second, 11.0, 11.5),
|
|
market(third, 12.0, 12.5),
|
|
market_with_volume(fourth, 12.0, 12.2, 0),
|
|
],
|
|
vec![
|
|
candidate(first),
|
|
candidate(second),
|
|
candidate(third),
|
|
candidate(fourth),
|
|
],
|
|
),
|
|
broker,
|
|
);
|
|
|
|
assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit");
|
|
}
|
|
|
|
#[test]
|
|
fn next_bar_open_same_day_rebuy_uses_actual_execution_date() {
|
|
let first = d(2025, 1, 2);
|
|
let second = d(2025, 1, 3);
|
|
let third = d(2025, 1, 6);
|
|
let fourth = d(2025, 1, 7);
|
|
let result = run_scheduled_same_day_rebuy_next_open_with_dataset_and_broker(
|
|
dataset_from_market_and_candidates(
|
|
vec![
|
|
market(first, 10.0, 10.5),
|
|
market(second, 11.0, 11.5),
|
|
market(third, 12.0, 12.5),
|
|
market(fourth, 13.0, 13.5),
|
|
],
|
|
vec![
|
|
candidate(first),
|
|
candidate(second),
|
|
candidate(third),
|
|
candidate(fourth),
|
|
],
|
|
),
|
|
scheduled_next_open_broker(FidcRiskControlConfig::default()),
|
|
);
|
|
|
|
assert!(result.fills.iter().any(|fill| {
|
|
fill.side == OrderSide::Buy
|
|
&& fill.date == second
|
|
&& fill.reason == "same_day_rebuy_setup_buy"
|
|
}));
|
|
assert!(result.fills.iter().any(|fill| {
|
|
fill.side == OrderSide::Sell
|
|
&& fill.date == fourth
|
|
&& fill.reason == "same_day_rebuy_sell"
|
|
}));
|
|
assert!(result.order_events.iter().any(|event| {
|
|
event.date == fourth
|
|
&& event.side == OrderSide::Buy
|
|
&& matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected)
|
|
&& event.reason.contains("same_day_rebuy_forbidden")
|
|
}));
|
|
assert!(!result.order_events.iter().any(|event| {
|
|
event.date == third && event.reason.contains("same_day_rebuy_forbidden")
|
|
}));
|
|
}
|
|
}
|