feat(stock-pool): unify target execution, durable intent state and ETF rules
This commit is contained in:
@@ -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,"es,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,"es,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,"es,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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user