支持动态排名每日替换上限
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user