支持动态排名每日替换上限

This commit is contained in:
boris
2026-07-15 21:49:46 +08:00
parent e7d1c875fd
commit 9d47d06064
2 changed files with 140 additions and 6 deletions
+133 -6
View File
@@ -361,6 +361,7 @@ pub struct PlatformExprStrategyConfig {
pub daily_position_target_adjust_enabled: bool,
pub rebalance_existing_positions: bool,
pub selection_buffer_multiple: f64,
pub daily_replacement_limit: usize,
pub retry_empty_rebalance: bool,
pub calendar_rebalance_interval: bool,
pub max_holding_days: Option<i64>,
@@ -435,6 +436,7 @@ fn band_low(index_close) {
daily_position_target_adjust_enabled: true,
rebalance_existing_positions: false,
selection_buffer_multiple: 1.0,
daily_replacement_limit: 0,
retry_empty_rebalance: false,
calendar_rebalance_interval: false,
max_holding_days: None,
@@ -829,6 +831,7 @@ impl PlatformExprStrategy {
held_symbols: &BTreeSet<String>,
selection_limit: usize,
buffer_rank: usize,
daily_replacement_limit: usize,
) -> Vec<String> {
if selection_limit == 0 {
return Vec::new();
@@ -849,7 +852,50 @@ impl PlatformExprStrategy {
selected.push(symbol.clone());
}
}
selected
if daily_replacement_limit == 0 || held_symbols.is_empty() {
return selected;
}
let rank_by_symbol = ranked_symbols
.iter()
.enumerate()
.map(|(index, symbol)| (symbol.as_str(), index))
.collect::<BTreeMap<_, _>>();
let selected_set = selected.iter().map(String::as_str).collect::<BTreeSet<_>>();
let available_held = held_symbols
.iter()
.filter(|symbol| rank_by_symbol.contains_key(symbol.as_str()))
.cloned()
.collect::<Vec<_>>();
let mut removed = available_held
.iter()
.filter(|symbol| !selected_set.contains(symbol.as_str()))
.cloned()
.collect::<Vec<_>>();
if removed.len() <= daily_replacement_limit {
return selected;
}
removed.sort_by_key(|symbol| std::cmp::Reverse(rank_by_symbol[symbol.as_str()]));
let permitted_removals = removed
.into_iter()
.take(daily_replacement_limit)
.collect::<BTreeSet<_>>();
let mut limited = available_held
.into_iter()
.filter(|symbol| !permitted_removals.contains(symbol))
.collect::<Vec<_>>();
limited.sort_by_key(|symbol| rank_by_symbol[symbol.as_str()]);
let mut limited_set = limited.iter().cloned().collect::<BTreeSet<_>>();
for symbol in selected.iter().chain(ranked_symbols.iter()) {
if limited.len() >= selection_limit {
break;
}
if limited_set.insert(symbol.clone()) {
limited.push(symbol.clone());
}
}
limited.sort_by_key(|symbol| rank_by_symbol[symbol.as_str()]);
limited
}
fn effective_rebalance_cash_mode(&self) -> RebalanceCashMode {
@@ -8709,6 +8755,11 @@ impl Strategy for PlatformExprStrategy {
let mut risk_decisions = Vec::new();
let stock_list = if self.config.rotation_enabled && !in_skip_window {
let selection_buffer_rank = self.selection_buffer_rank(selection_limit);
let ranked_selection_limit = if self.config.daily_replacement_limit > 0 {
usize::MAX
} else {
selection_buffer_rank
};
let (ranked_stock_list, notes, selection_risk_decisions) = self.select_symbols(
ctx,
selection_market_date,
@@ -8717,7 +8768,7 @@ impl Strategy for PlatformExprStrategy {
&day,
band_low,
band_high,
selection_buffer_rank,
ranked_selection_limit,
)?;
selection_notes = notes;
risk_decisions = selection_risk_decisions;
@@ -8732,6 +8783,7 @@ impl Strategy for PlatformExprStrategy {
&held_symbols,
selection_limit,
selection_buffer_rank,
self.config.daily_replacement_limit,
)
} else {
Vec::new()
@@ -9546,6 +9598,11 @@ impl Strategy for PlatformExprStrategy {
}
if periodic_rebalance {
let rebalance_existing_positions = self.config.rebalance_existing_positions
|| self
.last_trading_ratio
.map(|previous| (previous - trading_ratio).abs() > 1e-12)
.unwrap_or(true);
let pre_rebalance_symbols = projected
.positions()
.keys()
@@ -9625,9 +9682,7 @@ impl Strategy for PlatformExprStrategy {
&& !same_day_sold_symbols.contains(symbol)
&& !pending_full_close_symbols.contains(symbol);
if projected.positions().contains_key(symbol) && !released_exit_position {
if self.config.aiquant_transaction_cost
&& !self.config.rebalance_existing_positions
{
if self.config.aiquant_transaction_cost && !rebalance_existing_positions {
continue;
}
if self.config.aiquant_transaction_cost {
@@ -26215,6 +26270,10 @@ mod tests {
target_value_cfg.rebalance_existing_positions = true;
target_value_cfg.intraday_execution_time =
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap());
let hold_existing_cfg = PlatformExprStrategyConfig {
rebalance_existing_positions: false,
..target_value_cfg.clone()
};
let mut target_value_strategy = PlatformExprStrategy::new(target_value_cfg);
target_value_strategy.rebalance_day_counter = 20;
@@ -26239,6 +26298,44 @@ mod tests {
"{:?}",
target_value_decision.order_intents
);
let mut hold_existing_strategy = PlatformExprStrategy::new(hold_existing_cfg.clone());
hold_existing_strategy.rebalance_day_counter = 20;
hold_existing_strategy.last_trading_ratio = Some(1.0);
let unchanged_exposure_decision = hold_existing_strategy
.on_day(&ctx)
.expect("hold-existing platform decision");
assert!(
!unchanged_exposure_decision
.order_intents
.iter()
.any(|intent| matches!(
intent,
OrderIntent::TargetValue { reason, .. }
if reason == "periodic_rebalance_target_adjust"
)),
"{:?}",
unchanged_exposure_decision.order_intents
);
let mut changed_exposure_strategy = PlatformExprStrategy::new(hold_existing_cfg);
changed_exposure_strategy.rebalance_day_counter = 20;
changed_exposure_strategy.last_trading_ratio = Some(0.1);
let changed_exposure_decision = changed_exposure_strategy
.on_day(&ctx)
.expect("changed-exposure platform decision");
assert!(
changed_exposure_decision
.order_intents
.iter()
.any(|intent| matches!(
intent,
OrderIntent::TargetValue { reason, .. }
if reason == "periodic_rebalance_target_adjust"
)),
"{:?}",
changed_exposure_decision.order_intents
);
}
#[test]
@@ -30137,7 +30234,7 @@ mod tests {
.map(str::to_string)
.collect::<BTreeSet<_>>();
let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 6);
let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 6, 0);
assert_eq!(
selected,
@@ -30145,6 +30242,36 @@ mod tests {
);
}
#[test]
fn selection_replacement_limit_caps_voluntary_daily_removals() {
let ranked = [
"000005.SZ",
"000006.SZ",
"000007.SZ",
"000008.SZ",
"000001.SZ",
"000002.SZ",
"000003.SZ",
"000004.SZ",
]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
let held = ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"]
.into_iter()
.map(str::to_string)
.collect::<BTreeSet<_>>();
let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 4, 2);
assert_eq!(
selected,
vec!["000005.SZ", "000006.SZ", "000001.SZ", "000002.SZ"]
);
let selected_set = selected.into_iter().collect::<BTreeSet<_>>();
assert_eq!(held.difference(&selected_set).count(), 2);
}
#[test]
fn lagged_rebalance_emits_target_value_even_when_projection_is_unchanged() {
assert!(PlatformExprStrategy::should_emit_rebalance_target_value(
@@ -715,6 +715,8 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)]
pub selection_buffer_multiple: Option<f64>,
#[serde(default)]
pub daily_replacement_limit: Option<usize>,
#[serde(default)]
pub retry_empty_rebalance: Option<bool>,
#[serde(default)]
pub weak_market_shrink_overweight_threshold: Option<f64>,
@@ -1702,6 +1704,9 @@ pub fn platform_expr_config_from_spec(
{
cfg.selection_buffer_multiple = multiple;
}
if let Some(limit) = trading.daily_replacement_limit {
cfg.daily_replacement_limit = limit;
}
if let Some(enabled) = trading.retry_empty_rebalance {
cfg.retry_empty_rebalance = enabled;
}
@@ -3020,6 +3025,7 @@ mod tests {
"dailyPositionTargetAdjust": false,
"rebalanceExistingPositions": true,
"selectionBufferMultiple": 1.5,
"dailyReplacementLimit": 2,
"retryEmptyRebalance": false
}
}
@@ -3031,6 +3037,7 @@ mod tests {
assert!(!cfg.daily_position_target_adjust_enabled);
assert!(cfg.rebalance_existing_positions);
assert_eq!(cfg.selection_buffer_multiple, 1.5);
assert_eq!(cfg.daily_replacement_limit, 2);
assert!(!cfg.retry_empty_rebalance);
}