diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 8883704..2fd9a4a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -439,6 +439,7 @@ pub struct PlatformExprStrategyConfig { pub matching_type: MatchingType, pub quote_quantity_limit: bool, pub current_day_precomputed_factors: bool, + pub candidate_symbols_by_date: BTreeMap>, pub intraday_execution_time: Option, pub explicit_action_times: Vec, pub delayed_limit_open_exit_enabled: bool, @@ -513,6 +514,7 @@ impl PlatformExprStrategyConfig { matching_type: MatchingType::CurrentBarClose, quote_quantity_limit: true, current_day_precomputed_factors: false, + candidate_symbols_by_date: BTreeMap::new(), intraday_execution_time: None, explicit_action_times: Vec::new(), delayed_limit_open_exit_enabled: false, @@ -9590,6 +9592,15 @@ impl PlatformExprStrategy { let factor_symbol_ids = factor_day.factor_symbol_ids(); debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len()); for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) { + if !self.config.candidate_symbols_by_date.is_empty() + && !self + .config + .candidate_symbols_by_date + .get(&date) + .is_some_and(|symbols| symbols.contains(&factor.symbol)) + { + continue; + } if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) { continue; } @@ -31514,6 +31525,33 @@ mod tests { decision.order_intents, decision.diagnostics ); + + let mut filtered_cfg = PlatformExprStrategyConfig::microcap_rotation(); + filtered_cfg.signal_symbol = "000001.SZ".to_string(); + filtered_cfg.refresh_rate = 99; + filtered_cfg.max_positions = 1; + filtered_cfg.benchmark_short_ma_days = 1; + filtered_cfg.benchmark_long_ma_days = 1; + filtered_cfg.market_cap_lower_expr = "0".to_string(); + filtered_cfg.market_cap_upper_expr = "100".to_string(); + filtered_cfg.selection_limit_expr = "1".to_string(); + filtered_cfg.stock_filter_expr = "close > 0".to_string(); + filtered_cfg.current_day_precomputed_factors = true; + filtered_cfg.retry_empty_rebalance = true; + filtered_cfg + .candidate_symbols_by_date + .insert(curr, BTreeSet::from(["300002.SZ".to_string()])); + let mut filtered_strategy = PlatformExprStrategy::new(filtered_cfg); + let filtered = filtered_strategy.on_day(&ctx).expect("filtered decision"); + assert!( + matches!( + filtered.order_intents.first(), + Some(crate::strategy::OrderIntent::TargetValue { symbol, .. }) if symbol == "300002.SZ" + ), + "intents={:?} diagnostics={:?}", + filtered.order_intents, + filtered.diagnostics + ); } #[test] diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 340c7ad..0b749cc 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -892,6 +892,8 @@ pub struct StrategyExpressionSelectionConfig { pub stock_filter_expr: Option, #[serde(default, alias = "current_day_precomputed_factors")] pub current_day_precomputed_factors: Option, + #[serde(default, alias = "candidate_symbols_by_date")] + pub candidate_symbols_by_date: BTreeMap>, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -2040,6 +2042,31 @@ pub fn platform_expr_config_from_spec( if let Some(enabled) = selection.current_day_precomputed_factors { cfg.current_day_precomputed_factors = enabled; } + for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date { + let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| { + format!("candidateSymbolsByDate contains invalid date: {raw_date}") + })?; + let mut symbols = BTreeSet::new(); + for raw_symbol in raw_symbols { + let symbol = normalize_symbol(raw_symbol, None); + let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| { + code.len() == 6 + && code.bytes().all(|byte| byte.is_ascii_digit()) + && matches!(exchange, "SH" | "SZ" | "BJ") + }); + if !valid { + return Err(format!( + "candidateSymbolsByDate contains invalid stock symbol: {raw_symbol}" + )); + } + if !symbols.insert(symbol.clone()) { + return Err(format!( + "candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}" + )); + } + } + cfg.candidate_symbols_by_date.insert(trade_date, symbols); + } } if let Some(allocation) = runtime_expr.allocation.as_ref() && let Some(expr) = allocation @@ -3057,7 +3084,11 @@ mod tests { "marketCapLowerExpr": "3", "marketCapUpperExpr": "28", "stockFilterExpr": "stock_ma5 > stock_ma10", - "currentDayPrecomputedFactors": true + "currentDayPrecomputedFactors": true, + "candidateSymbolsByDate": { + "2025-01-02": ["600000.sh", "000001.SZ"], + "2025-01-03": [] + } }, "trading": { "refreshRateExpr": "year >= 2024 ? 5 : 20", @@ -3089,6 +3120,13 @@ mod tests { assert!(cfg.daily_top_up_enabled); assert!(cfg.retry_empty_rebalance); assert!(cfg.current_day_precomputed_factors); + assert_eq!( + cfg.candidate_symbols_by_date[&NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()], + BTreeSet::from(["000001.SZ".to_string(), "600000.SH".to_string()]) + ); + assert!( + cfg.candidate_symbols_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty() + ); assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1)); assert!(!cfg.calendar_rebalance_interval); assert_eq!(cfg.explicit_actions.len(), 1);