Emit rebalance clipping diagnostics

This commit is contained in:
boris
2026-04-23 00:10:49 -07:00
parent df1054ab8a
commit ec7085d10a
3 changed files with 55 additions and 7 deletions

View File

@@ -16,6 +16,7 @@ pub struct BrokerExecutionReport {
pub fill_events: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
@@ -294,11 +295,12 @@ where
return Ok(report);
}
let target_quantities = if decision.rebalance {
let (target_quantities, rebalance_diagnostics) = if decision.rebalance {
self.target_quantities(date, portfolio, data, &decision.target_weights)?
} else {
BTreeMap::new()
(BTreeMap::new(), Vec::new())
};
report.diagnostics.extend(rebalance_diagnostics);
let mut sell_symbols = BTreeSet::new();
sell_symbols.extend(portfolio.positions().keys().cloned());
@@ -437,10 +439,11 @@ where
portfolio: &PortfolioState,
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
) -> Result<BTreeMap<String, u32>, BacktestError> {
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
let equity = self.total_equity_at(date, portfolio, data, self.execution_price_field)?;
let target_weight_sum = target_weights.values().copied().sum::<f64>();
let mut desired_targets = BTreeMap::new();
let mut diagnostics = Vec::new();
for (symbol, weight) in target_weights {
let price = data
.price(date, symbol, self.execution_price_field)
@@ -500,6 +503,12 @@ where
order_step_size,
);
let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty);
if provisional_target_qty != desired_qty && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_target_clipped symbol={} desired={} min={} max={} provisional={}",
symbol, desired_qty, min_target_qty, max_target_qty, provisional_target_qty
));
}
if current_qty > provisional_target_qty {
projected_cash += self.estimated_sell_net_cash(
date,
@@ -535,12 +544,13 @@ where
.filter(|constraint| constraint.provisional_target_qty > constraint.current_qty)
.collect::<Vec<_>>();
if buy_constraints.is_empty() {
return Ok(targets);
return Ok((targets, diagnostics));
}
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 };
let initial_safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 };
let mut safety = initial_safety;
loop {
let mut candidate_targets = targets.clone();
let mut buy_cash_out = 0.0;
@@ -604,7 +614,30 @@ where
safety = next_safety;
}
Ok(best_targets)
if safety < initial_safety && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_safety_scaled final_safety={:.4} target_weight_sum={:.4} projected_cash={:.2}",
safety, target_weight_sum, projected_cash
));
}
for constraint in &buy_constraints {
let final_target_qty = best_targets
.get(&constraint.symbol)
.copied()
.unwrap_or(constraint.current_qty);
if final_target_qty < constraint.provisional_target_qty && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={}",
constraint.symbol,
constraint.provisional_target_qty,
final_target_qty,
constraint.current_qty
));
}
}
Ok((best_targets, diagnostics))
}
fn minimum_target_quantity(

View File

@@ -224,6 +224,7 @@ where
let daily_fill_count = report.fill_events.len();
let day_orders = report.order_events.clone();
let day_fills = report.fill_events.clone();
let broker_diagnostics = report.diagnostics.clone();
self.extend_result(&mut result, report);
portfolio.update_prices(execution_date, &self.data, PriceField::Close)?;
@@ -249,7 +250,12 @@ where
.chain(decision.notes.into_iter())
.collect::<Vec<_>>()
.join(" | ");
let diagnostics = decision.diagnostics.join(" | ");
let diagnostics = decision
.diagnostics
.into_iter()
.chain(broker_diagnostics.into_iter())
.collect::<Vec<_>>()
.join(" | ");
let holdings_for_day = portfolio.holdings_summary(execution_date);
result.equity_curve.push(DailyEquityPoint {

View File

@@ -754,6 +754,15 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
.iter()
.any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)
);
assert!(
report
.diagnostics
.iter()
.any(|line| line.contains("rebalance_safety_scaled")
|| line.contains("rebalance_buy_reduced")),
"expected rebalance diagnostics when cash is tight, got {:?}",
report.diagnostics
);
}
#[test]