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 fill_events: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>, pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>, pub account_events: Vec<AccountEvent>,
pub diagnostics: Vec<String>,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -294,11 +295,12 @@ where
return Ok(report); 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)? self.target_quantities(date, portfolio, data, &decision.target_weights)?
} else { } else {
BTreeMap::new() (BTreeMap::new(), Vec::new())
}; };
report.diagnostics.extend(rebalance_diagnostics);
let mut sell_symbols = BTreeSet::new(); let mut sell_symbols = BTreeSet::new();
sell_symbols.extend(portfolio.positions().keys().cloned()); sell_symbols.extend(portfolio.positions().keys().cloned());
@@ -437,10 +439,11 @@ where
portfolio: &PortfolioState, portfolio: &PortfolioState,
data: &DataSet, data: &DataSet,
target_weights: &BTreeMap<String, f64>, 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 equity = self.total_equity_at(date, portfolio, data, self.execution_price_field)?;
let target_weight_sum = target_weights.values().copied().sum::<f64>(); let target_weight_sum = target_weights.values().copied().sum::<f64>();
let mut desired_targets = BTreeMap::new(); let mut desired_targets = BTreeMap::new();
let mut diagnostics = Vec::new();
for (symbol, weight) in target_weights { for (symbol, weight) in target_weights {
let price = data let price = data
.price(date, symbol, self.execution_price_field) .price(date, symbol, self.execution_price_field)
@@ -500,6 +503,12 @@ where
order_step_size, order_step_size,
); );
let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty); 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 { if current_qty > provisional_target_qty {
projected_cash += self.estimated_sell_net_cash( projected_cash += self.estimated_sell_net_cash(
date, date,
@@ -535,12 +544,13 @@ where
.filter(|constraint| constraint.provisional_target_qty > constraint.current_qty) .filter(|constraint| constraint.provisional_target_qty > constraint.current_qty)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if buy_constraints.is_empty() { if buy_constraints.is_empty() {
return Ok(targets); return Ok((targets, diagnostics));
} }
let mut best_targets = targets.clone(); let mut best_targets = targets.clone();
let mut best_proportion_diff = f64::INFINITY; 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 { loop {
let mut candidate_targets = targets.clone(); let mut candidate_targets = targets.clone();
let mut buy_cash_out = 0.0; let mut buy_cash_out = 0.0;
@@ -604,7 +614,30 @@ where
safety = next_safety; 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( fn minimum_target_quantity(

View File

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

View File

@@ -754,6 +754,15 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
.iter() .iter()
.any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy) .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] #[test]