diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 9048139..7826c49 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -675,6 +675,9 @@ pub struct PlatformExprStrategyConfig { pub current_day_precomputed_factors: bool, pub completed_session_factor_fields: BTreeSet, pub candidate_symbols_by_date: BTreeMap>, + /// Explicit frozen candidate order, independent of the strategy's legacy + /// rank expression. Membership-only books keep their original ranking. + pub candidate_order_by_date: BTreeMap>, pub intraday_execution_time: Option, pub session_event_times: Vec, pub explicit_action_times: Vec, @@ -759,6 +762,7 @@ impl PlatformExprStrategyConfig { current_day_precomputed_factors: false, completed_session_factor_fields: BTreeSet::new(), candidate_symbols_by_date: BTreeMap::new(), + candidate_order_by_date: BTreeMap::new(), intraday_execution_time: None, session_event_times: Vec::new(), explicit_action_times: Vec::new(), @@ -10660,7 +10664,8 @@ impl PlatformExprStrategy { } fn rank_reuses_market_cap_order(&self) -> bool { - !self.rank_expr_present + self.config.candidate_order_by_date.is_empty() + && !self.rank_expr_present && !self.config.rank_desc && matches!(self.config.rank_by.trim(), "market_cap" | "market_cap_bn") } @@ -11046,8 +11051,13 @@ impl PlatformExprStrategy { if field_value < band_low || field_value > band_high { continue; } - let rank_value = - self.rank_value_from_caps(ctx, day, market_cap_bn, free_float_cap_bn, &stock)?; + let rank_value = if let Some(order) = self.config.candidate_order_by_date.get(&date) { + *order.get(symbol).ok_or_else(|| BacktestError::Execution(format!( + "frozen candidate order is missing {date}/{symbol}" + )))? as f64 + } else { + self.rank_value_from_caps(ctx, day, market_cap_bn, free_float_cap_bn, &stock)? + }; if !rank_value.is_finite() { // Model-score artifacts intentionally contain only the PIT-eligible // ranked universe. Do not report a missing score for a symbol that @@ -11094,7 +11104,7 @@ impl PlatformExprStrategy { candidates.sort_by(|lhs, rhs| { let lhs_value = lhs.1; let rhs_value = rhs.1; - let ordering = if self.config.rank_desc { + let ordering = if self.config.rank_desc && self.config.candidate_order_by_date.is_empty() { rhs_value .partial_cmp(&lhs_value) .unwrap_or(std::cmp::Ordering::Equal) @@ -34054,7 +34064,7 @@ mod tests { filtered_cfg .candidate_symbols_by_date .insert(curr, BTreeSet::from(["300002.SZ".to_string()])); - let mut filtered_strategy = PlatformExprStrategy::new(filtered_cfg); + let mut filtered_strategy = PlatformExprStrategy::new(filtered_cfg.clone()); let filtered = filtered_strategy.on_day(&ctx).expect("filtered decision"); assert!( matches!( @@ -34065,6 +34075,32 @@ mod tests { filtered.order_intents, filtered.diagnostics ); + + // The published screen order can deliberately disagree with both code + // and market-cap order. The old rank direction must not reverse it. + for rank_desc in [false, true] { + let mut ordered_cfg = filtered_cfg.clone(); + ordered_cfg.rank_desc = rank_desc; + ordered_cfg.candidate_symbols_by_date.insert(curr, BTreeSet::from([ + "300001.SZ".to_string(), "300002.SZ".to_string(), + ])); + ordered_cfg.candidate_order_by_date.insert(curr, BTreeMap::from([ + ("300002.SZ".to_string(), 0), ("300001.SZ".to_string(), 1), + ])); + let mut ordered_strategy = PlatformExprStrategy::new(ordered_cfg.clone()); + let ordered = ordered_strategy.on_day(&ctx).expect("ordered decision"); + assert!(matches!(ordered.order_intents.first(), + Some(crate::strategy::OrderIntent::TargetValue { symbol, .. }) if symbol == "300002.SZ" + ), "{:?}", ordered); + + // Rejection before Top N advances to the next published candidate. + ordered_cfg.stock_filter_expr = "symbol != \"300002.SZ\"".to_string(); + let mut excluded = PlatformExprStrategy::new(ordered_cfg); + let decision = excluded.on_day(&ctx).expect("filtered ordered decision"); + assert!(matches!(decision.order_intents.first(), + Some(crate::strategy::OrderIntent::TargetValue { symbol, .. }) if symbol == "300001.SZ" + ), "{:?}", decision); + } } #[test] diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index a9616b4..87be193 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -912,6 +912,8 @@ pub struct StrategyExpressionSelectionConfig { pub current_day_precomputed_factors: Option, #[serde(default, alias = "candidate_symbols_by_date")] pub candidate_symbols_by_date: BTreeMap>, + #[serde(default, alias = "preserve_candidate_order")] + pub preserve_candidate_order: bool, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -2127,12 +2129,16 @@ pub fn platform_expr_config_from_spec( if let Some(enabled) = selection.current_day_precomputed_factors { cfg.current_day_precomputed_factors = enabled; } + if selection.preserve_candidate_order && selection.candidate_symbols_by_date.is_empty() { + return Err("preserveCandidateOrder requires a dated candidate book".to_string()); + } 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 mut order = BTreeMap::new(); + for (index, raw_symbol) in raw_symbols.iter().enumerate() { let symbol = normalize_symbol(raw_symbol, None); let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| { code.len() == 6 @@ -2149,8 +2155,12 @@ pub fn platform_expr_config_from_spec( "candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}" )); } + order.insert(symbol, index); } cfg.candidate_symbols_by_date.insert(trade_date, symbols); + if selection.preserve_candidate_order { + cfg.candidate_order_by_date.insert(trade_date, order); + } } } if let Some(allocation) = runtime_expr.allocation.as_ref() @@ -3329,6 +3339,25 @@ mod tests { ); } + #[test] + fn frozen_candidate_order_is_explicit_and_preserves_source_positions() { + let mut spec = serde_json::json!({"runtimeExpressions": {"selection": { + "candidateSymbolsByDate": { + "2025-01-02": ["600000.SH", "000001.SZ"], "2025-01-03": [] + } + }}}); + let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let legacy = platform_expr_config_from_value("", "", &spec).unwrap(); + assert!(legacy.candidate_order_by_date.is_empty()); + spec["runtimeExpressions"]["selection"]["preserveCandidateOrder"] = serde_json::json!(true); + let ordered = platform_expr_config_from_value("", "", &spec).unwrap(); + assert_eq!(ordered.candidate_order_by_date[&date]["600000.SH"], 0); + assert_eq!(ordered.candidate_order_by_date[&date]["000001.SZ"], 1); + assert!(ordered.candidate_order_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty()); + spec["runtimeExpressions"]["selection"]["candidateSymbolsByDate"] = serde_json::json!({}); + assert!(platform_expr_config_from_value("", "", &spec).unwrap_err().to_string().contains("dated candidate book")); + } + #[test] fn rejects_invalid_or_duplicate_static_universe_symbols() { let invalid = serde_json::json!({ diff --git a/docs/stock-pool-screen-execution-20260911.md b/docs/stock-pool-screen-execution-20260911.md new file mode 100644 index 0000000..2bb8e9e --- /dev/null +++ b/docs/stock-pool-screen-execution-20260911.md @@ -0,0 +1,10 @@ +# 股票池候选顺序合同 + +新请求可显式设置 `runtimeExpressions.selection.preserveCandidateOrder=true`,同一 `candidateSymbolsByDate` 同时冻结成员和顺序。原有未设置该标志的策略保留成员过滤后自行排名的语义,不改写历史回测。 + +- 顺序在解析时保留,重复证券仍报错;空日期保持空,不继承旧候选。 +- 不再走市值快排或套用旧 rank 方向。选股风控和股票条件仍在 Top N 前执行,被排除后从后续已冻结候选补位。 +- 该标志必须绑定非空的日期映射,不允许空映射放开全市场。 +- 股票池完成日线筛选的新前端请求采用 next_bar_open,日线信号日与真实执行日分离。 + +本轮共享内核全量回归 668 项通过(8 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。