Improve jq microcap execution semantics

This commit is contained in:
boris
2026-04-18 18:02:50 +08:00
parent 9f4165e689
commit 0e2c25e4c4
26 changed files with 5058 additions and 362 deletions

View File

@@ -1,14 +1,14 @@
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::CostModel;
use crate::data::{DataSet, PriceField};
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
use crate::engine::BacktestError;
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
use crate::portfolio::PortfolioState;
use crate::rules::EquityRuleHooks;
use crate::strategy::StrategyDecision;
use crate::strategy::{OrderIntent, StrategyDecision};
#[derive(Debug, Default)]
pub struct BrokerExecutionReport {
@@ -18,10 +18,23 @@ pub struct BrokerExecutionReport {
pub account_events: Vec<AccountEvent>,
}
#[derive(Debug, Clone, Copy)]
struct ExecutionFill {
price: f64,
quantity: u32,
next_cursor: NaiveDateTime,
}
pub struct BrokerSimulator<C, R> {
cost_model: C,
rules: R,
board_lot_size: u32,
execution_price_field: PriceField,
volume_percent: f64,
volume_limit: bool,
inactive_limit: bool,
liquidity_limit: bool,
intraday_execution_start_time: Option<NaiveTime>,
}
impl<C, R> BrokerSimulator<C, R> {
@@ -30,8 +43,57 @@ impl<C, R> BrokerSimulator<C, R> {
cost_model,
rules,
board_lot_size: 100,
execution_price_field: PriceField::Open,
volume_percent: 0.25,
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
intraday_execution_start_time: None,
}
}
pub fn new_with_execution_price(
cost_model: C,
rules: R,
execution_price_field: PriceField,
) -> Self {
Self {
cost_model,
rules,
board_lot_size: 100,
execution_price_field,
volume_percent: 0.25,
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
intraday_execution_start_time: None,
}
}
pub fn with_volume_limit(mut self, enabled: bool) -> Self {
self.volume_limit = enabled;
self
}
pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
self.inactive_limit = enabled;
self
}
pub fn with_liquidity_limit(mut self, enabled: bool) -> Self {
self.liquidity_limit = enabled;
self
}
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
self.volume_percent = volume_percent;
self
}
pub fn with_intraday_execution_start_time(mut self, start_time: NaiveTime) -> Self {
self.intraday_execution_start_time = Some(start_time);
self
}
}
impl<C, R> BrokerSimulator<C, R>
@@ -39,6 +101,18 @@ where
C: CostModel,
R: EquityRuleHooks,
{
fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.buy_price(self.execution_price_field)
}
fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.sell_price(self.execution_price_field)
}
fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.price(self.execution_price_field)
}
pub fn execute(
&self,
date: NaiveDate,
@@ -47,6 +121,26 @@ where
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
let mut intraday_turnover = BTreeMap::<String, u32>::new();
let mut execution_cursors = BTreeMap::<String, NaiveDateTime>::new();
let mut global_execution_cursor = None::<NaiveDateTime>;
if !decision.order_intents.is_empty() {
for intent in &decision.order_intents {
self.process_order_intent(
date,
portfolio,
data,
intent,
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut report,
)?;
}
portfolio.prune_flat_positions();
return Ok(report);
}
let target_quantities = if decision.rebalance {
self.target_quantities(date, portfolio, data, &decision.target_weights)?
} else {
@@ -59,7 +153,10 @@ where
sell_symbols.extend(target_quantities.keys().cloned());
for symbol in sell_symbols {
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0);
let current_qty = portfolio
.position(&symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
if current_qty == 0 {
continue;
}
@@ -81,6 +178,9 @@ where
&symbol,
requested_qty,
sell_reason(decision, &symbol),
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut report,
)?;
}
@@ -88,7 +188,10 @@ where
if decision.rebalance {
for (symbol, target_qty) in target_quantities {
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0);
let current_qty = portfolio
.position(&symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
if target_qty > current_qty {
let requested_qty = target_qty - current_qty;
self.process_buy(
@@ -98,6 +201,10 @@ where
&symbol,
requested_qty,
"rebalance_buy",
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
None,
&mut report,
)?;
}
@@ -108,6 +215,53 @@ where
Ok(report)
}
fn process_order_intent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
intent: &OrderIntent,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
match intent {
OrderIntent::TargetValue {
symbol,
target_value,
reason,
} => self.process_target_value(
date,
portfolio,
data,
symbol,
*target_value,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
report,
),
OrderIntent::Value {
symbol,
value,
reason,
} => self.process_value(
date,
portfolio,
data,
symbol,
*value,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
report,
),
}
}
fn target_quantities(
&self,
date: NaiveDate,
@@ -120,14 +274,14 @@ where
for (symbol, weight) in target_weights {
let price = data
.price(date, symbol, PriceField::Open)
.price(date, symbol, self.execution_price_field)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.clone(),
field: "open",
field: price_field_name(self.execution_price_field),
})?;
let raw_qty = ((equity * weight) / price).floor() as u32;
let rounded_qty = self.round_buy_quantity(raw_qty);
let rounded_qty = self.round_buy_quantity(raw_qty, self.round_lot(data, symbol));
targets.insert(symbol.clone(), rounded_qty);
}
@@ -142,6 +296,9 @@ where
symbol: &str,
requested_qty: u32,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
@@ -150,22 +307,55 @@ where
return Ok(());
};
let rule = self.rules.can_sell(date, snapshot, candidate, position);
let rule = self.rules.can_sell(
date,
snapshot,
candidate,
position,
self.execution_price_field,
);
if !rule.allowed {
let status = match rule.reason.as_deref() {
Some("paused")
| Some("sell disabled by eligibility flags")
| Some("open at or below lower limit") => OrderStatus::Canceled,
_ => OrderStatus::Rejected,
};
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
status,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
});
return Ok(());
}
let sellable = position.sellable_qty(date);
let filled_qty = requested_qty.min(sellable);
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
requested_qty.min(sellable),
self.round_lot(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
);
let filled_qty = match market_limited_qty {
Ok(quantity) => quantity.min(sellable),
Err(limit_reason) => {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {limit_reason}"),
});
return Ok(());
}
};
if filled_qty == 0 {
report.order_events.push(OrderEvent {
date,
@@ -180,15 +370,42 @@ where
}
let cash_before = portfolio.cash();
let gross_amount = snapshot.open * filled_qty as f64;
let fill = self.resolve_execution_fill(
date,
symbol,
OrderSide::Sell,
snapshot,
data,
filled_qty,
self.round_lot(data, symbol),
execution_cursors,
None,
None,
None,
);
let (filled_qty, execution_price) = if let Some(fill) = fill {
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
*global_execution_cursor = Some(fill.next_cursor);
}
(fill.quantity, fill.price)
} else {
(
filled_qty,
self.sell_price(snapshot),
)
};
let gross_amount = execution_price * filled_qty as f64;
let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount);
let net_cash = gross_amount - cost.total();
let realized_pnl = portfolio
.position_mut(symbol)
.sell(filled_qty, snapshot.open)
.sell(filled_qty, execution_price)
.map_err(BacktestError::Execution)?;
portfolio.apply_cash_delta(net_cash);
portfolio.prune_flat_positions();
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let status = if filled_qty < requested_qty {
OrderStatus::PartiallyFilled
@@ -210,7 +427,7 @@ where
symbol: symbol.to_string(),
side: OrderSide::Sell,
quantity: filled_qty,
price: snapshot.open,
price: execution_price,
gross_amount,
commission: cost.commission,
stamp_tax: cost.stamp_tax,
@@ -221,7 +438,10 @@ where
date,
symbol: symbol.to_string(),
delta_quantity: -(filled_qty as i32),
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0),
quantity_after: portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0),
average_cost: portfolio
.position(symbol)
.map(|pos| pos.average_cost)
@@ -239,6 +459,139 @@ where
Ok(())
}
fn process_target_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_value: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let price = data
.market(date, symbol)
.map(|snapshot| self.sizing_price(snapshot))
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?;
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let current_value = price * current_qty as f64;
let target_qty = self.round_buy_quantity(
((target_value.max(0.0)) / price).floor() as u32,
self.round_lot(data, symbol),
);
if current_qty > target_qty {
self.process_sell(
date,
portfolio,
data,
symbol,
current_qty - target_qty,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
report,
)?;
} else if target_qty > current_qty {
self.process_buy(
date,
portfolio,
data,
symbol,
target_qty - current_qty,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
None,
report,
)?;
} else if (current_value - target_value).abs() <= f64::EPSILON {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: if current_qty > 0 {
OrderSide::Sell
} else {
OrderSide::Buy
},
requested_quantity: 0,
filled_quantity: 0,
status: OrderStatus::Filled,
reason: format!("{reason}: already at target value"),
});
}
Ok(())
}
fn process_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
value: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if value.abs() <= f64::EPSILON {
return Ok(());
}
let snapshot = data
.market(date, symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?;
let price = self.sizing_price(snapshot);
let requested_qty =
self.round_buy_quantity(((value.abs()) / price).floor() as u32, self.round_lot(data, symbol));
if value > 0.0 {
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
Some(value.abs()),
report,
)
} else {
self.process_sell(
date,
portfolio,
data,
symbol,
requested_qty,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
report,
)
}
}
fn process_buy(
&self,
date: NaiveDate,
@@ -247,12 +600,18 @@ where
symbol: &str,
requested_qty: u32,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
value_budget: Option<f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
let rule = self.rules.can_buy(date, snapshot, candidate);
let rule = self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
if !rule.allowed {
report.order_events.push(OrderEvent {
date,
@@ -266,8 +625,59 @@ where
return Ok(());
}
let filled_qty =
self.affordable_buy_quantity(portfolio.cash(), snapshot.open, requested_qty);
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
requested_qty,
self.round_lot(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
);
let constrained_qty = match market_limited_qty {
Ok(quantity) => quantity,
Err(limit_reason) => {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {limit_reason}"),
});
return Ok(());
}
};
let fill = self.resolve_execution_fill(
date,
symbol,
OrderSide::Buy,
snapshot,
data,
constrained_qty,
self.round_lot(data, symbol),
execution_cursors,
None,
Some(portfolio.cash()),
value_budget,
);
let (filled_qty, execution_price) = if let Some(fill) = fill {
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
*global_execution_cursor = Some(fill.next_cursor);
}
(fill.quantity, fill.price)
} else {
let execution_price = self.buy_price(snapshot);
let filled_qty = self.affordable_buy_quantity(
portfolio.cash(),
value_budget,
execution_price,
constrained_qty,
self.round_lot(data, symbol),
);
(filled_qty, execution_price)
};
if filled_qty == 0 {
report.order_events.push(OrderEvent {
date,
@@ -282,12 +692,15 @@ where
}
let cash_before = portfolio.cash();
let gross_amount = snapshot.open * filled_qty as f64;
let gross_amount = execution_price * filled_qty as f64;
let cost = self.cost_model.calculate(OrderSide::Buy, gross_amount);
let cash_out = gross_amount + cost.total();
portfolio.apply_cash_delta(-cash_out);
portfolio.position_mut(symbol).buy(date, filled_qty, snapshot.open);
portfolio
.position_mut(symbol)
.buy(date, filled_qty, execution_price);
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let status = if filled_qty < requested_qty {
OrderStatus::PartiallyFilled
@@ -309,7 +722,7 @@ where
symbol: symbol.to_string(),
side: OrderSide::Buy,
quantity: filled_qty,
price: snapshot.open,
price: execution_price,
gross_amount,
commission: cost.commission,
stamp_tax: cost.stamp_tax,
@@ -320,7 +733,10 @@ where
date,
symbol: symbol.to_string(),
delta_quantity: filled_qty as i32,
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0),
quantity_after: portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0),
average_cost: portfolio
.position(symbol)
.map(|pos| pos.average_cost)
@@ -347,38 +763,304 @@ where
) -> Result<f64, BacktestError> {
let mut market_value = 0.0;
for position in portfolio.positions().values() {
let price = data
.price(date, &position.symbol, field)
.ok_or_else(|| BacktestError::MissingPrice {
let price = data.price(date, &position.symbol, field).ok_or_else(|| {
BacktestError::MissingPrice {
date,
symbol: position.symbol.clone(),
field: match field {
PriceField::Open => "open",
PriceField::Close => "close",
PriceField::Last => "last",
},
})?;
}
})?;
market_value += price * position.quantity as f64;
}
Ok(portfolio.cash() + market_value)
}
fn round_buy_quantity(&self, quantity: u32) -> u32 {
(quantity / self.board_lot_size) * self.board_lot_size
fn round_lot(&self, data: &DataSet, symbol: &str) -> u32 {
data.instruments()
.get(symbol)
.map(|instrument| instrument.effective_round_lot())
.unwrap_or(self.board_lot_size.max(1))
}
fn affordable_buy_quantity(&self, cash: f64, price: f64, requested_qty: u32) -> u32 {
let mut quantity = self.round_buy_quantity(requested_qty);
fn round_buy_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
let lot = round_lot.max(1);
(quantity / lot) * lot
}
fn affordable_buy_quantity(
&self,
cash: f64,
gross_limit: Option<f64>,
price: f64,
requested_qty: u32,
round_lot: u32,
) -> u32 {
let lot = round_lot.max(1);
let mut quantity = self.round_buy_quantity(requested_qty, lot);
while quantity > 0 {
let gross = price * quantity as f64;
if gross_limit.is_some_and(|limit| gross > limit + 1e-6) {
quantity = quantity.saturating_sub(lot);
continue;
}
let cost = self.cost_model.calculate(OrderSide::Buy, gross);
if gross + cost.total() <= cash + 1e-6 {
return quantity;
}
quantity = quantity.saturating_sub(self.board_lot_size);
quantity = quantity.saturating_sub(lot);
}
0
}
fn market_fillable_quantity(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
requested_qty: u32,
round_lot: u32,
consumed_turnover: u32,
) -> Result<u32, String> {
if requested_qty == 0 {
return Ok(0);
}
if self.inactive_limit && snapshot.tick_volume == 0 {
return Err("tick no volume".to_string());
}
let mut max_fill = requested_qty;
let lot = round_lot.max(1);
if self.liquidity_limit {
let top_level_liquidity = match side {
OrderSide::Buy => snapshot.liquidity_for_buy(),
OrderSide::Sell => snapshot.liquidity_for_sell(),
}
.min(u32::MAX as u64) as u32;
if top_level_liquidity == 0 {
return Err("no quote liquidity".to_string());
}
max_fill = max_fill.min(self.round_buy_quantity(top_level_liquidity, lot));
}
if self.volume_limit {
let raw_limit =
((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
- consumed_turnover as i64;
if raw_limit <= 0 {
return Err("tick volume limit".to_string());
}
let volume_limited = self.round_buy_quantity(raw_limit as u32, lot);
if volume_limited == 0 {
return Err("tick volume limit".to_string());
}
max_fill = max_fill.min(volume_limited);
}
Ok(max_fill)
}
fn resolve_execution_fill(
&self,
date: NaiveDate,
symbol: &str,
side: OrderSide,
_snapshot: &crate::data::DailyMarketSnapshot,
data: &DataSet,
requested_qty: u32,
round_lot: u32,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: Option<NaiveDateTime>,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
) -> Option<ExecutionFill> {
if self.execution_price_field != PriceField::Last {
return None;
}
let start_cursor = execution_cursors
.get(symbol)
.copied()
.into_iter()
.chain(global_execution_cursor)
.chain(
self.intraday_execution_start_time
.map(|start_time| date.and_time(start_time)),
)
.max();
let quotes = data.execution_quotes_on(date, symbol);
self.select_execution_fill(
quotes,
side,
start_cursor,
requested_qty,
round_lot,
cash_limit,
gross_limit,
)
}
fn select_execution_fill(
&self,
quotes: &[IntradayExecutionQuote],
side: OrderSide,
start_cursor: Option<NaiveDateTime>,
requested_qty: u32,
round_lot: u32,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
) -> Option<ExecutionFill> {
if requested_qty == 0 {
return None;
}
let lot = round_lot.max(1);
let mut filled_qty = 0_u32;
let mut gross_amount = 0.0_f64;
let mut last_timestamp = None;
let mut last_quote_price = None;
for quote in quotes {
if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
continue;
}
let fallback_quote_price = match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
};
if fallback_quote_price.is_some() {
last_quote_price = fallback_quote_price;
last_timestamp = Some(quote.timestamp);
}
// Approximate JoinQuant market-order fills with the evolving L1 book after
// the decision time instead of trade VWAP. This keeps quantities/prices
// closer to the observed 10:18 execution logs.
if quote.volume_delta == 0 {
continue;
}
let quote_price = match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
};
let Some(quote_price) = quote_price else {
continue;
};
if !quote_price.is_finite() || quote_price <= 0.0 {
continue;
}
let top_level_liquidity = match side {
OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_volume,
};
let available_qty = top_level_liquidity
.saturating_mul(lot as u64)
.min(u32::MAX as u64) as u32;
if available_qty == 0 {
continue;
}
let remaining_qty = requested_qty.saturating_sub(filled_qty);
if remaining_qty == 0 {
break;
}
let mut take_qty = remaining_qty.min(available_qty);
take_qty = self.round_buy_quantity(take_qty, lot);
if take_qty == 0 {
continue;
}
if let Some(cash) = cash_limit {
while take_qty > 0 {
let candidate_gross = gross_amount + quote_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
take_qty = take_qty.saturating_sub(lot);
continue;
}
let candidate_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross);
if candidate_gross + candidate_cost.total() <= cash + 1e-6 {
break;
}
take_qty = take_qty.saturating_sub(lot);
}
if take_qty == 0 {
break;
}
}
gross_amount += quote_price * take_qty as f64;
filled_qty += take_qty;
last_timestamp = Some(quote.timestamp);
if filled_qty >= requested_qty {
break;
}
}
if filled_qty < requested_qty {
let remaining_qty = requested_qty.saturating_sub(filled_qty);
let mut residual_qty = self.round_buy_quantity(remaining_qty, lot);
if residual_qty > 0 {
if let Some(residual_price) = last_quote_price {
if let Some(cash) = cash_limit {
while residual_qty > 0 {
let candidate_gross = gross_amount + residual_price * residual_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
residual_qty = residual_qty.saturating_sub(lot);
continue;
}
let candidate_cost =
self.cost_model.calculate(OrderSide::Buy, candidate_gross);
if candidate_gross + candidate_cost.total() <= cash + 1e-6 {
break;
}
residual_qty = residual_qty.saturating_sub(lot);
}
}
if residual_qty > 0 {
let execution_price = match side {
OrderSide::Buy => residual_price,
OrderSide::Sell => residual_price,
};
gross_amount += execution_price * residual_qty as f64;
filled_qty += residual_qty;
}
}
}
}
if filled_qty == 0 {
return None;
}
Some(ExecutionFill {
price: gross_amount / filled_qty as f64,
quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
})
}
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
matches!(
reason,
"stop_loss_exit" | "take_profit_exit" | "replacement_after_stop_loss_exit"
| "replacement_after_take_profit_exit"
)
}
}
fn price_field_name(field: PriceField) -> &'static str {
match field {
PriceField::Open => "open",
PriceField::Close => "close",
PriceField::Last => "last",
}
}
fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {