From 1b4ce9826a9e71d0c0505641f45fb858d5df5822 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 22 Apr 2026 23:50:15 -0700 Subject: [PATCH] Iterate rebalance safety like rqalpha --- crates/fidc-core/src/broker.rs | 129 ++++++++++-------- crates/fidc-core/tests/explicit_order_flow.rs | 21 +-- 2 files changed, 82 insertions(+), 68 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index bef6bad..078194e 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -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::>(); - 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::(); + 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( diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index cc365c1..c726b2c 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -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