Explain rebalance denial reasons
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user