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