fix: preserve frozen stock pool candidate order through execution
This commit is contained in:
@@ -675,6 +675,9 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub current_day_precomputed_factors: bool,
|
pub current_day_precomputed_factors: bool,
|
||||||
pub completed_session_factor_fields: BTreeSet<String>,
|
pub completed_session_factor_fields: BTreeSet<String>,
|
||||||
pub candidate_symbols_by_date: BTreeMap<NaiveDate, 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 intraday_execution_time: Option<NaiveTime>,
|
||||||
pub session_event_times: Vec<NaiveTime>,
|
pub session_event_times: Vec<NaiveTime>,
|
||||||
pub explicit_action_times: Vec<NaiveTime>,
|
pub explicit_action_times: Vec<NaiveTime>,
|
||||||
@@ -759,6 +762,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
current_day_precomputed_factors: false,
|
current_day_precomputed_factors: false,
|
||||||
completed_session_factor_fields: BTreeSet::new(),
|
completed_session_factor_fields: BTreeSet::new(),
|
||||||
candidate_symbols_by_date: BTreeMap::new(),
|
candidate_symbols_by_date: BTreeMap::new(),
|
||||||
|
candidate_order_by_date: BTreeMap::new(),
|
||||||
intraday_execution_time: None,
|
intraday_execution_time: None,
|
||||||
session_event_times: Vec::new(),
|
session_event_times: Vec::new(),
|
||||||
explicit_action_times: Vec::new(),
|
explicit_action_times: Vec::new(),
|
||||||
@@ -10660,7 +10664,8 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rank_reuses_market_cap_order(&self) -> bool {
|
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
|
&& !self.config.rank_desc
|
||||||
&& matches!(self.config.rank_by.trim(), "market_cap" | "market_cap_bn")
|
&& 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 {
|
if field_value < band_low || field_value > band_high {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let rank_value =
|
let rank_value = if let Some(order) = self.config.candidate_order_by_date.get(&date) {
|
||||||
self.rank_value_from_caps(ctx, day, market_cap_bn, free_float_cap_bn, &stock)?;
|
*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() {
|
if !rank_value.is_finite() {
|
||||||
// Model-score artifacts intentionally contain only the PIT-eligible
|
// Model-score artifacts intentionally contain only the PIT-eligible
|
||||||
// ranked universe. Do not report a missing score for a symbol that
|
// ranked universe. Do not report a missing score for a symbol that
|
||||||
@@ -11094,7 +11104,7 @@ impl PlatformExprStrategy {
|
|||||||
candidates.sort_by(|lhs, rhs| {
|
candidates.sort_by(|lhs, rhs| {
|
||||||
let lhs_value = lhs.1;
|
let lhs_value = lhs.1;
|
||||||
let rhs_value = rhs.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
|
rhs_value
|
||||||
.partial_cmp(&lhs_value)
|
.partial_cmp(&lhs_value)
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
@@ -34054,7 +34064,7 @@ mod tests {
|
|||||||
filtered_cfg
|
filtered_cfg
|
||||||
.candidate_symbols_by_date
|
.candidate_symbols_by_date
|
||||||
.insert(curr, BTreeSet::from(["300002.SZ".to_string()]));
|
.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");
|
let filtered = filtered_strategy.on_day(&ctx).expect("filtered decision");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
@@ -34065,6 +34075,32 @@ mod tests {
|
|||||||
filtered.order_intents,
|
filtered.order_intents,
|
||||||
filtered.diagnostics
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -912,6 +912,8 @@ pub struct StrategyExpressionSelectionConfig {
|
|||||||
pub current_day_precomputed_factors: Option<bool>,
|
pub current_day_precomputed_factors: Option<bool>,
|
||||||
#[serde(default, alias = "candidate_symbols_by_date")]
|
#[serde(default, alias = "candidate_symbols_by_date")]
|
||||||
pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>,
|
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)]
|
#[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 {
|
if let Some(enabled) = selection.current_day_precomputed_factors {
|
||||||
cfg.current_day_precomputed_factors = enabled;
|
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 {
|
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(|_| {
|
let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| {
|
||||||
format!("candidateSymbolsByDate contains invalid date: {raw_date}")
|
format!("candidateSymbolsByDate contains invalid date: {raw_date}")
|
||||||
})?;
|
})?;
|
||||||
let mut symbols = BTreeSet::new();
|
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 symbol = normalize_symbol(raw_symbol, None);
|
||||||
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
|
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
|
||||||
code.len() == 6
|
code.len() == 6
|
||||||
@@ -2149,8 +2155,12 @@ pub fn platform_expr_config_from_spec(
|
|||||||
"candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}"
|
"candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
order.insert(symbol, index);
|
||||||
}
|
}
|
||||||
cfg.candidate_symbols_by_date.insert(trade_date, symbols);
|
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()
|
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]
|
#[test]
|
||||||
fn rejects_invalid_or_duplicate_static_universe_symbols() {
|
fn rejects_invalid_or_duplicate_static_universe_symbols() {
|
||||||
let invalid = serde_json::json!({
|
let invalid = serde_json::json!({
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 股票池候选顺序合同
|
||||||
|
|
||||||
|
新请求可显式设置 `runtimeExpressions.selection.preserveCandidateOrder=true`,同一 `candidateSymbolsByDate` 同时冻结成员和顺序。原有未设置该标志的策略保留成员过滤后自行排名的语义,不改写历史回测。
|
||||||
|
|
||||||
|
- 顺序在解析时保留,重复证券仍报错;空日期保持空,不继承旧候选。
|
||||||
|
- 不再走市值快排或套用旧 rank 方向。选股风控和股票条件仍在 Top N 前执行,被排除后从后续已冻结候选补位。
|
||||||
|
- 该标志必须绑定非空的日期映射,不允许空映射放开全市场。
|
||||||
|
- 股票池完成日线筛选的新前端请求采用 next_bar_open,日线信号日与真实执行日分离。
|
||||||
|
|
||||||
|
本轮共享内核全量回归 668 项通过(8 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。
|
||||||
Reference in New Issue
Block a user