修正回测执行时tick取价

This commit is contained in:
boris
2026-05-28 17:32:40 +08:00
parent 8c86918970
commit c6dc1d1474
4 changed files with 215 additions and 16 deletions
+4
View File
@@ -291,6 +291,10 @@ impl<C, R> BrokerSimulator<C, R> {
self.execution_price_field
}
pub fn intraday_execution_start_time(&self) -> Option<NaiveTime> {
self.intraday_execution_start_time
}
pub fn open_order_views(&self) -> Vec<OpenOrderView> {
self.open_orders
.borrow()
+190 -2
View File
@@ -6,7 +6,7 @@ use thiserror::Error;
use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType};
use crate::cost::CostModel;
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
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,
@@ -20,7 +20,10 @@ use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
use crate::rules::EquityRuleHooks;
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time};
use crate::strategy::{Strategy, StrategyContext};
use crate::strategy::{
OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision,
TargetPortfolioOrderPricing,
};
#[derive(Debug, Error)]
pub enum BacktestError {
@@ -95,6 +98,18 @@ pub struct BacktestResult {
pub metrics: BacktestMetrics,
}
#[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")]
@@ -325,6 +340,7 @@ pub struct BacktestEngine<S, C, R> {
futures_settlement_price_mode: String,
futures_cost_model: FuturesTransactionCostModel,
futures_validation_config: FuturesValidationConfig,
execution_quote_loader: Option<ExecutionQuoteLoader>,
}
impl<S, C, R> BacktestEngine<S, C, R> {
@@ -352,9 +368,24 @@ impl<S, C, R> BacktestEngine<S, C, R> {
futures_settlement_price_mode: "close".to_string(),
futures_cost_model: FuturesTransactionCostModel::default(),
futures_validation_config: FuturesValidationConfig::default(),
execution_quote_loader: None,
}
}
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_dividend_reinvestment(mut self, enabled: bool) -> Self {
self.dividend_reinvestment = enabled;
self
@@ -474,6 +505,48 @@ where
C: CostModel,
R: EquityRuleHooks,
{
fn ensure_execution_quotes_for_decision(
&mut self,
execution_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(());
}
if self.broker.execution_price_field() != PriceField::Last
&& !decision_has_algo_execution(decision)
{
return Ok(());
}
let start_time = start_time.or_else(|| self.broker.intraday_execution_start_time());
let mut symbols = execution_quote_symbols_for_decision(decision, portfolio, open_orders);
symbols.retain(|symbol| {
!has_execution_quote_in_window(&self.data, execution_date, symbol, start_time, end_time)
});
if symbols.is_empty() {
return Ok(());
}
let request = ExecutionQuoteRequest {
date: execution_date,
start_time,
end_time,
symbols,
};
let quotes = self
.execution_quote_loader
.as_mut()
.expect("checked execution quote loader")
.as_mut()(request)?;
self.data.add_execution_quotes(quotes);
Ok(())
}
fn apply_strategy_directives(
&mut self,
execution_date: NaiveDate,
@@ -1735,6 +1808,15 @@ where
&mut auction_decision,
&mut directive_report,
)?;
let pre_auction_execution_orders = self.open_order_views();
self.ensure_execution_quotes_for_decision(
execution_date,
&portfolio,
&pre_auction_execution_orders,
&auction_decision,
None,
None,
)?;
let mut report = self.broker.execute(
execution_date,
&mut portfolio,
@@ -1939,6 +2021,15 @@ where
&mut directive_report,
)?;
let pre_intraday_execution_orders = self.open_order_views();
self.ensure_execution_quotes_for_decision(
execution_date,
&portfolio,
&pre_intraday_execution_orders,
&decision,
None,
None,
)?;
let mut intraday_report =
self.broker
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
@@ -2096,6 +2187,15 @@ where
&mut tick_decision,
&mut directive_report,
)?;
let pre_tick_execution_orders = self.open_order_views();
self.ensure_execution_quotes_for_decision(
execution_date,
&portfolio,
&pre_tick_execution_orders,
&tick_decision,
Some(tick_time),
Some(tick_time),
)?;
let mut tick_report = self.broker.execute_between(
execution_date,
&mut portfolio,
@@ -3088,6 +3188,94 @@ where
}
}
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));
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 decision_has_algo_execution(decision: &StrategyDecision) -> bool {
decision.order_intents.iter().any(|intent| {
matches!(
intent,
OrderIntent::AlgoValue { .. }
| OrderIntent::AlgoPercent { .. }
| 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
|| !decision.target_weights.is_empty()
|| !decision.exit_symbols.is_empty()
{
symbols.extend(portfolio.positions().keys().cloned());
symbols.extend(decision.target_weights.keys().cloned());
symbols.extend(decision.exit_symbols.iter().cloned());
}
for intent in &decision.order_intents {
match intent {
OrderIntent::Shares { symbol, .. }
| OrderIntent::LimitShares { symbol, .. }
| OrderIntent::Lots { symbol, .. }
| OrderIntent::LimitLots { symbol, .. }
| OrderIntent::TargetShares { symbol, .. }
| OrderIntent::LimitTargetShares { symbol, .. }
| OrderIntent::TargetValue { 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::UpdateUniverse { .. }
| OrderIntent::Subscribe { .. }
| OrderIntent::Unsubscribe { .. }
| OrderIntent::DepositWithdraw { .. }
| OrderIntent::FinanceRepay { .. }
| OrderIntent::SetManagementFeeRate { .. }
| OrderIntent::CancelOrder { .. }
| OrderIntent::Futures { .. } => {}
}
}
symbols.retain(|symbol| !symbol.trim().is_empty());
symbols
}
fn collect_scheduled_decisions<S: Strategy>(
strategy: &mut S,
scheduler: &Scheduler<'_>,
+2 -2
View File
@@ -34,7 +34,7 @@ pub use data::{
pub use engine::{
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, DailyEquityPoint, FuturesValidationConfig,
BacktestResult, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
};
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{
@@ -51,7 +51,7 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use platform_expr_strategy::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionPrefetchPlan,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan,
PlatformTradeAction, PlatformUniverseActionKind,
};
pub use platform_runtime_schema::{
+19 -12
View File
@@ -473,7 +473,7 @@ pub struct PlatformExprStrategy {
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlatformSelectionPrefetchPlan {
pub struct PlatformSelectionQuotePlan {
pub execution_date: NaiveDate,
pub decision_date: NaiveDate,
pub selection_date: NaiveDate,
@@ -1761,6 +1761,10 @@ impl PlatformExprStrategy {
include_process_event_counts: bool,
) -> Scope<'static> {
let mut scope = Scope::new();
let trade_date = day.date.format("%Y-%m-%d").to_string();
scope.push("trade_date", trade_date.clone());
scope.push("current_date", trade_date.clone());
scope.push("date", trade_date);
scope.push("signal_open", day.signal_open);
scope.push("signal_close", day.signal_close);
scope.push("benchmark_open", day.benchmark_open);
@@ -5130,7 +5134,7 @@ impl PlatformExprStrategy {
})
}
fn select_prefetch_symbols(
fn select_quote_plan_symbols(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
@@ -5151,7 +5155,7 @@ impl PlatformExprStrategy {
if !field_value.is_finite() {
if diagnostics.len() < 12 {
diagnostics.push(format!(
"{} prefetch rejected by missing selection field",
"{} quote_plan rejected by missing selection field",
candidate.symbol
));
}
@@ -5164,7 +5168,7 @@ impl PlatformExprStrategy {
if !rank_value.is_finite() {
if diagnostics.len() < 12 {
diagnostics.push(format!(
"{} prefetch rejected by missing rank field",
"{} quote_plan rejected by missing rank field",
candidate.symbol
));
}
@@ -5202,13 +5206,13 @@ impl PlatformExprStrategy {
let stock = self.stock_state_with_factor_date(ctx, date, factor_date, symbol)?;
if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol, &stock)? {
if diagnostics.len() < 12 {
diagnostics.push(format!("{symbol} prefetch rejected by {reason}"));
diagnostics.push(format!("{symbol} quote_plan rejected by {reason}"));
}
continue;
}
if apply_stock_filter && !self.stock_passes_expr(ctx, day, &stock)? {
if diagnostics.len() < 12 {
diagnostics.push(format!("{symbol} prefetch rejected by stock_expr"));
diagnostics.push(format!("{symbol} quote_plan rejected by stock_expr"));
}
continue;
}
@@ -5223,13 +5227,13 @@ impl PlatformExprStrategy {
Ok((processed_symbols, selected_symbols, diagnostics))
}
pub fn selection_prefetch_plan(
pub fn selection_quote_plan(
&self,
ctx: &StrategyContext<'_>,
_scope_limit: usize,
) -> Result<PlatformSelectionPrefetchPlan, BacktestError> {
) -> Result<PlatformSelectionQuotePlan, BacktestError> {
if !self.config.rotation_enabled || self.config.in_skip_window(ctx.execution_date) {
return Ok(PlatformSelectionPrefetchPlan {
return Ok(PlatformSelectionQuotePlan {
execution_date: ctx.execution_date,
decision_date: ctx.decision_date,
selection_date: ctx.execution_date,
@@ -5252,7 +5256,7 @@ impl PlatformExprStrategy {
let selection_limit = self
.selection_limit(ctx, &day)?
.min(self.config.max_positions.max(1));
let (candidate_symbols, order_symbols, diagnostics) = self.select_prefetch_symbols(
let (candidate_symbols, order_symbols, diagnostics) = self.select_quote_plan_symbols(
ctx,
selection_date,
factor_date,
@@ -5261,7 +5265,7 @@ impl PlatformExprStrategy {
band_high,
selection_limit,
)?;
Ok(PlatformSelectionPrefetchPlan {
Ok(PlatformSelectionQuotePlan {
execution_date: ctx.execution_date,
decision_date: ctx.decision_date,
selection_date,
@@ -8669,6 +8673,7 @@ mod tests {
cfg.rotation_enabled = false;
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
cfg.prelude = "let blackout = trade_date >= \"2025-01-06\";".to_string();
cfg.explicit_actions = vec![PlatformTradeAction::Order {
kind: PlatformExplicitOrderKind::Value,
symbol: "000001.SZ".to_string(),
@@ -8688,7 +8693,9 @@ mod tests {
" && stddev(\"close\", 2) > 0.49",
" && rolling_zscore(\"close\", 2) > 0.9",
" && pct_change(\"close\", 1) > 0.09",
" && factor_value(\"mixed_factor\") == 7.0"
" && factor_value(\"mixed_factor\") == 7.0",
" && trade_date == \"2025-01-06\"",
" && blackout"
)
.to_string(),
),