diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 5db7e9b..6d269f9 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -291,6 +291,10 @@ impl BrokerSimulator { self.execution_price_field } + pub fn intraday_execution_start_time(&self) -> Option { + self.intraday_execution_start_time + } + pub fn open_order_views(&self) -> Vec { self.open_orders .borrow() diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 185e6a2..025a1bd 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -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, + pub end_time: Option, + pub symbols: BTreeSet, +} + +type ExecutionQuoteLoader = Box< + dyn FnMut(ExecutionQuoteRequest) -> Result, BacktestError> + Send, +>; + #[derive(Debug, Clone, Serialize)] pub struct AnalyzerTradeRow { #[serde(with = "date_format")] @@ -325,6 +340,7 @@ pub struct BacktestEngine { futures_settlement_price_mode: String, futures_cost_model: FuturesTransactionCostModel, futures_validation_config: FuturesValidationConfig, + execution_quote_loader: Option, } impl BacktestEngine { @@ -352,9 +368,24 @@ impl BacktestEngine { 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(mut self, loader: F) -> Self + where + F: FnMut(ExecutionQuoteRequest) -> Result, 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, + end_time: Option, + ) -> 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, + end_time: Option, +) -> 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 { + 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( strategy: &mut S, scheduler: &Scheduler<'_>, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 2e313b1..b86740f 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -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::{ diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 385dc69..63dfd9d 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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 { + ) -> Result { 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(), ),