Emit rebalance clipping diagnostics
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user