修正回测执行时tick取价
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<'_>,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user