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, min_target_qty: u32,
max_target_qty: u32, max_target_qty: u32,
provisional_target_qty: u32, provisional_target_qty: u32,
target_weight: f64,
price: f64, price: f64,
minimum_order_quantity: u32, minimum_order_quantity: u32,
order_step_size: u32, order_step_size: u32,
@@ -506,14 +505,12 @@ where
min_target_qty, min_target_qty,
max_target_qty, max_target_qty,
provisional_target_qty, provisional_target_qty,
target_weight: *target_weights.get(&symbol).unwrap_or(&0.0),
price, price,
minimum_order_quantity, minimum_order_quantity,
order_step_size, order_step_size,
}); });
} }
let safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 };
let mut targets = BTreeMap::new(); let mut targets = BTreeMap::new();
for constraint in &constraints { for constraint in &constraints {
if constraint.provisional_target_qty > constraint.current_qty { if constraint.provisional_target_qty > constraint.current_qty {
@@ -524,67 +521,81 @@ where
} }
} }
let mut buy_constraints = constraints let buy_constraints = constraints
.iter() .iter()
.filter(|constraint| constraint.provisional_target_qty > constraint.current_qty) .filter(|constraint| constraint.provisional_target_qty > constraint.current_qty)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
buy_constraints.sort_by(|lhs, rhs| { if buy_constraints.is_empty() {
rhs.target_weight return Ok(targets);
.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 best_targets = targets.clone();
let mut target_qty = if safety > 1.0 { 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 scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32;
self.round_buy_quantity( let mut target_qty = self
.round_buy_quantity(
scaled_desired_qty, scaled_desired_qty,
constraint.minimum_order_quantity, constraint.minimum_order_quantity,
constraint.order_step_size, constraint.order_step_size,
) )
.clamp(constraint.current_qty, constraint.max_target_qty) .clamp(constraint.min_target_qty, constraint.max_target_qty)
} else { .max(constraint.current_qty);
constraint.provisional_target_qty if target_qty < constraint.current_qty {
}; target_qty = constraint.current_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 { if target_qty > constraint.current_qty {
projected_cash -= self.estimated_buy_cash_out( buy_cash_out += self.estimated_buy_cash_out(
date, date,
constraint.price, constraint.price,
target_qty - constraint.current_qty, target_qty - constraint.current_qty,
); );
} }
if target_qty > 0 { if target_qty > 0 {
targets.insert(constraint.symbol.clone(), target_qty); candidate_targets.insert(constraint.symbol.clone(), target_qty);
} }
} }
Ok(targets) 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( fn minimum_target_quantity(

View File

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