fix(backtest): 停牌证券保留预算且不参与退出权重分配
This commit is contained in:
@@ -254,6 +254,10 @@ fn replenish_target_weight_bps(
|
|||||||
excluded_symbols: &BTreeSet<String>,
|
excluded_symbols: &BTreeSet<String>,
|
||||||
target_count: usize,
|
target_count: usize,
|
||||||
) -> Vec<(String, u32)> {
|
) -> 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 original_by_symbol = original_weights.iter().cloned().collect::<BTreeMap<_, _>>();
|
||||||
let mut active = original_weights
|
let mut active = original_weights
|
||||||
.iter()
|
.iter()
|
||||||
@@ -286,7 +290,7 @@ fn replenish_target_weight_bps(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let assigned = result.iter().map(|(_, weight)| *weight).sum::<u32>();
|
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 {
|
if missing == 0 {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -364,6 +368,7 @@ fn resolved_target_scales(
|
|||||||
excluded_symbols: &BTreeSet<String>,
|
excluded_symbols: &BTreeSet<String>,
|
||||||
target_count: usize,
|
target_count: usize,
|
||||||
redistribute_after_exit: bool,
|
redistribute_after_exit: bool,
|
||||||
|
unavailable_symbols: &BTreeSet<String>,
|
||||||
) -> Result<Vec<(String, f64)>, BacktestError> {
|
) -> Result<Vec<(String, f64)>, BacktestError> {
|
||||||
if !redistribute_after_exit {
|
if !redistribute_after_exit {
|
||||||
return Ok(replenish_target_scales(
|
return Ok(replenish_target_scales(
|
||||||
@@ -374,15 +379,25 @@ fn resolved_target_scales(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let original_weights = ordered_weight_bps_from_scales(original_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
|
let candidate_symbols = candidate_scales
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|(symbol, _)| !unavailable_symbols.contains(symbol))
|
||||||
.map(|(symbol, _)| symbol.clone())
|
.map(|(symbol, _)| symbol.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
Ok(replenish_target_weight_bps(
|
Ok(replenish_target_weight_bps(
|
||||||
&original_weights,
|
&available_weights,
|
||||||
&candidate_symbols,
|
&candidate_symbols,
|
||||||
excluded_symbols,
|
excluded_symbols,
|
||||||
target_count,
|
target_count.saturating_sub(reserved_slots),
|
||||||
)
|
)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(symbol, weight_bps)| {
|
.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);}}
|
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> {
|
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() {
|
if self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some() {
|
||||||
return Ok(StrategyDecision::default());
|
return Ok(StrategyDecision::default());
|
||||||
@@ -13387,12 +13423,16 @@ impl PlatformExprStrategy {
|
|||||||
} else {
|
} else {
|
||||||
(Vec::new(), Vec::new())
|
(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(
|
let target_portfolio_scales = resolved_target_scales(
|
||||||
&original_target_scales,
|
&original_target_scales,
|
||||||
&candidate_target_scales,
|
&candidate_target_scales,
|
||||||
&exit_symbols,
|
&exit_symbols,
|
||||||
selection_limit,
|
selection_limit,
|
||||||
self.config.redistribute_target_weights_after_exit,
|
self.config.redistribute_target_weights_after_exit,
|
||||||
|
"e_unavailable_symbols,
|
||||||
)?;
|
)?;
|
||||||
let target_portfolio_scale_by_symbol = target_portfolio_scales
|
let target_portfolio_scale_by_symbol = target_portfolio_scales
|
||||||
.iter()
|
.iter()
|
||||||
@@ -13984,6 +14024,7 @@ impl PlatformExprStrategy {
|
|||||||
&exit_symbols,
|
&exit_symbols,
|
||||||
daily_target_count,
|
daily_target_count,
|
||||||
self.config.redistribute_target_weights_after_exit,
|
self.config.redistribute_target_weights_after_exit,
|
||||||
|
"e_unavailable_symbols,
|
||||||
)?;
|
)?;
|
||||||
if daily_top_up_active && self.config.target_portfolio_daily_enabled {
|
if daily_top_up_active && self.config.target_portfolio_daily_enabled {
|
||||||
for (symbol, target_scale) in &daily_target_portfolio_scales {
|
for (symbol, target_scale) in &daily_target_portfolio_scales {
|
||||||
@@ -14736,14 +14777,14 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let excluded = BTreeSet::from(["B".to_string()]);
|
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");
|
.expect("preserved target scales");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
preserved,
|
preserved,
|
||||||
vec![("A".to_string(), 1.0008), ("C".to_string(), 0.9984)]
|
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");
|
.expect("redistributed target scales");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
redistributed,
|
redistributed,
|
||||||
@@ -14752,6 +14793,56 @@ mod tests {
|
|||||||
assert!((redistributed.iter().map(|(_, scale)| *scale).sum::<f64>() - 3.0).abs() < 1e-12);
|
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]
|
#[test]
|
||||||
fn model_target_lifecycle_removes_exits_and_appends_replacements() {
|
fn model_target_lifecycle_removes_exits_and_appends_replacements() {
|
||||||
let date = d(2025, 1, 3);
|
let date = d(2025, 1, 3);
|
||||||
|
|||||||
Reference in New Issue
Block a user