diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index a46cc1c..e8d9700 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1130,6 +1130,7 @@ pub struct PlatformExprStrategy { numeric_vm_scratch: RefCell, numeric_vm_hits: RefCell, numeric_vm_fallbacks: RefCell, + explicit_action_stock_state_required: Vec, prelude_dependency_plan: PreludeDependencyPlan, prelude_identifier_candidates: BTreeSet, prelude_declared_identifiers: BTreeSet, @@ -1437,7 +1438,7 @@ impl PlatformExprStrategy { .portfolio_drawdown_control .clone() .map(PlatformPortfolioDrawdownController::new); - Self { + let mut strategy = Self { config, engine, rebalance_day_counter: 0, @@ -1457,6 +1458,7 @@ impl PlatformExprStrategy { numeric_vm_scratch: RefCell::new(NumericVmScratch::default()), numeric_vm_hits: RefCell::new(0), numeric_vm_fallbacks: RefCell::new(0), + explicit_action_stock_state_required: Vec::new(), prelude_dependency_plan, prelude_identifier_candidates, prelude_declared_identifiers, @@ -1474,7 +1476,14 @@ impl PlatformExprStrategy { stock_state_cache_date: RefCell::new(None), stock_state_cache_calendar_index: RefCell::new(None), stock_state_cache: RefCell::new(AHashMap::new()), - } + }; + strategy.explicit_action_stock_state_required = strategy + .config + .explicit_actions + .iter() + .map(|action| strategy.explicit_action_requires_stock_state(action)) + .collect(); + strategy } /// AST 缓存命中次数(仅用于测试与诊断)。 @@ -8641,6 +8650,140 @@ impl PlatformExprStrategy { || self.config.signal_rebalance_dates.contains(&decision_date) } + fn numeric_vm_identifier_requires_stock_state(identifier: &str) -> bool { + !matches!( + identifier, + "trade_date" + | "current_date" + | "date" + | "decision_date" + | "execution_date" + | "signal_open" + | "signal_close" + | "benchmark_open" + | "benchmark_close" + | "benchmark_signal_close" + | "signal_ma5" + | "signal_ma10" + | "signal_ma20" + | "signal_ma30" + | "signal_ma_short" + | "signal_ma_long" + | "benchmark_ma5" + | "benchmark_ma10" + | "benchmark_ma20" + | "benchmark_ma30" + | "benchmark_ma_short" + | "benchmark_ma_long" + | "cash" + | "available_cash" + | "frozen_cash" + | "market_value" + | "total_equity" + | "total_value" + | "portfolio_value" + | "starting_cash" + | "unit_net_value" + | "static_unit_net_value" + | "daily_pnl" + | "daily_returns" + | "total_returns" + | "transaction_cost" + | "trading_pnl" + | "position_pnl" + | "cash_liabilities" + | "management_fee_rate" + | "management_fees" + | "current_exposure" + | "position_count" + | "max_positions" + | "refresh_rate" + | "year" + | "month" + | "quarter" + | "day_of_month" + | "day_of_year" + | "week_of_year" + | "weekday" + | "is_month_start" + | "is_month_end" + | "has_open_orders" + | "open_order_count" + | "open_buy_order_count" + | "open_sell_order_count" + | "open_buy_qty" + | "open_sell_qty" + | "latest_open_order_id" + | "latest_open_order_unfilled_qty" + | "has_dynamic_universe" + | "dynamic_universe_count" + | "has_subscriptions" + | "subscription_count" + | "subscription_guard_required" + | "has_process_events" + | "process_event_count" + | "current_process_order_id" + | "latest_process_order_id" + ) + } + + fn expression_requires_stock_state(&self, expression: &str) -> bool { + let plan = self.expression_eval_plan(expression); + let Some(numeric_vm) = plan.numeric_vm.as_ref() else { + return true; + }; + numeric_vm + .program + .variables() + .iter() + .zip(&numeric_vm.helper_bindings) + .any(|(identifier, helper)| { + helper.is_some() || Self::numeric_vm_identifier_requires_stock_state(identifier) + }) + } + + fn explicit_action_requires_stock_state(&self, action: &PlatformTradeAction) -> bool { + match action { + PlatformTradeAction::Order { + amount_expr, + limit_price_expr, + start_time_expr, + end_time_expr, + when_expr, + .. + } => std::iter::once(amount_expr.as_str()) + .chain(limit_price_expr.as_deref()) + .chain(start_time_expr.as_deref()) + .chain(end_time_expr.as_deref()) + .chain(when_expr.as_deref()) + .any(|expression| self.expression_requires_stock_state(expression)), + PlatformTradeAction::Cancel { + order_id_expr, + when_expr, + .. + } => order_id_expr + .as_deref() + .into_iter() + .chain(when_expr.as_deref()) + .any(|expression| self.expression_requires_stock_state(expression)), + PlatformTradeAction::Modify { + order_id_expr, + new_total_quantity_expr, + new_limit_price_expr, + when_expr, + .. + } => std::iter::once(order_id_expr.as_str()) + .chain(new_total_quantity_expr.as_deref()) + .chain(new_limit_price_expr.as_deref()) + .chain(when_expr.as_deref()) + .any(|expression| self.expression_requires_stock_state(expression)), + PlatformTradeAction::TargetPortfolioSmart { .. } + | PlatformTradeAction::Universe { .. } + | PlatformTradeAction::Account { .. } + | PlatformTradeAction::Futures { .. } => false, + } + } + fn action_when_matches( &self, ctx: &StrategyContext<'_>, @@ -8662,7 +8805,7 @@ impl PlatformExprStrategy { ) -> Result<(Vec, Vec), BacktestError> { let mut intents = Vec::new(); let mut diagnostics = Vec::new(); - for action in &self.config.explicit_actions { + for (action_index, action) in self.config.explicit_actions.iter().enumerate() { match action { PlatformTradeAction::Order { kind, @@ -8675,7 +8818,11 @@ impl PlatformExprStrategy { when_expr, reason, } => { - let stock_state = self.action_stock_state(ctx, date, Some(symbol))?; + let stock_state = if self.explicit_action_stock_state_required[action_index] { + self.action_stock_state(ctx, date, Some(symbol))? + } else { + None + }; if !self.action_when_matches( ctx, day, @@ -9139,7 +9286,11 @@ impl PlatformExprStrategy { when_expr, reason, } => { - let stock_state = self.action_stock_state(ctx, date, symbol.as_deref())?; + let stock_state = if self.explicit_action_stock_state_required[action_index] { + self.action_stock_state(ctx, date, symbol.as_deref())? + } else { + None + }; if !self.action_when_matches( ctx, day, @@ -9189,7 +9340,11 @@ impl PlatformExprStrategy { when_expr, reason, } => { - let stock_state = self.action_stock_state(ctx, date, symbol.as_deref())?; + let stock_state = if self.explicit_action_stock_state_required[action_index] { + self.action_stock_state(ctx, date, symbol.as_deref())? + } else { + None + }; if !self.action_when_matches( ctx, day, @@ -34421,6 +34576,47 @@ mod tests { ); } + #[test] + fn explicit_action_stock_state_dependency_is_compiled_conservatively() { + let mut config = PlatformExprStrategyConfig::generic(); + config.explicit_actions = vec![ + PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::TargetPercent, + symbol: "000001.SZ".to_string(), + amount_expr: "if decision_date == \"2025-02-03\" { 0.5 } else { 0.0 }".to_string(), + limit_price_expr: None, + time_in_force: None, + start_time_expr: None, + end_time_expr: None, + when_expr: None, + reason: "date_only".to_string(), + }, + PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::TargetPercent, + symbol: "000002.SZ".to_string(), + amount_expr: "allow_buy ? 0.5 : 0.0".to_string(), + limit_price_expr: None, + time_in_force: None, + start_time_expr: None, + end_time_expr: None, + when_expr: None, + reason: "stock_dependent".to_string(), + }, + ]; + let strategy = PlatformExprStrategy::new(config); + assert_eq!( + strategy.explicit_action_stock_state_required, + vec![false, true] + ); + assert!(!strategy.expression_requires_stock_state( + "if decision_date == \"2025-02-03\" { cash * 0.1 } else { 0.0 }" + )); + assert!(!strategy.expression_requires_stock_state("cash * 0.1")); + assert!(strategy.expression_requires_stock_state("allow_buy")); + assert!(strategy.expression_requires_stock_state("factor_value(\"quality\") > 0")); + assert!(strategy.expression_requires_stock_state("symbol == \"000001.SZ\"")); + } + #[test] fn expression_plan_only_executes_prelude_when_expression_depends_on_it() { let mut cfg = PlatformExprStrategyConfig::microcap_rotation();