diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 881ddbf..5b4405b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -16,6 +16,7 @@ pub struct BrokerExecutionReport { pub fill_events: Vec, pub position_events: Vec, pub account_events: Vec, + pub diagnostics: Vec, } #[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, - ) -> Result, BacktestError> { + ) -> Result<(BTreeMap, Vec), BacktestError> { let equity = self.total_equity_at(date, portfolio, data, self.execution_price_field)?; let target_weight_sum = target_weights.values().copied().sum::(); 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::>(); 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( diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 82d85a7..bbf82c6 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -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::>() .join(" | "); - let diagnostics = decision.diagnostics.join(" | "); + let diagnostics = decision + .diagnostics + .into_iter() + .chain(broker_diagnostics.into_iter()) + .collect::>() + .join(" | "); let holdings_for_day = portfolio.holdings_summary(execution_date); result.equity_curve.push(DailyEquityPoint { diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 41fae54..c8ca788 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -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]