feat(stock-pool): unify target execution, durable intent state and ETF rules

This commit is contained in:
boris
2026-09-12 03:55:00 +08:00
parent 29eafc79e2
commit 6ffa0346aa
26 changed files with 7223 additions and 38 deletions
+55 -28
View File
@@ -23,6 +23,9 @@ use crate::strategy::{
TargetPortfolioOrderPricing,
};
#[path="broker_stock_pool.rs"]
mod stock_pool;
#[derive(Debug, Default)]
pub struct BrokerExecutionReport {
pub order_events: Vec<OrderEvent>,
@@ -1757,6 +1760,9 @@ where
return result;
}
match intent {
OrderIntent::StockPool { contract } => self.process_stock_pool_contract(
date,portfolio,data,contract,intraday_turnover,execution_cursors,global_execution_cursor,commission_state,report,
),
OrderIntent::WithTimeInForce { .. } => unreachable!("wrapper handled above"),
OrderIntent::Shares {
symbol,
@@ -3527,6 +3533,7 @@ where
date,
sell_execution_price,
current_qty.saturating_sub(provisional_target_qty),
data.instruments().get(&symbol),
);
}
constraints.push(TargetConstraint {
@@ -3582,6 +3589,7 @@ where
date,
constraint.buy_execution_price,
target_qty - constraint.current_qty,
data.instruments().get(&constraint.symbol),
);
}
if target_qty > 0 {
@@ -4165,14 +4173,14 @@ where
u32::MAX
}
fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 {
fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32, instrument: Option<&Instrument>) -> f64 {
if quantity == 0 {
return 0.0;
}
let gross = Self::fixed_gross_amount(price, quantity);
let cost = self
.cost_model
.calculate(date, OrderSide::Sell, gross.to_f64());
.calculate_for_instrument(date, OrderSide::Sell, gross.to_f64(), instrument);
gross
.checked_sub(cost.fixed_total())
.expect("fixed-point sell proceeds underflow")
@@ -4282,14 +4290,14 @@ where
}
}
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, instrument: Option<&Instrument>) -> f64 {
if quantity == 0 {
return 0.0;
}
let gross = Self::fixed_gross_amount(price, quantity);
let cost = self
.cost_model
.calculate(date, OrderSide::Buy, gross.to_f64());
.calculate_for_instrument(date, OrderSide::Buy, gross.to_f64(), instrument);
gross
.checked_add(cost.fixed_total())
.expect("fixed-point buy cash overflow")
@@ -4328,7 +4336,7 @@ where
let minimum_execution_price =
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?;
Ok(Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity, data.instruments().get(symbol)),
portfolio.cash(),
))
}
@@ -4754,7 +4762,7 @@ where
let execution_price =
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(fillable_qty))?;
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price, data.instruments().get(symbol))
{
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
(0, Vec::new(), None, Vec::new())
@@ -4775,6 +4783,7 @@ where
OrderSide::Sell,
execution_price,
limit_price,
data.instruments().get(symbol),
) {
Ok(execution_price) => (
fillable_qty,
@@ -4900,12 +4909,13 @@ where
let leg_cash_before = portfolio.cash();
let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity);
let gross_amount = gross_money.to_f64();
let cost = self.cost_model.calculate_with_order_state(
let cost = self.cost_model.calculate_with_order_state_for_instrument(
date,
OrderSide::Sell,
gross_amount,
Some(order_id),
commission_state,
data.instruments().get(symbol),
);
let net_cash = gross_money
.checked_sub(cost.fixed_total())
@@ -5603,6 +5613,7 @@ where
price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
@@ -5719,6 +5730,7 @@ where
price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
@@ -5887,6 +5899,7 @@ where
price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
@@ -6482,7 +6495,7 @@ where
let execution_price =
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(constrained_qty))?;
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price, data.instruments().get(symbol))
{
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
(0, Vec::new(), None, Vec::new())
@@ -6503,6 +6516,7 @@ where
OrderSide::Buy,
execution_price,
limit_price,
data.instruments().get(symbol),
) {
Err(reason) => {
partial_fill_reason =
@@ -6518,6 +6532,7 @@ where
constrained_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
data.instruments().get(symbol),
);
let mut blocked_by_final_price = false;
if filled_qty > 0 {
@@ -6532,6 +6547,7 @@ where
OrderSide::Buy,
execution_price,
limit_price,
data.instruments().get(symbol),
) {
Ok(price) => execution_price = price,
Err(reason) => {
@@ -6681,12 +6697,13 @@ where
let leg_cash_before = portfolio.cash();
let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity);
let gross_amount = gross_money.to_f64();
let cost = self.cost_model.calculate_with_order_state(
let cost = self.cost_model.calculate_with_order_state_for_instrument(
date,
OrderSide::Buy,
gross_amount,
Some(order_id),
commission_state,
data.instruments().get(symbol),
);
let cash_out = gross_money
.checked_add(cost.fixed_total())
@@ -7083,6 +7100,7 @@ where
price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
instrument: Option<&Instrument>,
) -> u32 {
if !value_budget.is_finite() || value_budget <= 0.0 || !price.is_finite() || price <= 0.0 {
return 0;
@@ -7093,7 +7111,7 @@ where
self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size);
while quantity >= minimum {
if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, price, quantity),
self.estimated_buy_cash_out(date, price, quantity, instrument),
value_budget,
) {
return quantity;
@@ -7122,6 +7140,7 @@ where
fallback_price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
for _ in 0..8 {
let execution_price = snapshot
@@ -7137,6 +7156,7 @@ where
execution_price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
if resolved == quantity {
return Ok(quantity);
@@ -7152,7 +7172,7 @@ where
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price);
if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, execution_price, quantity),
self.estimated_buy_cash_out(date, execution_price, quantity, data.instruments().get(symbol)),
value_budget,
) {
return Ok(quantity);
@@ -7186,6 +7206,7 @@ where
requested_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
instrument: Option<&Instrument>,
) -> u32 {
let mut quantity =
self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size);
@@ -7199,7 +7220,7 @@ where
);
continue;
}
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross);
let cost = self.cost_model.calculate_for_instrument(date, OrderSide::Buy, gross, instrument);
let cash_out = FixedMoney::checked_sum_f64([gross, cost.total()])
.expect("buy cash must be finite fixed-point money")
.to_f64();
@@ -7326,6 +7347,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
execution_price: f64,
instrument: Option<&Instrument>,
) -> Option<&'static str> {
if !execution_price.is_finite() || execution_price <= 0.0 {
return Some("invalid execution price");
@@ -7333,6 +7355,7 @@ where
match side {
OrderSide::Buy
if self.risk_config.static_rules.reject_one_yuan_buy
&& !instrument.is_some_and(Instrument::is_exchange_traded_fund)
&& execution_price <= 1.0 =>
{
Some("one_yuan")
@@ -7370,9 +7393,10 @@ where
side: OrderSide,
execution_price: f64,
limit_price: Option<f64>,
instrument: Option<&Instrument>,
) -> Result<f64, &'static str> {
let adjusted = self.execution_price_with_limit_slippage(execution_price, limit_price);
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, adjusted) {
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, adjusted, instrument) {
Err(reason)
} else {
Ok(adjusted)
@@ -7460,6 +7484,7 @@ where
limit_price,
execution_ledger,
calibration.as_ref(),
data.instruments().get(symbol),
)? {
return Ok(Some(fill));
}
@@ -7555,6 +7580,7 @@ where
limit_price,
&IntradayExecutionLedger::default(),
None,
None,
)
.expect("test quote selection without historical calibration")
}
@@ -7579,6 +7605,7 @@ where
limit_price: Option<f64>,
execution_ledger: &IntradayExecutionLedger,
calibration: Option<&HistoricalSlippageCalibration>,
instrument: Option<&Instrument>,
) -> Result<Option<ExecutionFill>, BacktestError> {
if requested_qty == 0 {
return Ok(None);
@@ -7649,7 +7676,7 @@ where
else {
continue;
};
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price, instrument) {
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(execution_at);
continue;
@@ -7743,7 +7770,7 @@ where
let mut quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument)
{
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(execution_at);
@@ -7771,7 +7798,7 @@ where
quote_price =
self.execution_price_with_limit_slippage(quote_price, limit_price);
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, side, quote_price)
self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument)
{
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(execution_at);
@@ -7792,7 +7819,7 @@ where
}
let candidate_cost = self
.cost_model
.calculate(snapshot.date, OrderSide::Buy, candidate_gross)
.calculate_for_instrument(snapshot.date, OrderSide::Buy, candidate_gross, instrument)
.total();
let candidate_cash =
FixedMoney::checked_sum_f64([candidate_gross, candidate_cost])
@@ -7816,7 +7843,7 @@ where
quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument)
{
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(execution_at);
@@ -8755,19 +8782,19 @@ mod tests {
).unwrap();
assert_eq!(blocked.quantity, 0);
assert_eq!(blocked.unfilled_reason, Some("one_yuan"));
assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None), Err("one_yuan"));
assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None, None), Err("one_yuan"));
let limit_broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_slippage_model(SlippageModel::LimitPrice);
assert_eq!(limit_broker.execution_price_with_limit_slippage_or_rejection(
&snapshot, OrderSide::Buy, 1.2, Some(0.9)), Err("one_yuan"));
&snapshot, OrderSide::Buy, 1.2, Some(0.9), None), Err("one_yuan"));
let mut risk = FidcRiskControlConfig::default();
risk.static_rules.reject_one_yuan_buy = false;
let allowed = limit_broker.with_risk_config(risk);
assert_eq!(allowed.execution_price_with_limit_slippage_or_rejection(
&snapshot, OrderSide::Buy, 1.2, Some(0.9)), Ok(0.9));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN), Some("invalid execution price"));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9), None);
&snapshot, OrderSide::Buy, 1.2, Some(0.9), None), Ok(0.9));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN, None), Some("invalid execution price"));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9, None), None);
}
#[test]
@@ -9998,9 +10025,9 @@ mod tests {
let allocated_amount = 50_000.0;
assert_eq!(quantity, 4_900);
assert!(broker.estimated_buy_cash_out(date, execution_price, quantity) <= allocated_amount);
assert!(broker.estimated_buy_cash_out(date, execution_price, quantity, None) <= allocated_amount);
assert!(
broker.estimated_buy_cash_out(date, execution_price, quantity + 100) > allocated_amount
broker.estimated_buy_cash_out(date, execution_price, quantity + 100, None) > allocated_amount
);
}
@@ -11585,7 +11612,7 @@ mod tests {
let clock = date.and_hms_opt(9,33,0).unwrap();
let first = broker.select_execution_fill_with_ledger(
&snapshot.symbol,&snapshot,&quotes,OrderSide::Buy,MatchingType::MinuteLast,
Some(clock),Some(clock),200,100,100,100,false,None,None,None,&ledger,None,
Some(clock),Some(clock),200,100,100,100,false,None,None,None,&ledger,None,None,
).unwrap().unwrap();
assert_eq!(first.quantity,200);
assert_eq!(first.legs[0].execution_timestamp,Some(clock));
@@ -11594,7 +11621,7 @@ mod tests {
let later = clock + chrono::Duration::seconds(1);
let second = broker.select_execution_fill_with_ledger(
&snapshot.symbol,&snapshot,&quotes,OrderSide::Sell,MatchingType::MinuteLast,
Some(later),Some(later),50,100,100,100,true,None,None,None,&ledger,None,
Some(later),Some(later),50,100,100,100,true,None,None,None,&ledger,None,None,
).unwrap().unwrap();
assert_eq!(second.quantity,50);
assert_eq!(second.legs[0].execution_timestamp,Some(later));
@@ -11602,7 +11629,7 @@ mod tests {
assert_eq!(ledger.volume_consumed(&snapshot.symbol,quotes[0].timestamp),250);
let third = broker.select_execution_fill_with_ledger(
&snapshot.symbol,&snapshot,&quotes,OrderSide::Buy,MatchingType::MinuteLast,
Some(later),Some(later),100,100,100,100,false,None,None,None,&ledger,None,
Some(later),Some(later),100,100,100,100,false,None,None,None,&ledger,None,None,
).unwrap();
assert!(third.is_none());
}