diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 3a04c39..071c5e6 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -605,6 +605,7 @@ pub struct PlatformExprStrategyConfig { pub selection_limit_expr: String, pub selection_candidate_limit_expr: String, pub stock_filter_expr: String, + pub buy_filter_expr: String, pub buy_scale_expr: String, pub exposure_expr: String, pub position_exposure_schedule: BTreeMap, @@ -684,6 +685,7 @@ impl PlatformExprStrategyConfig { selection_limit_expr: "1".to_string(), selection_candidate_limit_expr: String::new(), stock_filter_expr: String::new(), + buy_filter_expr: String::new(), buy_scale_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(), position_exposure_schedule: BTreeMap::new(), @@ -1800,6 +1802,7 @@ impl PlatformExprStrategy { "stock_filter_expr".to_string(), self.config.stock_filter_expr.as_str(), ), + ("buy_filter_expr".to_string(), self.config.buy_filter_expr.as_str()), ( "buy_scale_expr".to_string(), self.config.buy_scale_expr.as_str(), @@ -10960,6 +10963,7 @@ impl PlatformExprStrategy { let expressions = [ config.prelude.as_str(), config.stock_filter_expr.as_str(), + config.buy_filter_expr.as_str(), config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), config.take_profit_expr.as_str(), @@ -10992,6 +10996,7 @@ impl PlatformExprStrategy { for expr in [ config.prelude.as_str(), config.stock_filter_expr.as_str(), + config.buy_filter_expr.as_str(), config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), config.take_profit_expr.as_str(), @@ -11039,6 +11044,7 @@ impl PlatformExprStrategy { for expr in [ config.prelude.as_str(), config.stock_filter_expr.as_str(), + config.buy_filter_expr.as_str(), config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), config.take_profit_expr.as_str(), @@ -11079,6 +11085,9 @@ impl PlatformExprStrategy { if Self::expr_requires_stock_extra_factors( &config.stock_filter_expr, prelude_declared_identifiers, + ) || Self::expr_requires_stock_extra_factors( + &config.buy_filter_expr, + prelude_declared_identifiers, ) { return true; } @@ -11108,6 +11117,7 @@ impl PlatformExprStrategy { [ config.prelude.as_str(), config.stock_filter_expr.as_str(), + config.buy_filter_expr.as_str(), config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), config.take_profit_expr.as_str(), @@ -11142,6 +11152,11 @@ impl PlatformExprStrategy { &config.stock_filter_expr, prelude_declared_identifiers, ); + Self::collect_stock_extra_factor_identifiers( + &mut identifiers, + &config.buy_filter_expr, + prelude_declared_identifiers, + ); for expr in [ config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), @@ -12119,10 +12134,11 @@ impl Strategy for PlatformExprStrategy { .is_some(); if scheduled_rotation { self.executing_scheduled_rotation = true; - let rotation = self.on_day(ctx); + let rotation = self.compute_day_decision(ctx); self.executing_scheduled_rotation = false; decision.merge_from(rotation?); } + self.attach_buy_denials(ctx, &mut decision)?; Ok(decision) } @@ -12171,12 +12187,53 @@ impl Strategy for PlatformExprStrategy { && self.config.explicit_action_schedule.is_none() && self.unscheduled_explicit_actions_are_due(ctx.decision_date) { - return self.explicit_action_decision(ctx); + let mut decision = self.explicit_action_decision(ctx)?; + self.attach_buy_denials(ctx, &mut decision)?; + return Ok(decision); } Ok(StrategyDecision::default()) } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + let mut decision = self.compute_day_decision(ctx)?; + self.attach_buy_denials(ctx, &mut decision)?; + Ok(decision) + } +} + +impl PlatformExprStrategy { + fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> { + if self.config.buy_filter_expr.trim().is_empty() { + return Ok(()); + } + let symbols = decision.potential_buy_symbols(ctx.open_orders); + if symbols.is_empty() { + return Ok(()); + } + let day = self.day_state(ctx, ctx.decision_date)?; + let (market_date, _, factor_date) = self.selection_dates(ctx); + let needs_quote = Self::stock_filter_quote_usage_for_expr(&Self::normalize_expr(&self.config.buy_filter_expr)) + != StockFilterQuoteUsage::DailyOnly; + for symbol in symbols { + if needs_quote && self.uses_intraday_execution_quotes() && !ctx.is_lagged_execution() + && self.scheduled_quote(ctx, market_date, &symbol).is_none() + { + return Err(BacktestError::Execution(format!( + "buy condition quote unavailable: symbol={symbol} decision_date={}", ctx.decision_date, + ))); + } + let stock = self.stock_state_with_factor_date(ctx, market_date, factor_date, &symbol)?; + if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? { + decision.buy_denials.insert(symbol, format!( + "strategy_buy_condition_false decision_date={} expression={}", + ctx.decision_date, self.config.buy_filter_expr, + )); + } + } + Ok(()) + } + + fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result { if self.config.rotation_enabled && self .config @@ -13955,6 +14012,67 @@ mod tests { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } + #[test] + fn buy_filter_attaches_denials_without_rewriting_selection() { + let prev = d(2025, 1, 2); + let curr = d(2025, 1, 3); + let symbol = "000001.SZ"; + let mut parts = single_symbol_platform_data(&[prev, curr], symbol).snapshot_components(); + for row in &mut parts.factors { row.extra_factors.insert("entry_gate".into(), 0.0); } + let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap(); + let portfolio = PortfolioState::new(30_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: curr, decision_date: curr, decision_index: 1, data: &data, + portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None, + subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, order_events: &[], fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = symbol.to_string(); + cfg.max_positions = 1; + cfg.refresh_rate = 1; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "1".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.buy_filter_expr = "entry_gate > 0".to_string(); + cfg.current_day_precomputed_factors = true; + let mut strategy = PlatformExprStrategy::new(cfg); + let decision = strategy.on_day(&ctx).unwrap(); + assert!(!decision.order_intents.is_empty()); + assert!(decision.buy_denials.contains_key(symbol)); + assert!(strategy.stock_extra_factor_identifiers.contains("entry_gate")); + assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); + } + + #[test] + fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() { + let date = d(2025, 1, 2); + let symbol = "000001.SZ"; + let data = single_symbol_platform_data(&[date], symbol); + let portfolio = PortfolioState::new(30_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, decision_date: date, decision_index: 0, data: &data, + portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None, + subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, order_events: &[], fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::generic(); + cfg.signal_symbol = symbol.to_string(); + cfg.buy_filter_expr = "last > 0".to_string(); + cfg.intraday_execution_time = NaiveTime::from_hms_opt(10, 18, 0); + let strategy = PlatformExprStrategy::new(cfg); + let mut decision = crate::StrategyDecision::default(); + decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "buy".to_string() }); + let error = strategy.attach_buy_denials(&ctx, &mut decision).unwrap_err(); + assert!(error.to_string().contains("buy condition quote unavailable"), "{error}"); + assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); + } + #[test] fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() { let prev = d(2025, 1, 2); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 9e2781d..00d76e2 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -977,6 +977,8 @@ pub struct StrategyExpressionOrderingConfig { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExpressionTradingConfig { + #[serde(default, alias = "buy_filter_expr")] + pub buy_filter_expr: Option, #[serde(default)] pub stage: Option, #[serde(default)] @@ -2289,6 +2291,9 @@ pub fn platform_expr_config_from_spec( } } if let Some(trading) = runtime_expr.trading.as_ref() { + if let Some(expr) = trading.buy_filter_expr.as_ref() { + cfg.buy_filter_expr = expr.clone(); + } if let Some(expr) = trading .refresh_rate_expr .as_ref() @@ -3127,6 +3132,18 @@ fn symbol_is_kcb(symbol: &str) -> bool { mod tests { use super::*; + #[test] + fn parses_buy_filter_as_a_separate_trading_condition() { + let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({ + "runtimeExpressions": { + "selection": {"stockFilterExpr": "close > 0"}, + "trading": {"buyFilterExpr": "gate > 0"} + } + })).unwrap(); + assert_eq!(cfg.stock_filter_expr, "close > 0"); + assert_eq!(cfg.buy_filter_expr, "gate > 0"); + } + #[test] fn native_factor_bindings_declare_completed_session_fields() { let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [ diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 68cc98f..fb76b14 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -988,6 +988,18 @@ pub struct StrategyDecision { } impl StrategyDecision { + pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet { + let mut symbols = BTreeSet::new(); + if self.rebalance { + symbols.extend(self.target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone())); + } + for intent in &self.order_intents { + intent.collect_potential_buy_symbols(open_orders, &mut symbols); + } + symbols.retain(|symbol| !symbol.trim().is_empty()); + symbols + } + pub fn merge_from(&mut self, mut other: StrategyDecision) { self.buy_denials.append(&mut other.buy_denials); self.rebalance |= other.rebalance; @@ -1217,6 +1229,42 @@ pub enum OrderIntent { } impl OrderIntent { + fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet) { + match self.unwrapped() { + Self::Shares { symbol, quantity, .. } | Self::LimitShares { symbol, quantity, .. } if *quantity > 0 => { symbols.insert(symbol.clone()); } + Self::Lots { symbol, lots, .. } | Self::LimitLots { symbol, lots, .. } if *lots > 0 => { symbols.insert(symbol.clone()); } + Self::TargetShares { symbol, target_quantity, .. } | Self::LimitTargetShares { symbol, target_quantity, .. } if *target_quantity > 0 => { symbols.insert(symbol.clone()); } + Self::Value { symbol, value, .. } | Self::LimitValue { symbol, value, .. } | Self::AlgoValue { symbol, value, .. } if *value > 0.0 => { symbols.insert(symbol.clone()); } + Self::Percent { symbol, percent, .. } | Self::LimitPercent { symbol, percent, .. } | Self::AlgoPercent { symbol, percent, .. } if *percent > 0.0 => { symbols.insert(symbol.clone()); } + Self::TargetValue { symbol, target_value, .. } | Self::LimitTargetValue { symbol, target_value, .. } | Self::TimedTargetValue { symbol, target_value, .. } if *target_value > 0.0 => { symbols.insert(symbol.clone()); } + Self::TargetPercent { symbol, target_percent, .. } | Self::LimitTargetPercent { symbol, target_percent, .. } if *target_percent > 0.0 => { symbols.insert(symbol.clone()); } + Self::TargetPortfolioSmart { target_weights, .. } => { + symbols.extend(target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone())); + } + Self::ModifyOrder { order_id, new_total_quantity, new_limit_price, .. } => { + if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id) + && order.side == OrderSide::Buy + && (new_total_quantity.is_some_and(|value| value > order.requested_quantity) + || new_limit_price.is_some_and(|value| value > order.limit_price)) + { + symbols.insert(order.symbol.clone()); + } + } + Self::Shares { .. } | Self::LimitShares { .. } + | Self::Lots { .. } | Self::LimitLots { .. } + | Self::TargetShares { .. } | Self::LimitTargetShares { .. } + | Self::Value { .. } | Self::LimitValue { .. } | Self::AlgoValue { .. } + | Self::Percent { .. } | Self::LimitPercent { .. } | Self::AlgoPercent { .. } + | Self::TargetValue { .. } | Self::LimitTargetValue { .. } | Self::TimedTargetValue { .. } + | Self::TargetPercent { .. } | Self::LimitTargetPercent { .. } + | Self::CancelOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. } + | Self::UpdateUniverse { .. } | Self::Subscribe { .. } | Self::Unsubscribe { .. } + | Self::DepositWithdraw { .. } | Self::FinanceRepay { .. } | Self::SetManagementFeeRate { .. } + | Self::Futures { .. } => {} + Self::WithTimeInForce { .. } => unreachable!("intent is unwrapped"), + } + } + pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self { match self { Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {