Explain rebalance denial reasons

This commit is contained in:
boris
2026-04-23 00:17:39 -07:00
parent 3c0ced89bf
commit c7a5bedf02
2 changed files with 138 additions and 0 deletions

View File

@@ -503,6 +503,42 @@ 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 desired_qty < current_qty
&& min_target_qty >= current_qty
&& diagnostics.len() < 16
&& let Some(reason) = self.sell_target_denial_reason(
date,
portfolio,
data,
&symbol,
current_qty,
minimum_order_quantity,
order_step_size,
)
{
diagnostics.push(format!(
"rebalance_target_denied symbol={} side=sell reason={}",
symbol, reason
));
}
if desired_qty > current_qty
&& max_target_qty <= current_qty
&& diagnostics.len() < 16
&& let Some(reason) = self.buy_target_denial_reason(
date,
portfolio,
data,
&symbol,
current_qty,
minimum_order_quantity,
order_step_size,
)
{
diagnostics.push(format!(
"rebalance_target_denied symbol={} side=buy reason={}",
symbol, reason
));
}
if provisional_target_qty != desired_qty && diagnostics.len() < 16 { if provisional_target_qty != desired_qty && diagnostics.len() < 16 {
diagnostics.push(format!( diagnostics.push(format!(
"rebalance_target_clipped symbol={} desired={} min={} max={} provisional={}", "rebalance_target_clipped symbol={} desired={} min={} max={} provisional={}",
@@ -734,6 +770,92 @@ where
gross - cost.total() gross - cost.total()
} }
fn sell_target_denial_reason(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> {
if current_qty == 0 {
return None;
}
let position = portfolio.position(symbol)?;
let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?;
let rule = self.rules.can_sell(
date,
snapshot,
candidate,
position,
self.execution_price_field,
);
if !rule.allowed {
return rule.reason;
}
let sellable = position.sellable_qty(date);
match self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
sellable.min(current_qty),
minimum_order_quantity,
order_step_size,
0,
sellable >= current_qty,
) {
Ok(quantity) => {
let quantity = quantity.min(sellable).min(current_qty);
if quantity == 0 {
Some("no sellable quantity".to_string())
} else {
None
}
}
Err(reason) => Some(reason),
}
}
fn buy_target_denial_reason(
&self,
date: NaiveDate,
_portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> Option<String> {
let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?;
let rule = self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
if !rule.allowed {
return rule.reason;
}
match self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
u32::MAX,
minimum_order_quantity,
order_step_size,
0,
false,
) {
Ok(quantity) => {
if current_qty.saturating_add(quantity) <= current_qty {
Some("no fillable buy quantity".to_string())
} else {
None
}
}
Err(reason) => Some(reason),
}
}
fn estimated_buy_cash_out(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 { fn estimated_buy_cash_out(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 {
if quantity == 0 { if quantity == 0 {
return 0.0; return 0.0;

View File

@@ -678,6 +678,22 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
.unwrap_or(0), .unwrap_or(0),
10_000 10_000
); );
assert!(
report
.diagnostics
.iter()
.any(|line| line.contains("rebalance_target_denied symbol=000001.SZ side=sell")),
"expected locked position denial diagnostics, got {:?}",
report.diagnostics
);
assert!(
report
.diagnostics
.iter()
.any(|line| line.contains("rebalance_buy_reduced symbol=000002.SZ")),
"expected unfunded buy reduction diagnostics, got {:?}",
report.diagnostics
);
} }
#[test] #[test]