Iterate rebalance safety like rqalpha

This commit is contained in:
boris
2026-04-22 23:50:15 -07:00
parent c4967c3711
commit 1b4ce9826a
2 changed files with 82 additions and 68 deletions

View File

@@ -33,7 +33,6 @@ struct TargetConstraint {
min_target_qty: u32,
max_target_qty: u32,
provisional_target_qty: u32,
target_weight: f64,
price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
@@ -506,14 +505,12 @@ where
min_target_qty,
max_target_qty,
provisional_target_qty,
target_weight: *target_weights.get(&symbol).unwrap_or(&0.0),
price,
minimum_order_quantity,
order_step_size,
});
}
let safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 };
let mut targets = BTreeMap::new();
for constraint in &constraints {
if constraint.provisional_target_qty > constraint.current_qty {
@@ -524,67 +521,81 @@ where
}
}
let mut buy_constraints = constraints
let buy_constraints = constraints
.iter()
.filter(|constraint| constraint.provisional_target_qty > constraint.current_qty)
.collect::<Vec<_>>();
buy_constraints.sort_by(|lhs, rhs| {
rhs.target_weight
.partial_cmp(&lhs.target_weight)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
let lhs_gap = (lhs.provisional_target_qty.saturating_sub(lhs.current_qty))
as f64
* lhs.price;
let rhs_gap = (rhs.provisional_target_qty.saturating_sub(rhs.current_qty))
as f64
* rhs.price;
rhs_gap
.partial_cmp(&lhs_gap)
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| lhs.symbol.cmp(&rhs.symbol))
});
for constraint in buy_constraints {
let mut target_qty = if safety > 1.0 {
let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32;
self.round_buy_quantity(
scaled_desired_qty,
constraint.minimum_order_quantity,
constraint.order_step_size,
)
.clamp(constraint.current_qty, constraint.max_target_qty)
} else {
constraint.provisional_target_qty
};
target_qty = target_qty.max(constraint.current_qty);
let desired_additional = target_qty.saturating_sub(constraint.current_qty);
let affordable_additional = self.affordable_buy_quantity(
date,
projected_cash,
None,
constraint.price,
desired_additional,
constraint.minimum_order_quantity,
constraint.order_step_size,
);
target_qty = (constraint.current_qty + affordable_additional)
.clamp(constraint.min_target_qty, constraint.max_target_qty);
if target_qty > constraint.current_qty {
projected_cash -= self.estimated_buy_cash_out(
date,
constraint.price,
target_qty - constraint.current_qty,
);
}
if target_qty > 0 {
targets.insert(constraint.symbol.clone(), target_qty);
}
if buy_constraints.is_empty() {
return Ok(targets);
}
Ok(targets)
let mut best_targets = targets.clone();
let mut best_proportion_diff = f64::INFINITY;
let mut safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 };
loop {
let mut candidate_targets = targets.clone();
let mut buy_cash_out = 0.0;
for constraint in &buy_constraints {
let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32;
let mut target_qty = self
.round_buy_quantity(
scaled_desired_qty,
constraint.minimum_order_quantity,
constraint.order_step_size,
)
.clamp(constraint.min_target_qty, constraint.max_target_qty)
.max(constraint.current_qty);
if target_qty < constraint.current_qty {
target_qty = constraint.current_qty;
}
if target_qty > constraint.current_qty {
buy_cash_out += self.estimated_buy_cash_out(
date,
constraint.price,
target_qty - constraint.current_qty,
);
}
if target_qty > 0 {
candidate_targets.insert(constraint.symbol.clone(), target_qty);
}
}
let total_target_value = constraints
.iter()
.map(|constraint| {
candidate_targets
.get(&constraint.symbol)
.copied()
.unwrap_or(0) as f64
* constraint.price
})
.sum::<f64>();
let proportion_diff = if equity > 0.0 {
((total_target_value / equity) - target_weight_sum).abs()
} else {
0.0
};
if buy_cash_out <= projected_cash + 1e-6 {
if proportion_diff <= best_proportion_diff + 1e-12 {
best_targets = candidate_targets;
best_proportion_diff = proportion_diff;
} else if best_proportion_diff.is_finite() {
break;
}
}
if safety <= 0.0 {
break;
}
let step = (proportion_diff / 10.0).clamp(0.0001, 0.002);
let next_safety = (safety - step).max(0.0);
if (next_safety - safety).abs() < f64::EPSILON {
break;
}
safety = next_safety;
}
Ok(best_targets)
}
fn minimum_target_quantity(

View File

@@ -631,16 +631,19 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
)
.expect("broker execution");
assert_eq!(
portfolio
.position("000002.SZ")
.map(|position| position.quantity)
.unwrap_or(0),
900
);
let lower_weight_qty = portfolio
.position("000001.SZ")
.map(|position| position.quantity)
.unwrap_or(0);
let higher_weight_qty = portfolio
.position("000002.SZ")
.map(|position| position.quantity)
.unwrap_or(0);
assert!(
portfolio.position("000001.SZ").is_none(),
"higher target weight should consume the limited rebalance cash first"
higher_weight_qty > lower_weight_qty,
"cash-constrained rebalance should preserve more exposure for the higher target weight, got low={} high={}",
lower_weight_qty,
higher_weight_qty
);
assert!(
report