fix: preserve frozen stock pool candidate order through execution

This commit is contained in:
boris
2026-09-11 12:14:33 +08:00
parent 6eaa06c1d6
commit c0b78846d6
3 changed files with 81 additions and 6 deletions
+41 -5
View File
@@ -675,6 +675,9 @@ pub struct PlatformExprStrategyConfig {
pub current_day_precomputed_factors: bool,
pub completed_session_factor_fields: BTreeSet<String>,
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
/// 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<NaiveDate, BTreeMap<String, usize>>,
pub intraday_execution_time: Option<NaiveTime>,
pub session_event_times: Vec<NaiveTime>,
pub explicit_action_times: Vec<NaiveTime>,
@@ -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]
+30 -1
View File
@@ -912,6 +912,8 @@ pub struct StrategyExpressionSelectionConfig {
pub current_day_precomputed_factors: Option<bool>,
#[serde(default, alias = "candidate_symbols_by_date")]
pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>,
#[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!({
@@ -0,0 +1,10 @@
# 股票池候选顺序合同
新请求可显式设置 `runtimeExpressions.selection.preserveCandidateOrder=true`,同一 `candidateSymbolsByDate` 同时冻结成员和顺序。原有未设置该标志的策略保留成员过滤后自行排名的语义,不改写历史回测。
- 顺序在解析时保留,重复证券仍报错;空日期保持空,不继承旧候选。
- 不再走市值快排或套用旧 rank 方向。选股风控和股票条件仍在 Top N 前执行,被排除后从后续已冻结候选补位。
- 该标志必须绑定非空的日期映射,不允许空映射放开全市场。
- 股票池完成日线筛选的新前端请求采用 next_bar_open,日线信号日与真实执行日分离。
本轮共享内核全量回归 668 项通过(8 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。