统一止盈退出后的目标权重重分配

This commit is contained in:
boris
2026-09-07 03:15:05 +08:00
parent b8e0d3bf4c
commit 3f14d9de54
+226 -10
View File
@@ -178,6 +178,121 @@ impl PlatformPortfolioDrawdownController {
} }
} }
fn ordered_weight_bps_from_scales(
scales: &[(String, f64)],
) -> Result<Vec<(String, u32)>, BacktestError> {
if scales.is_empty() {
return Ok(Vec::new());
}
if scales
.iter()
.any(|(_, scale)| !scale.is_finite() || *scale < 0.0)
{
return Err(BacktestError::Execution(
"target portfolio buy scale must be finite and non-negative".to_string(),
));
}
let scale_total = scales.iter().map(|(_, scale)| *scale).sum::<f64>();
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()
.map(|(symbol, scale)| {
(
symbol.clone(),
((*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(
original_weights: &[(String, u32)],
candidate_symbols: &[String],
excluded_symbols: &BTreeSet<String>,
target_count: usize,
) -> Vec<(String, u32)> {
let original_by_symbol = original_weights.iter().cloned().collect::<BTreeMap<_, _>>();
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();
}
let mut result = active
.iter()
.map(|symbol| {
(
symbol.clone(),
*original_by_symbol.get(symbol).unwrap_or(&0),
)
})
.collect::<Vec<_>>();
let assigned = result.iter().map(|(_, weight)| *weight).sum::<u32>();
let missing = 10_000_u32.saturating_sub(assigned);
if missing == 0 {
return result;
}
let recipients = if promoted.is_empty() {
active
} else {
promoted
};
let base_total = recipients
.iter()
.map(|symbol| *original_by_symbol.get(symbol).unwrap_or(&0))
.sum::<u32>();
let mut allocated = recipients
.iter()
.map(|symbol| {
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
}
})
.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)]
enum SelectionRiskDeferral { enum SelectionRiskDeferral {
None, None,
@@ -12416,6 +12531,32 @@ 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 target_portfolio_weight_bps =
if self.config.target_portfolio_daily_enabled && selection_limit > 0 {
let mut scales = Vec::new();
for symbol in stock_list.iter().take(selection_limit) {
let decision_stock = self.stock_state_with_factor_date(
ctx,
decision_date,
selection_factor_date,
symbol,
)?;
scales.push((symbol.clone(), self.buy_scale(ctx, &day, &decision_stock)?));
}
let original_weights = ordered_weight_bps_from_scales(&scales)?;
let mut excluded_target_symbols = exit_symbols.clone();
excluded_target_symbols.extend(stop_take_exit_signal_symbols.iter().cloned());
replenish_target_weight_bps(
&original_weights,
&stock_list,
&excluded_target_symbols,
selection_limit,
)
.into_iter()
.collect::<BTreeMap<_, _>>()
} else {
BTreeMap::new()
};
if self.config.rotation_enabled if self.config.rotation_enabled
&& self.config.daily_position_target_adjust_enabled && self.config.daily_position_target_adjust_enabled
@@ -12457,9 +12598,24 @@ impl Strategy for PlatformExprStrategy {
&position.symbol, &position.symbol,
)?; )?;
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
let target_value = strategy_visible_total_value * trading_ratio let target_value = if self.config.target_portfolio_daily_enabled {
target_portfolio_weight_bps
.get(&position.symbol)
.map(|weight_bps| {
strategy_visible_total_value
* trading_ratio
* f64::from(*weight_bps)
/ 10_000.0
})
.unwrap_or(
strategy_visible_total_value * trading_ratio
/ selection_limit as f64 / selection_limit as f64
* stock_scale; * stock_scale,
)
} else {
strategy_visible_total_value * trading_ratio / selection_limit as f64
* stock_scale
};
if !target_value.is_finite() || target_value <= 0.0 { if !target_value.is_finite() || target_value <= 0.0 {
continue; continue;
} }
@@ -12558,9 +12714,24 @@ impl Strategy for PlatformExprStrategy {
&symbol, &symbol,
)?; )?;
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
let target_value = strategy_visible_total_value * trading_ratio let target_value = if self.config.target_portfolio_daily_enabled {
target_portfolio_weight_bps
.get(&symbol)
.map(|weight_bps| {
strategy_visible_total_value
* trading_ratio
* f64::from(*weight_bps)
/ 10_000.0
})
.unwrap_or(
strategy_visible_total_value * trading_ratio
/ selection_limit as f64 / selection_limit as f64
* stock_scale; * stock_scale,
)
} else {
strategy_visible_total_value * trading_ratio / selection_limit as f64
* stock_scale
};
if !target_value.is_finite() || target_value <= 0.0 { if !target_value.is_finite() || target_value <= 0.0 {
continue; continue;
} }
@@ -12999,9 +13170,14 @@ impl Strategy for PlatformExprStrategy {
&symbol, &symbol,
)?; )?;
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
let target_value = strategy_visible_total_value * trading_ratio let target_value = if let Some(weight_bps) =
/ selection_limit as f64 target_portfolio_weight_bps.get(&symbol)
* stock_scale; {
strategy_visible_total_value * trading_ratio * f64::from(*weight_bps) / 10_000.0
} else {
strategy_visible_total_value * trading_ratio / selection_limit as f64
* stock_scale
};
if !target_value.is_finite() || target_value <= 0.0 { if !target_value.is_finite() || target_value <= 0.0 {
continue; continue;
} }
@@ -13166,7 +13342,10 @@ impl Strategy for PlatformExprStrategy {
if !rebalance_existing_positions { if !rebalance_existing_positions {
continue; continue;
} }
let target_value = target_budget / selection_limit as f64 * stock_scale; let target_value = target_portfolio_weight_bps
.get(symbol)
.map(|weight_bps| target_budget * f64::from(*weight_bps) / 10_000.0)
.unwrap_or(target_budget / selection_limit as f64 * stock_scale);
let before_qty = projected let before_qty = projected
.position(symbol) .position(symbol)
.map(|position| position.quantity) .map(|position| position.quantity)
@@ -13218,7 +13397,10 @@ impl Strategy for PlatformExprStrategy {
{ {
continue; continue;
} }
let target_value = fixed_buy_cash * stock_scale; let target_value = target_portfolio_weight_bps
.get(symbol)
.map(|weight_bps| target_budget * f64::from(*weight_bps) / 10_000.0)
.unwrap_or(fixed_buy_cash * stock_scale);
if target_value <= 0.0 { if target_value <= 0.0 {
continue; continue;
} }
@@ -13444,7 +13626,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, scheduled_position_exposure, framework_stock_rolling_factor_requirement, ordered_weight_bps_from_scales,
replenish_target_weight_bps, scheduled_position_exposure,
}; };
use crate::{ use crate::{
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
@@ -13460,6 +13643,39 @@ mod tests {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date") NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
} }
#[test]
fn target_weight_replenishment_matches_integer_bps_pool_contract() {
let scales = (0..24)
.map(|index| {
(
format!("S{index:02}"),
if index < 16 { 1.0008 } else { 0.9984 },
)
})
.collect::<Vec<_>>();
let original = ordered_weight_bps_from_scales(&scales).expect("weights");
let excluded = BTreeSet::from(["S01".to_string()]);
let candidates = scales
.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<_, _>>();
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"));
}
fn single_symbol_platform_data(dates: &[NaiveDate], symbol: &str) -> DataSet { fn single_symbol_platform_data(dates: &[NaiveDate], symbol: &str) -> DataSet {
DataSet::from_components( DataSet::from_components(
vec![Instrument { vec![Instrument {