支持动态排名每日替换上限
This commit is contained in:
@@ -361,6 +361,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub daily_position_target_adjust_enabled: bool,
|
pub daily_position_target_adjust_enabled: bool,
|
||||||
pub rebalance_existing_positions: bool,
|
pub rebalance_existing_positions: bool,
|
||||||
pub selection_buffer_multiple: f64,
|
pub selection_buffer_multiple: f64,
|
||||||
|
pub daily_replacement_limit: usize,
|
||||||
pub retry_empty_rebalance: bool,
|
pub retry_empty_rebalance: bool,
|
||||||
pub calendar_rebalance_interval: bool,
|
pub calendar_rebalance_interval: bool,
|
||||||
pub max_holding_days: Option<i64>,
|
pub max_holding_days: Option<i64>,
|
||||||
@@ -435,6 +436,7 @@ fn band_low(index_close) {
|
|||||||
daily_position_target_adjust_enabled: true,
|
daily_position_target_adjust_enabled: true,
|
||||||
rebalance_existing_positions: false,
|
rebalance_existing_positions: false,
|
||||||
selection_buffer_multiple: 1.0,
|
selection_buffer_multiple: 1.0,
|
||||||
|
daily_replacement_limit: 0,
|
||||||
retry_empty_rebalance: false,
|
retry_empty_rebalance: false,
|
||||||
calendar_rebalance_interval: false,
|
calendar_rebalance_interval: false,
|
||||||
max_holding_days: None,
|
max_holding_days: None,
|
||||||
@@ -829,6 +831,7 @@ impl PlatformExprStrategy {
|
|||||||
held_symbols: &BTreeSet<String>,
|
held_symbols: &BTreeSet<String>,
|
||||||
selection_limit: usize,
|
selection_limit: usize,
|
||||||
buffer_rank: usize,
|
buffer_rank: usize,
|
||||||
|
daily_replacement_limit: usize,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
if selection_limit == 0 {
|
if selection_limit == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -849,7 +852,50 @@ impl PlatformExprStrategy {
|
|||||||
selected.push(symbol.clone());
|
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 {
|
fn effective_rebalance_cash_mode(&self) -> RebalanceCashMode {
|
||||||
@@ -8709,6 +8755,11 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
let mut risk_decisions = Vec::new();
|
let mut risk_decisions = Vec::new();
|
||||||
let stock_list = if self.config.rotation_enabled && !in_skip_window {
|
let stock_list = if self.config.rotation_enabled && !in_skip_window {
|
||||||
let selection_buffer_rank = self.selection_buffer_rank(selection_limit);
|
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(
|
let (ranked_stock_list, notes, selection_risk_decisions) = self.select_symbols(
|
||||||
ctx,
|
ctx,
|
||||||
selection_market_date,
|
selection_market_date,
|
||||||
@@ -8717,7 +8768,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
&day,
|
&day,
|
||||||
band_low,
|
band_low,
|
||||||
band_high,
|
band_high,
|
||||||
selection_buffer_rank,
|
ranked_selection_limit,
|
||||||
)?;
|
)?;
|
||||||
selection_notes = notes;
|
selection_notes = notes;
|
||||||
risk_decisions = selection_risk_decisions;
|
risk_decisions = selection_risk_decisions;
|
||||||
@@ -8732,6 +8783,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
&held_symbols,
|
&held_symbols,
|
||||||
selection_limit,
|
selection_limit,
|
||||||
selection_buffer_rank,
|
selection_buffer_rank,
|
||||||
|
self.config.daily_replacement_limit,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -9546,6 +9598,11 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if periodic_rebalance {
|
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
|
let pre_rebalance_symbols = projected
|
||||||
.positions()
|
.positions()
|
||||||
.keys()
|
.keys()
|
||||||
@@ -9625,9 +9682,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
&& !same_day_sold_symbols.contains(symbol)
|
&& !same_day_sold_symbols.contains(symbol)
|
||||||
&& !pending_full_close_symbols.contains(symbol);
|
&& !pending_full_close_symbols.contains(symbol);
|
||||||
if projected.positions().contains_key(symbol) && !released_exit_position {
|
if projected.positions().contains_key(symbol) && !released_exit_position {
|
||||||
if self.config.aiquant_transaction_cost
|
if self.config.aiquant_transaction_cost && !rebalance_existing_positions {
|
||||||
&& !self.config.rebalance_existing_positions
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.config.aiquant_transaction_cost {
|
if self.config.aiquant_transaction_cost {
|
||||||
@@ -26215,6 +26270,10 @@ mod tests {
|
|||||||
target_value_cfg.rebalance_existing_positions = true;
|
target_value_cfg.rebalance_existing_positions = true;
|
||||||
target_value_cfg.intraday_execution_time =
|
target_value_cfg.intraday_execution_time =
|
||||||
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap());
|
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);
|
let mut target_value_strategy = PlatformExprStrategy::new(target_value_cfg);
|
||||||
target_value_strategy.rebalance_day_counter = 20;
|
target_value_strategy.rebalance_day_counter = 20;
|
||||||
|
|
||||||
@@ -26239,6 +26298,44 @@ mod tests {
|
|||||||
"{:?}",
|
"{:?}",
|
||||||
target_value_decision.order_intents
|
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]
|
#[test]
|
||||||
@@ -30137,7 +30234,7 @@ mod tests {
|
|||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
|
|
||||||
let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 6);
|
let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 6, 0);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
selected,
|
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]
|
#[test]
|
||||||
fn lagged_rebalance_emits_target_value_even_when_projection_is_unchanged() {
|
fn lagged_rebalance_emits_target_value_even_when_projection_is_unchanged() {
|
||||||
assert!(PlatformExprStrategy::should_emit_rebalance_target_value(
|
assert!(PlatformExprStrategy::should_emit_rebalance_target_value(
|
||||||
|
|||||||
@@ -715,6 +715,8 @@ pub struct StrategyExpressionTradingConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub selection_buffer_multiple: Option<f64>,
|
pub selection_buffer_multiple: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub daily_replacement_limit: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
pub retry_empty_rebalance: Option<bool>,
|
pub retry_empty_rebalance: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
||||||
@@ -1702,6 +1704,9 @@ pub fn platform_expr_config_from_spec(
|
|||||||
{
|
{
|
||||||
cfg.selection_buffer_multiple = multiple;
|
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 {
|
if let Some(enabled) = trading.retry_empty_rebalance {
|
||||||
cfg.retry_empty_rebalance = enabled;
|
cfg.retry_empty_rebalance = enabled;
|
||||||
}
|
}
|
||||||
@@ -3020,6 +3025,7 @@ mod tests {
|
|||||||
"dailyPositionTargetAdjust": false,
|
"dailyPositionTargetAdjust": false,
|
||||||
"rebalanceExistingPositions": true,
|
"rebalanceExistingPositions": true,
|
||||||
"selectionBufferMultiple": 1.5,
|
"selectionBufferMultiple": 1.5,
|
||||||
|
"dailyReplacementLimit": 2,
|
||||||
"retryEmptyRebalance": false
|
"retryEmptyRebalance": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3031,6 +3037,7 @@ mod tests {
|
|||||||
assert!(!cfg.daily_position_target_adjust_enabled);
|
assert!(!cfg.daily_position_target_adjust_enabled);
|
||||||
assert!(cfg.rebalance_existing_positions);
|
assert!(cfg.rebalance_existing_positions);
|
||||||
assert_eq!(cfg.selection_buffer_multiple, 1.5);
|
assert_eq!(cfg.selection_buffer_multiple, 1.5);
|
||||||
|
assert_eq!(cfg.daily_replacement_limit, 2);
|
||||||
assert!(!cfg.retry_empty_rebalance);
|
assert!(!cfg.retry_empty_rebalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user