区分退出后权重重分配语义
This commit is contained in:
@@ -197,6 +197,113 @@ fn validated_target_scales(scales: &[(String, f64)]) -> Result<Vec<(String, f64)
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ordered_weight_bps_from_scales(
|
||||||
|
scales: &[(String, f64)],
|
||||||
|
) -> Result<Vec<(String, u32)>, BacktestError> {
|
||||||
|
if scales.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
fn replenish_target_scales(
|
fn replenish_target_scales(
|
||||||
original_scales: &[(String, f64)],
|
original_scales: &[(String, f64)],
|
||||||
candidate_scales: &[(String, f64)],
|
candidate_scales: &[(String, f64)],
|
||||||
@@ -230,6 +337,42 @@ fn replenish_target_scales(
|
|||||||
active
|
active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolved_target_scales(
|
||||||
|
original_scales: &[(String, f64)],
|
||||||
|
candidate_scales: &[(String, f64)],
|
||||||
|
excluded_symbols: &BTreeSet<String>,
|
||||||
|
target_count: usize,
|
||||||
|
redistribute_after_exit: bool,
|
||||||
|
) -> Result<Vec<(String, f64)>, BacktestError> {
|
||||||
|
if !redistribute_after_exit {
|
||||||
|
return Ok(replenish_target_scales(
|
||||||
|
original_scales,
|
||||||
|
candidate_scales,
|
||||||
|
excluded_symbols,
|
||||||
|
target_count,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let original_weights = ordered_weight_bps_from_scales(original_scales)?;
|
||||||
|
let candidate_symbols = candidate_scales
|
||||||
|
.iter()
|
||||||
|
.map(|(symbol, _)| symbol.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(replenish_target_weight_bps(
|
||||||
|
&original_weights,
|
||||||
|
&candidate_symbols,
|
||||||
|
excluded_symbols,
|
||||||
|
target_count,
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(symbol, weight_bps)| {
|
||||||
|
(
|
||||||
|
symbol,
|
||||||
|
f64::from(weight_bps) * target_count as f64 / 10_000.0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn target_value_for_scale(
|
fn target_value_for_scale(
|
||||||
total_value: f64,
|
total_value: f64,
|
||||||
exposure: f64,
|
exposure: f64,
|
||||||
@@ -509,6 +652,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub delayed_limit_open_exit_enabled: bool,
|
pub delayed_limit_open_exit_enabled: bool,
|
||||||
pub delayed_limit_open_exit_time: Option<NaiveTime>,
|
pub delayed_limit_open_exit_time: Option<NaiveTime>,
|
||||||
pub release_slot_on_exit_signal: bool,
|
pub release_slot_on_exit_signal: bool,
|
||||||
|
pub redistribute_target_weights_after_exit: bool,
|
||||||
pub explicit_action_stage: PlatformExplicitActionStage,
|
pub explicit_action_stage: PlatformExplicitActionStage,
|
||||||
pub explicit_action_schedule: Option<PlatformRebalanceSchedule>,
|
pub explicit_action_schedule: Option<PlatformRebalanceSchedule>,
|
||||||
pub subscription_guard_required: bool,
|
pub subscription_guard_required: bool,
|
||||||
@@ -584,6 +728,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
delayed_limit_open_exit_enabled: false,
|
delayed_limit_open_exit_enabled: false,
|
||||||
delayed_limit_open_exit_time: None,
|
delayed_limit_open_exit_time: None,
|
||||||
release_slot_on_exit_signal: false,
|
release_slot_on_exit_signal: false,
|
||||||
|
redistribute_target_weights_after_exit: false,
|
||||||
explicit_action_stage: PlatformExplicitActionStage::OnDay,
|
explicit_action_stage: PlatformExplicitActionStage::OnDay,
|
||||||
explicit_action_schedule: None,
|
explicit_action_schedule: None,
|
||||||
subscription_guard_required: false,
|
subscription_guard_required: false,
|
||||||
@@ -12528,12 +12673,13 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
} else {
|
} else {
|
||||||
(Vec::new(), Vec::new())
|
(Vec::new(), Vec::new())
|
||||||
};
|
};
|
||||||
let target_portfolio_scales = replenish_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,
|
||||||
|
)?;
|
||||||
let target_portfolio_scale_by_symbol = target_portfolio_scales
|
let target_portfolio_scale_by_symbol = target_portfolio_scales
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -13106,12 +13252,13 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let daily_target_portfolio_scales = replenish_target_scales(
|
let daily_target_portfolio_scales = resolved_target_scales(
|
||||||
&original_target_scales,
|
&original_target_scales,
|
||||||
&candidate_target_scales,
|
&candidate_target_scales,
|
||||||
&same_day_sold_symbols,
|
&same_day_sold_symbols,
|
||||||
selection_limit,
|
selection_limit,
|
||||||
);
|
self.config.redistribute_target_weights_after_exit,
|
||||||
|
)?;
|
||||||
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 {
|
||||||
let target_value = target_value_for_scale(
|
let target_value = target_value_for_scale(
|
||||||
@@ -13547,7 +13694,8 @@ mod tests {
|
|||||||
PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral,
|
PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral,
|
||||||
StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements,
|
StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements,
|
||||||
framework_stock_rolling_factor_requirement, replenish_target_scales,
|
framework_stock_rolling_factor_requirement, replenish_target_scales,
|
||||||
scheduled_position_exposure, target_value_for_scale, validated_target_scales,
|
resolved_target_scales, scheduled_position_exposure, target_value_for_scale,
|
||||||
|
validated_target_scales,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
||||||
@@ -13612,6 +13760,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stock_pool_exit_weight_redistribution_is_explicit_and_uses_integer_bps() {
|
||||||
|
let original = vec![
|
||||||
|
("A".to_string(), 1.0008),
|
||||||
|
("B".to_string(), 1.0008),
|
||||||
|
("C".to_string(), 0.9984),
|
||||||
|
];
|
||||||
|
let excluded = BTreeSet::from(["B".to_string()]);
|
||||||
|
|
||||||
|
let preserved = resolved_target_scales(&original, &original, &excluded, 3, false)
|
||||||
|
.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)
|
||||||
|
.expect("redistributed target scales");
|
||||||
|
assert_eq!(
|
||||||
|
redistributed,
|
||||||
|
vec![("A".to_string(), 1.5021), ("C".to_string(), 1.4979)]
|
||||||
|
);
|
||||||
|
assert!((redistributed.iter().map(|(_, scale)| *scale).sum::<f64>() - 3.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -1020,6 +1020,8 @@ pub struct StrategyExpressionTradingConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub release_slot_on_exit_signal: Option<bool>,
|
pub release_slot_on_exit_signal: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub redistribute_target_weights_after_exit: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
pub subscription_guard_required: Option<bool>,
|
pub subscription_guard_required: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subscriptions: Vec<String>,
|
pub subscriptions: Vec<String>,
|
||||||
@@ -2294,6 +2296,9 @@ pub fn platform_expr_config_from_spec(
|
|||||||
if let Some(enabled) = trading.release_slot_on_exit_signal {
|
if let Some(enabled) = trading.release_slot_on_exit_signal {
|
||||||
cfg.release_slot_on_exit_signal = enabled;
|
cfg.release_slot_on_exit_signal = enabled;
|
||||||
}
|
}
|
||||||
|
if let Some(enabled) = trading.redistribute_target_weights_after_exit {
|
||||||
|
cfg.redistribute_target_weights_after_exit = enabled;
|
||||||
|
}
|
||||||
if let Some(enabled) = trading.delayed_limit_open_exit {
|
if let Some(enabled) = trading.delayed_limit_open_exit {
|
||||||
cfg.delayed_limit_open_exit_enabled = enabled;
|
cfg.delayed_limit_open_exit_enabled = enabled;
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -3484,7 +3489,8 @@ mod tests {
|
|||||||
"targetPortfolioDaily": true,
|
"targetPortfolioDaily": true,
|
||||||
"rebalanceExistingPositions": true,
|
"rebalanceExistingPositions": true,
|
||||||
"holdUntilExit": true,
|
"holdUntilExit": true,
|
||||||
"releaseSlotOnExitSignal": true
|
"releaseSlotOnExitSignal": true,
|
||||||
|
"redistributeTargetWeightsAfterExit": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3498,6 +3504,7 @@ mod tests {
|
|||||||
assert!(cfg.rebalance_existing_positions);
|
assert!(cfg.rebalance_existing_positions);
|
||||||
assert!(cfg.hold_until_exit_enabled);
|
assert!(cfg.hold_until_exit_enabled);
|
||||||
assert!(cfg.release_slot_on_exit_signal);
|
assert!(cfg.release_slot_on_exit_signal);
|
||||||
|
assert!(cfg.redistribute_target_weights_after_exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user