From c7a5bedf02929f5e8e33b4ead2d8818d272b7abc Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 00:17:39 -0700 Subject: [PATCH] Explain rebalance denial reasons --- crates/fidc-core/src/broker.rs | 122 ++++++++++++++++++ crates/fidc-core/tests/explicit_order_flow.rs | 16 +++ 2 files changed, 138 insertions(+) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index af14e76..2d6a09a 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -503,6 +503,42 @@ where order_step_size, ); 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 { diagnostics.push(format!( "rebalance_target_clipped symbol={} desired={} min={} max={} provisional={}", @@ -734,6 +770,92 @@ where 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 { + 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 { + 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 { if quantity == 0 { return 0.0; diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 62a74ab..1d9af67 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -678,6 +678,22 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { .unwrap_or(0), 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]