fix(backtest): 停牌证券保留预算且不参与退出权重分配
This commit is contained in:
@@ -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,
|
||||
"e_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,
|
||||
"e_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);
|
||||
|
||||
Reference in New Issue
Block a user