保留策略目标资金比例

This commit is contained in:
boris
2026-09-07 04:19:15 +08:00
parent 78e872b609
commit ef24402747
+144 -164
View File
@@ -178,9 +178,7 @@ impl PlatformPortfolioDrawdownController {
} }
} }
fn ordered_weight_bps_from_scales( fn validated_target_scales(scales: &[(String, f64)]) -> Result<Vec<(String, f64)>, BacktestError> {
scales: &[(String, f64)],
) -> Result<Vec<(String, u32)>, BacktestError> {
if scales.is_empty() { if scales.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -192,105 +190,56 @@ fn ordered_weight_bps_from_scales(
"target portfolio buy scale must be finite and non-negative".to_string(), "target portfolio buy scale must be finite and non-negative".to_string(),
)); ));
} }
let scale_total = scales.iter().map(|(_, scale)| *scale).sum::<f64>(); Ok(scales
if !scale_total.is_finite() || scale_total <= 0.0 {
return Err(BacktestError::Execution(
"target portfolio buy scale total must be positive".to_string(),
));
}
let mut weights = scales
.iter() .iter()
.map(|(symbol, scale)| { .filter(|(_, scale)| *scale > 0.0)
( .cloned()
symbol.clone(), .collect())
((*scale / scale_total * 10_000.0) + 1e-9).floor() as u32,
)
})
.collect::<Vec<_>>();
let assigned = weights.iter().map(|(_, weight)| *weight).sum::<u32>();
let remainder = 10_000_u32.saturating_sub(assigned);
let weight_count = weights.len();
for index in 0..remainder as usize {
weights[index % weight_count].1 += 1;
}
Ok(weights)
} }
fn replenish_target_weight_bps( fn replenish_target_scales(
original_weights: &[(String, u32)], original_scales: &[(String, f64)],
candidate_symbols: &[String], candidate_scales: &[(String, f64)],
excluded_symbols: &BTreeSet<String>, excluded_symbols: &BTreeSet<String>,
target_count: usize, target_count: usize,
) -> Vec<(String, u32)> { ) -> Vec<(String, f64)> {
let original_by_symbol = original_weights.iter().cloned().collect::<BTreeMap<_, _>>(); if target_count == 0 {
let mut active = original_weights
.iter()
.filter(|(symbol, weight)| *weight > 0 && !excluded_symbols.contains(symbol))
.map(|(symbol, _)| symbol.clone())
.collect::<Vec<_>>();
let mut seen = active.iter().cloned().collect::<BTreeSet<_>>();
let mut promoted = Vec::new();
for symbol in candidate_symbols {
if active.len() >= target_count {
break;
}
if excluded_symbols.contains(symbol) || !seen.insert(symbol.clone()) {
continue;
}
active.push(symbol.clone());
promoted.push(symbol.clone());
}
if active.is_empty() {
return Vec::new(); return Vec::new();
} }
let mut result = active let mut active = Vec::with_capacity(target_count);
.iter() let mut seen = BTreeSet::new();
.map(|symbol| { for (symbol, scale) in original_scales {
( if active.len() >= target_count {
symbol.clone(), break;
*original_by_symbol.get(symbol).unwrap_or(&0), }
) if *scale <= 0.0 || excluded_symbols.contains(symbol) || !seen.insert(symbol.clone()) {
}) continue;
.collect::<Vec<_>>(); }
let assigned = result.iter().map(|(_, weight)| *weight).sum::<u32>(); active.push((symbol.clone(), *scale));
let missing = 10_000_u32.saturating_sub(assigned); }
if missing == 0 { for (symbol, scale) in candidate_scales {
return result; if active.len() >= target_count {
break;
}
if *scale <= 0.0 || excluded_symbols.contains(symbol) || !seen.insert(symbol.clone()) {
continue;
}
active.push((symbol.clone(), *scale));
} }
let recipients = if promoted.is_empty() {
active active
} else { }
promoted
}; fn target_value_for_scale(
let base_total = recipients total_value: f64,
.iter() exposure: f64,
.map(|symbol| *original_by_symbol.get(symbol).unwrap_or(&0)) selection_limit: usize,
.sum::<u32>(); scale: f64,
let mut allocated = recipients ) -> f64 {
.iter() if selection_limit == 0 {
.map(|symbol| { return 0.0;
if base_total > 0 {
(u64::from(missing) * u64::from(*original_by_symbol.get(symbol).unwrap_or(&0))
/ u64::from(base_total)) as u32
} else {
missing / recipients.len() as u32
} }
}) total_value * exposure / selection_limit as f64 * scale
.collect::<Vec<_>>();
let allocated_total = allocated.iter().sum::<u32>();
let recipient_count = allocated.len();
for index in 0..missing.saturating_sub(allocated_total) as usize {
allocated[index % recipient_count] += 1;
}
let additions = recipients
.into_iter()
.zip(allocated)
.collect::<BTreeMap<_, _>>();
for (symbol, weight) in &mut result {
*weight += additions.get(symbol).copied().unwrap_or(0);
}
result
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -12536,9 +12485,8 @@ impl Strategy for PlatformExprStrategy {
} }
let stop_take_exit_signal_symbols = current_stop_take_exit_symbols.clone(); let stop_take_exit_signal_symbols = current_stop_take_exit_symbols.clone();
let original_target_weights = let (original_target_scales, candidate_target_scales) =
if self.config.target_portfolio_daily_enabled && selection_limit > 0 { if self.config.target_portfolio_daily_enabled && selection_limit > 0 {
let mut scales = Vec::new();
let original_target_symbols = self let original_target_symbols = self
.last_target_order .last_target_order
.as_ref() .as_ref()
@@ -12551,6 +12499,7 @@ impl Strategy for PlatformExprStrategy {
.map(|symbols| symbols.iter().cloned().collect()) .map(|symbols| symbols.iter().cloned().collect())
}) })
.unwrap_or_else(|| stock_list.iter().take(selection_limit).cloned().collect()); .unwrap_or_else(|| stock_list.iter().take(selection_limit).cloned().collect());
let mut original_scales = Vec::new();
for symbol in original_target_symbols.iter().take(selection_limit) { for symbol in original_target_symbols.iter().take(selection_limit) {
let decision_stock = self.stock_state_with_factor_date( let decision_stock = self.stock_state_with_factor_date(
ctx, ctx,
@@ -12558,19 +12507,34 @@ impl Strategy for PlatformExprStrategy {
selection_factor_date, selection_factor_date,
symbol, symbol,
)?; )?;
scales.push((symbol.clone(), self.buy_scale(ctx, &day, &decision_stock)?)); original_scales
.push((symbol.clone(), self.buy_scale(ctx, &day, &decision_stock)?));
} }
ordered_weight_bps_from_scales(&scales)? let mut candidate_scales = Vec::new();
for symbol in &stock_list {
let decision_stock = self.stock_state_with_factor_date(
ctx,
decision_date,
selection_factor_date,
symbol,
)?;
candidate_scales
.push((symbol.clone(), self.buy_scale(ctx, &day, &decision_stock)?));
}
(
validated_target_scales(&original_scales)?,
validated_target_scales(&candidate_scales)?,
)
} else { } else {
Vec::new() (Vec::new(), Vec::new())
}; };
let target_portfolio_weights = replenish_target_weight_bps( let target_portfolio_scales = replenish_target_scales(
&original_target_weights, &original_target_scales,
&stock_list, &candidate_target_scales,
&exit_symbols, &exit_symbols,
selection_limit, selection_limit,
); );
let target_portfolio_weight_bps = target_portfolio_weights let target_portfolio_scale_by_symbol = target_portfolio_scales
.iter() .iter()
.cloned() .cloned()
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
@@ -12614,18 +12578,15 @@ impl Strategy for PlatformExprStrategy {
)?; )?;
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
let target_value = if self.config.target_portfolio_daily_enabled { let target_value = if self.config.target_portfolio_daily_enabled {
target_portfolio_weight_bps let target_scale = target_portfolio_scale_by_symbol
.get(&position.symbol) .get(&position.symbol)
.map(|weight_bps| { .copied()
strategy_visible_total_value .unwrap_or(stock_scale);
* trading_ratio target_value_for_scale(
* f64::from(*weight_bps) strategy_visible_total_value,
/ 10_000.0 trading_ratio,
}) selection_limit,
.unwrap_or( target_scale,
strategy_visible_total_value * trading_ratio
/ selection_limit as f64
* stock_scale,
) )
} else { } else {
strategy_visible_total_value * trading_ratio / selection_limit as f64 strategy_visible_total_value * trading_ratio / selection_limit as f64
@@ -12730,18 +12691,15 @@ impl Strategy for PlatformExprStrategy {
)?; )?;
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
let target_value = if self.config.target_portfolio_daily_enabled { let target_value = if self.config.target_portfolio_daily_enabled {
target_portfolio_weight_bps let target_scale = target_portfolio_scale_by_symbol
.get(&symbol) .get(&symbol)
.map(|weight_bps| { .copied()
strategy_visible_total_value .unwrap_or(stock_scale);
* trading_ratio target_value_for_scale(
* f64::from(*weight_bps) strategy_visible_total_value,
/ 10_000.0 trading_ratio,
}) selection_limit,
.unwrap_or( target_scale,
strategy_visible_total_value * trading_ratio
/ selection_limit as f64
* stock_scale,
) )
} else { } else {
strategy_visible_total_value * trading_ratio / selection_limit as f64 strategy_visible_total_value * trading_ratio / selection_limit as f64
@@ -13148,17 +13106,20 @@ impl Strategy for PlatformExprStrategy {
} }
} }
let daily_target_portfolio_weights = replenish_target_weight_bps( let daily_target_portfolio_scales = replenish_target_scales(
&original_target_weights, &original_target_scales,
&stock_list, &candidate_target_scales,
&same_day_sold_symbols, &same_day_sold_symbols,
selection_limit, selection_limit,
); );
if daily_top_up_active && self.config.target_portfolio_daily_enabled { if daily_top_up_active && self.config.target_portfolio_daily_enabled {
for (symbol, weight_bps) in &daily_target_portfolio_weights { for (symbol, target_scale) in &daily_target_portfolio_scales {
let target_value = let target_value = target_value_for_scale(
strategy_visible_total_value * trading_ratio * f64::from(*weight_bps) strategy_visible_total_value,
/ 10_000.0; trading_ratio,
selection_limit,
*target_scale,
);
if !target_value.is_finite() || target_value <= 0.0 { if !target_value.is_finite() || target_value <= 0.0 {
continue; continue;
} }
@@ -13262,7 +13223,6 @@ impl Strategy for PlatformExprStrategy {
} }
let target_budget = strategy_visible_total_value * trading_ratio; let target_budget = strategy_visible_total_value * trading_ratio;
let fixed_buy_cash = target_budget / selection_limit as f64;
let mut rebalance_working_symbols = slot_working_symbols.clone(); let mut rebalance_working_symbols = slot_working_symbols.clone();
if self.config.release_slot_on_exit_signal { if self.config.release_slot_on_exit_signal {
for symbol in &same_day_sold_symbols { for symbol in &same_day_sold_symbols {
@@ -13293,10 +13253,12 @@ impl Strategy for PlatformExprStrategy {
if !rebalance_existing_positions { if !rebalance_existing_positions {
continue; continue;
} }
let target_value = target_portfolio_weight_bps let target_scale = target_portfolio_scale_by_symbol
.get(symbol) .get(symbol)
.map(|weight_bps| target_budget * f64::from(*weight_bps) / 10_000.0) .copied()
.unwrap_or(target_budget / selection_limit as f64 * stock_scale); .unwrap_or(stock_scale);
let target_value =
target_value_for_scale(target_budget, 1.0, selection_limit, target_scale);
let before_qty = projected let before_qty = projected
.position(symbol) .position(symbol)
.map(|position| position.quantity) .map(|position| position.quantity)
@@ -13348,10 +13310,12 @@ impl Strategy for PlatformExprStrategy {
{ {
continue; continue;
} }
let target_value = target_portfolio_weight_bps let target_scale = target_portfolio_scale_by_symbol
.get(symbol) .get(symbol)
.map(|weight_bps| target_budget * f64::from(*weight_bps) / 10_000.0) .copied()
.unwrap_or(fixed_buy_cash * stock_scale); .unwrap_or(stock_scale);
let target_value =
target_value_for_scale(target_budget, 1.0, selection_limit, target_scale);
if target_value <= 0.0 { if target_value <= 0.0 {
continue; continue;
} }
@@ -13582,8 +13546,8 @@ mod tests {
PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral, PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral,
StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements, StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements,
framework_stock_rolling_factor_requirement, ordered_weight_bps_from_scales, framework_stock_rolling_factor_requirement, replenish_target_scales,
replenish_target_weight_bps, scheduled_position_exposure, scheduled_position_exposure, target_value_for_scale, validated_target_scales,
}; };
use crate::{ use crate::{
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
@@ -13600,36 +13564,52 @@ mod tests {
} }
#[test] #[test]
fn target_weight_replenishment_matches_integer_bps_pool_contract() { fn target_scale_replenishment_preserves_strategy_cash_allocation() {
let scales = (0..24) let scale = 30.0 / 31.0;
.map(|index| { let original_scales = (0..12)
( .map(|index| (format!("S{index:02}"), scale))
format!("S{index:02}"),
if index < 16 { 1.0008 } else { 0.9984 },
)
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let original = ordered_weight_bps_from_scales(&scales).expect("weights"); let candidate_scales = (0..20)
.map(|index| (format!("S{index:02}"), scale))
.collect::<Vec<_>>();
let original = validated_target_scales(&original_scales).expect("valid scales");
let candidates = validated_target_scales(&candidate_scales).expect("valid candidates");
let per_symbol = target_value_for_scale(10_000_000.0, 0.2, 30, scale);
assert!((per_symbol - 64_516.129_032_258_07).abs() < 1e-9);
assert!((per_symbol * 12.0 - 774_193.548_387_096_8).abs() < 1e-8);
let excluded = BTreeSet::from(["S01".to_string()]); let excluded = BTreeSet::from(["S01".to_string()]);
let candidates = scales let replenished = replenish_target_scales(&original, &candidates, &excluded, 12);
.iter()
.map(|(symbol, _)| symbol.clone())
.collect::<Vec<_>>();
let replenished = replenish_target_weight_bps(&original, &candidates, &excluded, 24);
let by_symbol = replenished.iter().cloned().collect::<BTreeMap<_, _>>(); let by_symbol = replenished.iter().cloned().collect::<BTreeMap<_, _>>();
assert_eq!(replenished.len(), 12);
assert_eq!(replenished.len(), 23);
assert_eq!(
replenished.iter().map(|(_, weight)| *weight).sum::<u32>(),
10_000
);
assert_eq!(by_symbol.get("S00"), Some(&436));
assert_eq!(by_symbol.get("S02"), Some(&436));
assert_eq!(by_symbol.get("S03"), Some(&436));
assert_eq!(by_symbol.get("S04"), Some(&435));
assert_eq!(by_symbol.get("S16"), Some(&434));
assert!(!by_symbol.contains_key("S01")); assert!(!by_symbol.contains_key("S01"));
assert_eq!(by_symbol.get("S12"), Some(&scale));
assert!(replenished.iter().all(|(_, value)| *value == scale));
assert!(
(replenished.iter().map(|(_, value)| *value).sum::<f64>() - 12.0 * scale).abs() < 1e-12
);
let without_replacement = replenish_target_scales(&original, &original, &excluded, 12);
assert_eq!(without_replacement.len(), 11);
assert!(
(without_replacement
.iter()
.map(|(_, value)| *value)
.sum::<f64>()
- 11.0 * scale)
.abs()
< 1e-12
);
assert!(replenish_target_scales(&original, &candidates, &BTreeSet::new(), 0).is_empty());
assert!(validated_target_scales(&[("BAD".to_string(), f64::NAN)]).is_err());
assert!(validated_target_scales(&[("BAD".to_string(), -0.1)]).is_err());
assert!(
validated_target_scales(&[("ZERO".to_string(), 0.0)])
.expect("zero scale is valid cash retention")
.is_empty()
);
} }
fn single_symbol_platform_data(dates: &[NaiveDate], symbol: &str) -> DataSet { fn single_symbol_platform_data(dates: &[NaiveDate], symbol: &str) -> DataSet {