fix(backtest): 停牌证券保留预算且不参与退出权重分配

This commit is contained in:
boris
2026-09-11 22:50:27 +08:00
parent b6859a3360
commit 29eafc79e2
+96 -5
View File
@@ -254,6 +254,10 @@ fn replenish_target_weight_bps(
excluded_symbols: &BTreeSet<String>,
target_count: usize,
) -> Vec<(String, u32)> {
if target_count == 0 || original_weights.is_empty() {
return Vec::new();
}
let total_weight = original_weights.iter().map(|(_, weight)| *weight).sum::<u32>();
let original_by_symbol = original_weights.iter().cloned().collect::<BTreeMap<_, _>>();
let mut active = original_weights
.iter()
@@ -286,7 +290,7 @@ fn replenish_target_weight_bps(
})
.collect::<Vec<_>>();
let assigned = result.iter().map(|(_, weight)| *weight).sum::<u32>();
let missing = 10_000_u32.saturating_sub(assigned);
let missing = total_weight.saturating_sub(assigned);
if missing == 0 {
return result;
}
@@ -364,6 +368,7 @@ fn resolved_target_scales(
excluded_symbols: &BTreeSet<String>,
target_count: usize,
redistribute_after_exit: bool,
unavailable_symbols: &BTreeSet<String>,
) -> Result<Vec<(String, f64)>, BacktestError> {
if !redistribute_after_exit {
return Ok(replenish_target_scales(
@@ -374,15 +379,25 @@ fn resolved_target_scales(
));
}
let original_weights = ordered_weight_bps_from_scales(original_scales)?;
// 不可报价证券的原始预算及席位保留,不能将其预算重新归一到其他股票。
let reserved_slots = original_weights
.iter()
.filter(|(symbol, _)| unavailable_symbols.contains(symbol))
.count();
let available_weights = original_weights
.into_iter()
.filter(|(symbol, _)| !unavailable_symbols.contains(symbol))
.collect::<Vec<_>>();
let candidate_symbols = candidate_scales
.iter()
.filter(|(symbol, _)| !unavailable_symbols.contains(symbol))
.map(|(symbol, _)| symbol.clone())
.collect::<Vec<_>>();
Ok(replenish_target_weight_bps(
&original_weights,
&available_weights,
&candidate_symbols,
excluded_symbols,
target_count,
target_count.saturating_sub(reserved_slots),
)
.into_iter()
.map(|(symbol, weight_bps)| {
@@ -12663,6 +12678,27 @@ impl PlatformExprStrategy {
for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}}
}
fn target_quote_unavailable_symbols(
&self,
ctx: &StrategyContext<'_>,
original_scales: &[(String, f64)],
candidates: &[(String, f64)],
) -> Result<BTreeSet<String>, BacktestError> {
if ctx.is_lagged_execution() {
return Ok(BTreeSet::new());
}
let symbols = original_scales.iter().chain(candidates).map(|(symbol, _)| symbol)
.collect::<BTreeSet<_>>();
let mut unavailable = BTreeSet::new();
for symbol in symbols {
// 只用实际执行时点已知的停牌事实;未知行情仍由原有严格读取返回错误。
if self.stock_state(ctx, ctx.execution_date, symbol)?.paused {
unavailable.insert(symbol.clone());
}
}
Ok(unavailable)
}
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
if self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some() {
return Ok(StrategyDecision::default());
@@ -13387,12 +13423,16 @@ impl PlatformExprStrategy {
} else {
(Vec::new(), Vec::new())
};
let quote_unavailable_symbols = self.target_quote_unavailable_symbols(
ctx, &original_target_scales, &candidate_target_scales,
)?;
let target_portfolio_scales = resolved_target_scales(
&original_target_scales,
&candidate_target_scales,
&exit_symbols,
selection_limit,
self.config.redistribute_target_weights_after_exit,
&quote_unavailable_symbols,
)?;
let target_portfolio_scale_by_symbol = target_portfolio_scales
.iter()
@@ -13984,6 +14024,7 @@ impl PlatformExprStrategy {
&exit_symbols,
daily_target_count,
self.config.redistribute_target_weights_after_exit,
&quote_unavailable_symbols,
)?;
if daily_top_up_active && self.config.target_portfolio_daily_enabled {
for (symbol, target_scale) in &daily_target_portfolio_scales {
@@ -14736,14 +14777,14 @@ mod tests {
];
let excluded = BTreeSet::from(["B".to_string()]);
let preserved = resolved_target_scales(&original, &original, &excluded, 3, false)
let preserved = resolved_target_scales(&original, &original, &excluded, 3, false, &BTreeSet::new())
.expect("preserved target scales");
assert_eq!(
preserved,
vec![("A".to_string(), 1.0008), ("C".to_string(), 0.9984)]
);
let redistributed = resolved_target_scales(&original, &original, &excluded, 3, true)
let redistributed = resolved_target_scales(&original, &original, &excluded, 3, true, &BTreeSet::new())
.expect("redistributed target scales");
assert_eq!(
redistributed,
@@ -14752,6 +14793,56 @@ mod tests {
assert!((redistributed.iter().map(|(_, scale)| *scale).sum::<f64>() - 3.0).abs() < 1e-12);
}
#[test]
fn stock_pool_suspension_keeps_budget_and_exit_redistributes_only_available_weights() {
let original = (0..24).map(|i| (format!("S{i:02}"), if i < 16 { 1.0008 } else { 0.9984 })).collect::<Vec<_>>();
let excluded = BTreeSet::from(["S02".to_string()]);
let paused = BTreeSet::from(["S15".to_string()]);
let resolved = resolved_target_scales(&original, &original, &excluded, 24, true, &paused).unwrap();
let weights = resolved.iter().map(|(s, scale)| (s.clone(), (scale / 24.0 * 10000.0).round() as u32)).collect::<BTreeMap<_, _>>();
assert_eq!(weights.len(), 22);
assert_eq!(weights.values().sum::<u32>(), 9583);
assert!(!weights.contains_key("S15"));
assert!(!weights.contains_key("S02"));
assert_eq!(weights["S11"], 436);
assert_eq!(weights["S14"], 436);
assert_eq!(weights["S22"], 435);
assert_eq!(weights["S23"], 434);
let candidates = [("A".to_string(), 1.2), ("PAUSED".to_string(), 0.9),
("EXIT".to_string(), 0.9), ("NEW".to_string(), 0.9)];
let replacement = resolved_target_scales(&candidates[..3], &candidates,
&BTreeSet::from(["EXIT".to_string()]), 3, true,
&BTreeSet::from(["PAUSED".to_string()])).unwrap();
assert_eq!(replacement, vec![("A".to_string(), 1.2), ("NEW".to_string(), 0.9)]);
let all_paused = original.iter().map(|(s, _)| s.clone()).collect::<BTreeSet<_>>();
assert!(resolved_target_scales(&original, &original, &BTreeSet::new(), 24, true, &all_paused).unwrap().is_empty());
}
#[test]
fn stock_pool_suspension_resolution_never_reads_the_future_execution_day() {
let date = d(2025, 9, 29);
let symbol = "000001.SZ";
let mut parts = single_symbol_platform_data(&[date], symbol).snapshot_components();
parts.market[0].paused = true;
let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
parts.factors, parts.candidates, parts.benchmarks, Vec::new(), Vec::new()).unwrap();
let portfolio = PortfolioState::new(100_000.0);
let subscriptions = BTreeSet::new();
let mut 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 strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::generic());
let targets = [(symbol.to_string(), 1.0)];
assert_eq!(strategy.target_quote_unavailable_symbols(&ctx, &targets, &targets).unwrap(),
BTreeSet::from([symbol.to_string()]));
ctx.execution_date = d(2025, 9, 30);
assert!(strategy.target_quote_unavailable_symbols(&ctx, &targets, &targets).unwrap().is_empty());
}
#[test]
fn model_target_lifecycle_removes_exits_and_appends_replacements() {
let date = d(2025, 1, 3);