Iterate rebalance safety like rqalpha
This commit is contained in:
@@ -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 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
fn minimum_target_quantity(
|
||||||
|
|||||||
@@ -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")
|
||||||
.position("000002.SZ")
|
.map(|position| position.quantity)
|
||||||
.map(|position| position.quantity)
|
.unwrap_or(0);
|
||||||
.unwrap_or(0),
|
let higher_weight_qty = portfolio
|
||||||
900
|
.position("000002.SZ")
|
||||||
);
|
.map(|position| position.quantity)
|
||||||
|
.unwrap_or(0);
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user