Files
fidc-backtest-engine/crates/fidc-core/src/broker.rs
2026-04-23 06:34:07 -07:00

4120 lines
137 KiB
Rust

use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet};
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::CostModel;
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
use crate::engine::BacktestError;
use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
use crate::portfolio::PortfolioState;
use crate::rules::EquityRuleHooks;
use crate::strategy::{OpenOrderView, OrderIntent, StrategyDecision};
#[derive(Debug, Default)]
pub struct BrokerExecutionReport {
pub order_events: Vec<OrderEvent>,
pub fill_events: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
pub process_events: Vec<ProcessEvent>,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
struct ExecutionLeg {
price: f64,
quantity: u32,
}
#[derive(Debug, Clone)]
struct ExecutionFill {
quantity: u32,
next_cursor: NaiveDateTime,
legs: Vec<ExecutionLeg>,
unfilled_reason: Option<&'static str>,
}
#[derive(Debug, Clone)]
struct OpenOrder {
order_id: u64,
symbol: String,
side: OrderSide,
requested_quantity: u32,
filled_quantity: u32,
remaining_quantity: u32,
limit_price: f64,
reason: String,
}
#[derive(Debug, Clone)]
struct TargetConstraint {
symbol: String,
current_qty: u32,
desired_qty: u32,
min_target_qty: u32,
max_target_qty: u32,
provisional_target_qty: u32,
price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchingType {
OpenAuction,
CurrentBarClose,
NextBarOpen,
NextTickLast,
NextTickBestOwn,
NextTickBestCounterparty,
CounterpartyOffer,
Vwap,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SlippageModel {
None,
PriceRatio(f64),
TickSize(f64),
LimitPrice,
}
pub struct BrokerSimulator<C, R> {
cost_model: C,
rules: R,
board_lot_size: u32,
matching_type: MatchingType,
execution_price_field: PriceField,
slippage_model: SlippageModel,
volume_percent: f64,
volume_limit: bool,
inactive_limit: bool,
liquidity_limit: bool,
intraday_execution_start_time: Option<NaiveTime>,
next_order_id: Cell<u64>,
open_orders: RefCell<Vec<OpenOrder>>,
}
impl<C, R> BrokerSimulator<C, R> {
pub fn new(cost_model: C, rules: R) -> Self {
Self {
cost_model,
rules,
board_lot_size: 100,
matching_type: matching_type_from_price_field(PriceField::Open),
execution_price_field: PriceField::Open,
slippage_model: SlippageModel::None,
volume_percent: 0.25,
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
intraday_execution_start_time: None,
next_order_id: Cell::new(1),
open_orders: RefCell::new(Vec::new()),
}
}
pub fn new_with_execution_price(
cost_model: C,
rules: R,
execution_price_field: PriceField,
) -> Self {
Self {
cost_model,
rules,
board_lot_size: 100,
matching_type: matching_type_from_price_field(execution_price_field),
execution_price_field,
slippage_model: SlippageModel::None,
volume_percent: 0.25,
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
intraday_execution_start_time: None,
next_order_id: Cell::new(1),
open_orders: RefCell::new(Vec::new()),
}
}
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
}
pub fn with_matching_type(mut self, matching_type: MatchingType) -> Self {
self.matching_type = matching_type;
self
}
pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
self.slippage_model = slippage_model;
self
}
pub fn open_order_views(&self) -> Vec<OpenOrderView> {
self.open_orders
.borrow()
.iter()
.map(|order| OpenOrderView {
order_id: order.order_id,
symbol: order.symbol.clone(),
side: order.side,
requested_quantity: order.requested_quantity,
filled_quantity: order.filled_quantity,
remaining_quantity: order.remaining_quantity,
limit_price: order.limit_price,
reason: order.reason.clone(),
})
.collect()
}
}
impl<C, R> BrokerSimulator<C, R>
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)
}
fn snapshot_execution_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
let raw_price = if self.execution_price_field == PriceField::Last
&& self.intraday_execution_start_time.is_some()
{
let _ = side;
snapshot.price(PriceField::Last)
} else {
match side {
OrderSide::Buy => self.buy_price(snapshot),
OrderSide::Sell => self.sell_price(snapshot),
}
};
self.apply_slippage(snapshot, side, raw_price)
}
fn is_open_auction_matching(&self) -> bool {
self.execution_price_field == PriceField::DayOpen
}
fn apply_slippage(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
raw_price: f64,
) -> f64 {
if !raw_price.is_finite() || raw_price <= 0.0 {
return raw_price;
}
if self.is_open_auction_matching() {
return self.clamp_execution_price(snapshot, side, raw_price);
}
let adjusted = match self.slippage_model {
SlippageModel::None => raw_price,
SlippageModel::PriceRatio(ratio) => {
let ratio = ratio.max(0.0);
match side {
OrderSide::Buy => raw_price * (1.0 + ratio),
OrderSide::Sell => raw_price * (1.0 - ratio),
}
}
SlippageModel::TickSize(ticks) => {
let tick = snapshot.effective_price_tick();
let ticks = ticks.max(0.0);
match side {
OrderSide::Buy => raw_price + tick * ticks,
OrderSide::Sell => raw_price - tick * ticks,
}
}
SlippageModel::LimitPrice => raw_price,
};
self.clamp_execution_price(snapshot, side, adjusted)
}
fn clamp_execution_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
adjusted_price: f64,
) -> f64 {
if !adjusted_price.is_finite() {
return adjusted_price;
}
let mut bounded = adjusted_price.max(snapshot.effective_price_tick());
match side {
OrderSide::Buy => {
if snapshot.upper_limit.is_finite() && snapshot.upper_limit > 0.0 {
bounded = bounded.min(snapshot.upper_limit);
}
}
OrderSide::Sell => {
if snapshot.lower_limit.is_finite() && snapshot.lower_limit > 0.0 {
bounded = bounded.max(snapshot.lower_limit);
}
}
}
bounded
}
fn quote_execution_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
raw_price: f64,
) -> f64 {
self.apply_slippage(snapshot, side, raw_price)
}
fn select_quote_reference_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
quote: &IntradayExecutionQuote,
side: OrderSide,
) -> Option<f64> {
let raw_price = match self.matching_type {
MatchingType::NextTickBestOwn => match side {
OrderSide::Buy => {
if quote.bid1.is_finite() && quote.bid1 > 0.0 {
Some(quote.bid1)
} else {
quote
.last_price
.is_finite()
.then_some(quote.last_price)
.filter(|price| *price > 0.0)
}
}
OrderSide::Sell => {
if quote.ask1.is_finite() && quote.ask1 > 0.0 {
Some(quote.ask1)
} else {
quote
.last_price
.is_finite()
.then_some(quote.last_price)
.filter(|price| *price > 0.0)
}
}
},
MatchingType::NextTickBestCounterparty | MatchingType::CounterpartyOffer => {
match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
}
}
MatchingType::NextTickLast | MatchingType::Vwap => {
if quote.last_price.is_finite() && quote.last_price > 0.0 {
Some(quote.last_price)
} else {
match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
}
}
}
_ => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
}?;
let execution_price = self.quote_execution_price(snapshot, side, raw_price);
if execution_price.is_finite() && execution_price > 0.0 {
Some(execution_price)
} else {
None
}
}
pub fn execute(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
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>;
let mut commission_state = BTreeMap::<u64, f64>::new();
self.process_open_orders(
date,
portfolio,
data,
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut commission_state,
&mut report,
)?;
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 commission_state,
&mut report,
)?;
}
portfolio.prune_flat_positions();
return Ok(report);
}
let (target_quantities, rebalance_diagnostics) = if decision.rebalance {
self.target_quantities(date, portfolio, data, &decision.target_weights)?
} else {
(BTreeMap::new(), Vec::new())
};
report.diagnostics.extend(rebalance_diagnostics);
let mut sell_symbols = BTreeSet::new();
sell_symbols.extend(portfolio.positions().keys().cloned());
sell_symbols.extend(decision.exit_symbols.iter().cloned());
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);
if current_qty == 0 {
continue;
}
let target_qty = if decision.exit_symbols.contains(&symbol) {
0
} else if decision.rebalance {
*target_quantities.get(&symbol).unwrap_or(&0)
} else {
current_qty
};
if current_qty > target_qty {
let requested_qty = current_qty - target_qty;
self.process_sell(
date,
portfolio,
data,
&symbol,
requested_qty,
self.reserve_order_id(),
sell_reason(decision, &symbol),
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut commission_state,
None,
false,
true,
&mut report,
)?;
}
}
if decision.rebalance {
for (symbol, target_qty) in target_quantities {
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(
date,
portfolio,
data,
&symbol,
requested_qty,
self.reserve_order_id(),
"rebalance_buy",
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut commission_state,
None,
None,
false,
true,
&mut report,
)?;
}
}
}
portfolio.prune_flat_positions();
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>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
match intent {
OrderIntent::Shares {
symbol,
quantity,
reason,
} => self.process_shares(
date,
portfolio,
data,
symbol,
*quantity,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::LimitShares {
symbol,
quantity,
limit_price,
reason,
} => self.process_limit_shares(
date,
portfolio,
data,
symbol,
*quantity,
*limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::Lots {
symbol,
lots,
reason,
} => self.process_lots(
date,
portfolio,
data,
symbol,
*lots,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::LimitLots {
symbol,
lots,
limit_price,
reason,
} => self.process_limit_lots(
date,
portfolio,
data,
symbol,
*lots,
*limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::TargetShares {
symbol,
target_quantity,
reason,
} => self.process_target_shares(
date,
portfolio,
data,
symbol,
*target_quantity,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::LimitTargetShares {
symbol,
target_quantity,
limit_price,
reason,
} => self.process_limit_target_shares(
date,
portfolio,
data,
symbol,
*target_quantity,
*limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::TargetValue {
symbol,
target_value,
reason,
} => self.process_target_value(
date,
portfolio,
data,
symbol,
*target_value,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::LimitTargetValue {
symbol,
target_value,
limit_price,
reason,
} => self.process_limit_target_value(
date,
portfolio,
data,
symbol,
*target_value,
*limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::Value {
symbol,
value,
reason,
} => self.process_value(
date,
portfolio,
data,
symbol,
*value,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::LimitValue {
symbol,
value,
limit_price,
reason,
} => self.process_limit_value(
date,
portfolio,
data,
symbol,
*value,
*limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::Percent {
symbol,
percent,
reason,
} => self.process_percent(
date,
portfolio,
data,
symbol,
*percent,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::LimitPercent {
symbol,
percent,
limit_price,
reason,
} => self.process_limit_percent(
date,
portfolio,
data,
symbol,
*percent,
*limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::TargetPercent {
symbol,
target_percent,
reason,
} => self.process_target_percent(
date,
portfolio,
data,
symbol,
*target_percent,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::LimitTargetPercent {
symbol,
target_percent,
limit_price,
reason,
} => self.process_limit_target_percent(
date,
portfolio,
data,
symbol,
*target_percent,
*limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::TargetPortfolioSmart {
target_weights,
order_prices,
valuation_prices,
reason,
} => self.process_target_portfolio_smart(
date,
portfolio,
data,
target_weights,
order_prices.as_ref(),
valuation_prices.as_ref(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::CancelOrder { order_id, reason } => {
self.cancel_open_order(date, *order_id, reason, report);
Ok(())
}
OrderIntent::CancelSymbol { symbol, reason } => {
self.cancel_open_orders_for_symbol(date, symbol, reason, report);
Ok(())
}
OrderIntent::CancelAll { reason } => {
self.cancel_all_open_orders(date, reason, report);
Ok(())
}
}
}
fn process_limit_shares(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
quantity: i32,
limit_price: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
self.process_limit_shares_internal(
date,
portfolio,
data,
symbol,
quantity,
limit_price,
reason,
None,
true,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
fn process_limit_shares_internal(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
quantity: i32,
limit_price: f64,
reason: &str,
existing_order_id: Option<u64>,
emit_creation_events: bool,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if quantity == 0 {
return Ok(());
}
let order_id = existing_order_id.unwrap_or_else(|| self.reserve_order_id());
if quantity > 0 {
let requested_qty = self.round_buy_quantity(
quantity as u32,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
if requested_qty == 0 {
return Ok(());
}
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
order_id,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
Some(limit_price),
true,
emit_creation_events,
report,
)
} else {
self.process_sell(
date,
portfolio,
data,
symbol,
quantity.unsigned_abs(),
order_id,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(limit_price),
true,
emit_creation_events,
report,
)
}
}
fn process_limit_lots(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
lots: i32,
limit_price: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let round_lot = self.round_lot(data, symbol);
let requested_quantity = lots.saturating_abs() as u32 * round_lot;
let signed_quantity = if lots >= 0 {
requested_quantity as i32
} else {
-(requested_quantity as i32)
};
self.process_limit_shares(
date,
portfolio,
data,
symbol,
signed_quantity,
limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
fn reserve_order_id(&self) -> u64 {
let order_id = self.next_order_id.get();
self.next_order_id.set(order_id.saturating_add(1));
order_id
}
fn upsert_open_order(&self, open_order: OpenOrder) {
let mut open_orders = self.open_orders.borrow_mut();
open_orders.retain(|existing| existing.order_id != open_order.order_id);
open_orders.push(open_order);
}
fn clear_open_order(&self, order_id: u64) {
self.open_orders
.borrow_mut()
.retain(|existing| existing.order_id != order_id);
}
fn extend_report(into: &mut BrokerExecutionReport, mut other: BrokerExecutionReport) {
into.order_events.append(&mut other.order_events);
into.fill_events.append(&mut other.fill_events);
into.position_events.append(&mut other.position_events);
into.account_events.append(&mut other.account_events);
into.process_events.append(&mut other.process_events);
into.diagnostics.append(&mut other.diagnostics);
}
fn reserved_open_sell_quantity(&self, symbol: &str, exclude_order_id: Option<u64>) -> u32 {
self.open_orders
.borrow()
.iter()
.filter(|order| {
order.side == OrderSide::Sell
&& order.symbol == symbol
&& exclude_order_id.is_none_or(|order_id| order.order_id != order_id)
})
.map(|order| order.remaining_quantity)
.sum()
}
fn process_open_orders(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let pending_orders = {
let mut open_orders = self.open_orders.borrow_mut();
std::mem::take(&mut *open_orders)
};
for order in pending_orders {
let signed_quantity = if order.side == OrderSide::Buy {
order.remaining_quantity as i32
} else {
-(order.remaining_quantity as i32)
};
self.process_limit_shares_internal(
date,
portfolio,
data,
&order.symbol,
signed_quantity,
order.limit_price,
&order.reason,
Some(order.order_id),
false,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)?;
}
Ok(())
}
fn cancel_open_order(
&self,
date: NaiveDate,
order_id: u64,
reason: &str,
report: &mut BrokerExecutionReport,
) {
let canceled = {
let mut open_orders = self.open_orders.borrow_mut();
if let Some(index) = open_orders
.iter()
.position(|order| order.order_id == order_id)
{
Some(open_orders.remove(index))
} else {
None
}
};
if let Some(order) = canceled {
self.emit_user_canceled_open_order(date, order, reason, report);
} else {
report.process_events.push(ProcessEvent {
date,
kind: ProcessEventKind::OrderCancellationReject,
order_id: Some(order_id),
symbol: None,
side: None,
detail: format!("reason={reason} status=not_found"),
});
}
}
fn cancel_open_orders_for_symbol(
&self,
date: NaiveDate,
symbol: &str,
reason: &str,
report: &mut BrokerExecutionReport,
) {
let canceled = {
let mut open_orders = self.open_orders.borrow_mut();
let mut canceled = Vec::new();
let mut retained = Vec::with_capacity(open_orders.len());
for order in open_orders.drain(..) {
if order.symbol == symbol {
canceled.push(order);
} else {
retained.push(order);
}
}
*open_orders = retained;
canceled
};
if canceled.is_empty() {
report.process_events.push(ProcessEvent {
date,
kind: ProcessEventKind::OrderCancellationReject,
order_id: None,
symbol: Some(symbol.to_string()),
side: None,
detail: format!("reason={reason} status=no_open_orders_for_symbol"),
});
}
for order in canceled {
self.emit_user_canceled_open_order(date, order, reason, report);
}
}
fn cancel_all_open_orders(
&self,
date: NaiveDate,
reason: &str,
report: &mut BrokerExecutionReport,
) {
let canceled = {
let mut open_orders = self.open_orders.borrow_mut();
std::mem::take(&mut *open_orders)
};
if canceled.is_empty() {
report.process_events.push(ProcessEvent {
date,
kind: ProcessEventKind::OrderCancellationReject,
order_id: None,
symbol: None,
side: None,
detail: format!("reason={reason} status=no_open_orders"),
});
}
for order in canceled {
self.emit_user_canceled_open_order(date, order, reason, report);
}
}
fn emit_user_canceled_open_order(
&self,
date: NaiveDate,
order: OpenOrder,
reason: &str,
report: &mut BrokerExecutionReport,
) {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderPendingCancel,
order.order_id,
&order.symbol,
order.side,
format!("reason={reason}"),
);
report.order_events.push(OrderEvent {
date,
order_id: Some(order.order_id),
symbol: order.symbol.clone(),
side: order.side,
requested_quantity: order.requested_quantity,
filled_quantity: order.filled_quantity,
status: OrderStatus::Canceled,
reason: format!("{reason}: canceled by user"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderCancellationPass,
order.order_id,
&order.symbol,
order.side,
format!(
"status=Canceled requested_quantity={} filled_quantity={}",
order.requested_quantity, order.filled_quantity
),
);
}
pub fn after_trading(&self, date: NaiveDate) -> BrokerExecutionReport {
let mut report = BrokerExecutionReport::default();
let pending = {
let mut open_orders = self.open_orders.borrow_mut();
std::mem::take(&mut *open_orders)
};
for order in pending {
let market_close_reason = format!(
"Order Rejected: {} can not match. Market close.",
order.symbol
);
report.order_events.push(OrderEvent {
date,
order_id: Some(order.order_id),
symbol: order.symbol.clone(),
side: order.side,
requested_quantity: order.requested_quantity,
filled_quantity: order.filled_quantity,
status: OrderStatus::Rejected,
reason: market_close_reason.clone(),
});
Self::emit_order_process_event(
&mut report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order.order_id,
&order.symbol,
order.side,
format!(
"status=Rejected requested_quantity={} filled_quantity={} reason={market_close_reason}",
order.requested_quantity, order.filled_quantity
),
);
}
report
}
fn emit_order_process_event(
report: &mut BrokerExecutionReport,
date: NaiveDate,
kind: ProcessEventKind,
order_id: u64,
symbol: &str,
side: OrderSide,
detail: impl Into<String>,
) {
report.process_events.push(ProcessEvent {
date,
kind,
order_id: Some(order_id),
symbol: Some(symbol.to_string()),
side: Some(side),
detail: detail.into(),
});
}
fn creation_reject_kind(emit_creation_events: bool) -> ProcessEventKind {
if emit_creation_events {
ProcessEventKind::OrderCreationReject
} else {
ProcessEventKind::OrderUnsolicitedUpdate
}
}
fn target_quantities(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
self.target_quantities_with_valuation_prices(date, portfolio, data, target_weights, None)
}
fn target_quantities_with_valuation_prices(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
valuation_prices: Option<&BTreeMap<String, f64>>,
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
let equity =
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?;
let target_weight_sum = target_weights.values().copied().sum::<f64>();
let mut desired_targets = BTreeMap::new();
let mut diagnostics = Vec::new();
for (symbol, weight) in target_weights {
let price = self.rebalance_valuation_price_with_overrides(
date,
symbol,
data,
valuation_prices,
)?;
let raw_qty = ((equity * weight) / price).floor() as u32;
desired_targets.insert(
symbol.clone(),
self.round_buy_quantity(
raw_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
),
);
}
let mut symbols = BTreeSet::new();
symbols.extend(portfolio.positions().keys().cloned());
symbols.extend(desired_targets.keys().cloned());
let mut constraints = Vec::new();
let mut projected_cash = portfolio.cash();
for symbol in symbols {
let current_qty = portfolio
.position(&symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let desired_qty = *desired_targets.get(&symbol).unwrap_or(&0);
let price = self.rebalance_valuation_price_with_overrides(
date,
&symbol,
data,
valuation_prices,
)?;
let minimum_order_quantity = self.minimum_order_quantity(data, &symbol);
let order_step_size = self.order_step_size(data, &symbol);
let min_target_qty = self.minimum_target_quantity(
date,
portfolio,
data,
&symbol,
current_qty,
minimum_order_quantity,
order_step_size,
);
let max_target_qty = self.maximum_target_quantity(
date,
portfolio,
data,
&symbol,
current_qty,
minimum_order_quantity,
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={}",
symbol, desired_qty, min_target_qty, max_target_qty, provisional_target_qty
));
}
if current_qty > provisional_target_qty {
projected_cash += self.estimated_sell_net_cash(
date,
price,
current_qty.saturating_sub(provisional_target_qty),
);
}
constraints.push(TargetConstraint {
symbol: symbol.clone(),
current_qty,
desired_qty,
min_target_qty,
max_target_qty,
provisional_target_qty,
price,
minimum_order_quantity,
order_step_size,
});
}
let mut targets = BTreeMap::new();
for constraint in &constraints {
if constraint.provisional_target_qty > constraint.current_qty {
continue;
}
if constraint.provisional_target_qty > 0 {
targets.insert(constraint.symbol.clone(), constraint.provisional_target_qty);
}
}
let buy_constraints = constraints
.iter()
.filter(|constraint| constraint.provisional_target_qty > constraint.current_qty)
.collect::<Vec<_>>();
if buy_constraints.is_empty() {
return Ok((targets, diagnostics));
}
let mut best_targets = targets.clone();
let mut best_proportion_diff = f64::INFINITY;
let initial_safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 };
let mut safety = initial_safety;
loop {
let mut candidate_targets = targets.clone();
let mut buy_cash_out = 0.0;
for constraint in &buy_constraints {
let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32;
let mut target_qty = self
.round_buy_quantity(
scaled_desired_qty,
constraint.minimum_order_quantity,
constraint.order_step_size,
)
.clamp(constraint.min_target_qty, constraint.max_target_qty)
.max(constraint.current_qty);
if target_qty < constraint.current_qty {
target_qty = constraint.current_qty;
}
if target_qty > constraint.current_qty {
buy_cash_out += self.estimated_buy_cash_out(
date,
constraint.price,
target_qty - constraint.current_qty,
);
}
if target_qty > 0 {
candidate_targets.insert(constraint.symbol.clone(), target_qty);
}
}
let total_target_value = constraints
.iter()
.map(|constraint| {
candidate_targets
.get(&constraint.symbol)
.copied()
.unwrap_or(0) as f64
* constraint.price
})
.sum::<f64>();
let proportion_diff = if equity > 0.0 {
((total_target_value / equity) - target_weight_sum).abs()
} else {
0.0
};
if buy_cash_out <= projected_cash + 1e-6 {
if proportion_diff <= best_proportion_diff + 1e-12 {
best_targets = candidate_targets;
best_proportion_diff = proportion_diff;
} else if best_proportion_diff.is_finite() {
break;
}
}
if safety <= 0.0 {
break;
}
let step = (proportion_diff / 10.0).clamp(0.0001, 0.002);
let next_safety = (safety - step).max(0.0);
if (next_safety - safety).abs() < f64::EPSILON {
break;
}
safety = next_safety;
}
if safety < initial_safety && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_safety_scaled final_safety={:.4} target_weight_sum={:.4} projected_cash={:.2}",
safety, target_weight_sum, projected_cash
));
}
for constraint in &buy_constraints {
let final_target_qty = best_targets
.get(&constraint.symbol)
.copied()
.unwrap_or(constraint.current_qty);
if final_target_qty < constraint.provisional_target_qty && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={}",
constraint.symbol,
constraint.provisional_target_qty,
final_target_qty,
constraint.current_qty
));
}
}
Ok((best_targets, diagnostics))
}
fn process_target_portfolio_smart(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
order_prices: Option<&BTreeMap<String, f64>>,
valuation_prices: Option<&BTreeMap<String, f64>>,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let (target_quantities, diagnostics) = self.target_quantities_with_valuation_prices(
date,
portfolio,
data,
target_weights,
valuation_prices,
)?;
report.diagnostics.extend(diagnostics);
let mut symbols = BTreeSet::new();
symbols.extend(portfolio.positions().keys().cloned());
symbols.extend(target_quantities.keys().cloned());
for symbol in &symbols {
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let target_qty = target_quantities.get(symbol).copied().unwrap_or(0);
if current_qty <= target_qty {
continue;
}
let sell_qty = current_qty - target_qty;
let mut local_report = BrokerExecutionReport::default();
if let Some(limit_price) =
self.required_custom_order_price(date, symbol, order_prices)?
{
self.process_limit_shares(
date,
portfolio,
data,
symbol,
-(sell_qty as i32),
limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
&mut local_report,
)?;
} else {
self.process_shares(
date,
portfolio,
data,
symbol,
-(sell_qty as i32),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
&mut local_report,
)?;
}
Self::extend_report(report, local_report);
}
for symbol in &symbols {
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let target_qty = target_quantities.get(symbol).copied().unwrap_or(0);
if target_qty <= current_qty {
continue;
}
let buy_qty = target_qty - current_qty;
let mut local_report = BrokerExecutionReport::default();
if let Some(limit_price) =
self.required_custom_order_price(date, symbol, order_prices)?
{
self.process_limit_shares(
date,
portfolio,
data,
symbol,
buy_qty as i32,
limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
&mut local_report,
)?;
} else {
self.process_shares(
date,
portfolio,
data,
symbol,
buy_qty as i32,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
&mut local_report,
)?;
}
Self::extend_report(report, local_report);
}
Ok(())
}
fn minimum_target_quantity(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
if current_qty == 0 {
return 0;
}
let Some(position) = portfolio.position(symbol) else {
return 0;
};
let Ok(snapshot) = data.require_market(date, symbol) else {
return current_qty;
};
let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty;
};
let rule = self.rules.can_sell(
date,
snapshot,
candidate,
position,
self.execution_price_field,
);
if !rule.allowed {
return current_qty;
}
let sellable = position
.sellable_qty(date)
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
let sell_limit = match self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
sellable.min(current_qty),
minimum_order_quantity,
order_step_size,
0,
sellable >= current_qty,
) {
Ok(quantity) => quantity.min(sellable).min(current_qty),
Err(_) => 0,
};
current_qty.saturating_sub(sell_limit)
}
fn maximum_target_quantity(
&self,
date: NaiveDate,
_portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let Ok(snapshot) = data.require_market(date, symbol) else {
return current_qty;
};
let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty;
};
let rule = self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
if !rule.allowed {
return current_qty;
}
let additional_limit = match self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
u32::MAX,
minimum_order_quantity,
order_step_size,
0,
false,
) {
Ok(quantity) => quantity,
Err(_) => 0,
};
current_qty.saturating_add(additional_limit)
}
fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 {
if quantity == 0 {
return 0.0;
}
let gross = price * quantity as f64;
let cost = self.cost_model.calculate(date, OrderSide::Sell, gross);
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)
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
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 {
if quantity == 0 {
return 0.0;
}
let gross = price * quantity as f64;
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross);
gross + cost.total()
}
fn process_sell(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
requested_qty: u32,
order_id: u64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
limit_price: Option<f64>,
allow_pending_limit: bool,
emit_creation_events: bool,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
let Some(position) = portfolio.position(symbol) else {
return Ok(());
};
if emit_creation_events {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderPendingNew,
order_id,
symbol,
OrderSide::Sell,
format!("requested_quantity={requested_qty} reason={reason}"),
);
}
let rule = self.rules.can_sell(
date,
snapshot,
candidate,
position,
self.execution_price_field,
);
if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
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,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status,
reason: format!("{reason}: {rule_reason}"),
});
Self::emit_order_process_event(
report,
date,
Self::creation_reject_kind(emit_creation_events),
order_id,
symbol,
OrderSide::Sell,
format!("status={status:?} reason={rule_reason}"),
);
self.clear_open_order(order_id);
return Ok(());
}
if emit_creation_events {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderCreationPass,
order_id,
symbol,
OrderSide::Sell,
"sell order passed rule checks",
);
}
let sellable = position
.sellable_qty(date)
.saturating_sub(self.reserved_open_sell_quantity(symbol, Some(order_id)));
let mut partial_fill_reason = if sellable < requested_qty {
Some("sellable quantity limit".to_string())
} else {
None
};
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
requested_qty.min(sellable),
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
requested_qty >= position.quantity && sellable >= position.quantity,
);
let fillable_qty = match market_limited_qty {
Ok(quantity) => {
let quantity = quantity.min(sellable);
if quantity < requested_qty.min(sellable) {
partial_fill_reason = merge_partial_fill_reason(
partial_fill_reason,
Some("market liquidity or volume limit"),
);
}
quantity
}
Err(limit_reason) => {
if allow_pending_limit {
self.upsert_open_order(OpenOrder {
order_id,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
remaining_quantity: requested_qty,
limit_price: limit_price.expect("limit price for pending limit sell"),
reason: reason.to_string(),
});
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Pending,
reason: format!("{reason}: pending due to {limit_reason}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Sell,
format!("status=Pending reason={limit_reason}"),
);
return Ok(());
}
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: zero_fill_status_for_reason(&limit_reason),
reason: format!("{reason}: {limit_reason}"),
});
Self::emit_order_process_event(
report,
date,
Self::creation_reject_kind(emit_creation_events),
order_id,
symbol,
OrderSide::Sell,
format!(
"status={:?} reason={limit_reason}",
zero_fill_status_for_reason(&limit_reason)
),
);
return Ok(());
}
};
if fillable_qty == 0 {
if allow_pending_limit {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("no sellable quantity");
self.upsert_open_order(OpenOrder {
order_id,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
remaining_quantity: requested_qty,
limit_price: limit_price.expect("limit price for pending limit sell"),
reason: reason.to_string(),
});
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Pending,
reason: format!("{reason}: pending due to {detail}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Sell,
format!("status=Pending reason={detail}"),
);
return Ok(());
}
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: no sellable quantity"),
});
Self::emit_order_process_event(
report,
date,
Self::creation_reject_kind(emit_creation_events),
order_id,
symbol,
OrderSide::Sell,
"status=Rejected reason=no sellable quantity",
);
return Ok(());
}
let fill = self.resolve_execution_fill(
date,
symbol,
OrderSide::Sell,
snapshot,
data,
fillable_qty,
self.round_lot(data, symbol),
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
fillable_qty >= position.quantity,
execution_cursors,
None,
None,
None,
limit_price,
);
let (filled_qty, execution_legs) = 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);
}
partial_fill_reason =
merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason);
(fill.quantity, fill.legs)
} else {
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell);
if !self.price_satisfies_limit(
OrderSide::Sell,
execution_price,
limit_price,
snapshot.effective_price_tick(),
) {
partial_fill_reason = merge_partial_fill_reason(
partial_fill_reason,
Some("limit price not marketable yet"),
);
(0, Vec::new())
} else {
let execution_price =
self.execution_price_with_limit_slippage(execution_price, limit_price);
(
fillable_qty,
vec![ExecutionLeg {
price: execution_price,
quantity: fillable_qty,
}],
)
}
};
if filled_qty == 0 {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("limit price not marketable yet");
if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) {
self.upsert_open_order(OpenOrder {
order_id,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
remaining_quantity: requested_qty,
limit_price: limit_price.expect("limit price for pending limit sell"),
reason: reason.to_string(),
});
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Pending,
reason: format!("{reason}: pending due to {detail}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Sell,
format!("status=Pending reason={detail}"),
);
return Ok(());
}
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: zero_fill_status_for_reason(detail),
reason: format!("{reason}: {detail}"),
});
Self::emit_order_process_event(
report,
date,
Self::creation_reject_kind(emit_creation_events),
order_id,
symbol,
OrderSide::Sell,
format!(
"status={:?} reason={detail}",
zero_fill_status_for_reason(detail)
),
);
self.clear_open_order(order_id);
return Ok(());
}
if execution_legs.len() > 1 {
report.diagnostics.push(format!(
"order_split_fill symbol={symbol} side=sell order_id={order_id} fills={}",
execution_legs.len()
));
}
for leg in &execution_legs {
let leg_cash_before = portfolio.cash();
let gross_amount = leg.price * leg.quantity as f64;
let cost = self.cost_model.calculate_with_order_state(
date,
OrderSide::Sell,
gross_amount,
Some(order_id),
commission_state,
);
let net_cash = gross_amount - cost.total();
let realized_pnl = portfolio
.position_mut(symbol)
.sell(leg.quantity, leg.price)
.map_err(BacktestError::Execution)?;
if let Some(position) = portfolio.position_mut_if_exists(symbol) {
position.record_trade_cost(cost.total());
}
portfolio.apply_cash_delta(net_cash);
report.fill_events.push(FillEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
quantity: leg.quantity,
price: leg.price,
gross_amount,
commission: cost.commission,
stamp_tax: cost.stamp_tax,
net_cash_flow: net_cash,
reason: reason.to_string(),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::Trade,
order_id,
symbol,
OrderSide::Sell,
format!("filled_quantity={} price={}", leg.quantity, leg.price),
);
report.position_events.push(PositionEvent {
date,
symbol: symbol.to_string(),
delta_quantity: -(leg.quantity as i32),
quantity_after: portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0),
average_cost: portfolio
.position(symbol)
.map(|pos| pos.average_cost)
.unwrap_or(0.0),
realized_pnl_delta: realized_pnl,
reason: reason.to_string(),
});
report.account_events.push(AccountEvent {
date,
cash_before: leg_cash_before,
cash_after: portfolio.cash(),
total_equity: self.total_equity_at(
date,
portfolio,
data,
self.account_mark_price_field(),
)?,
note: format!("sell {symbol} {reason}"),
});
}
portfolio.prune_flat_positions();
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let remaining_qty = requested_qty.saturating_sub(filled_qty);
let keep_open = allow_pending_limit
&& remaining_qty > 0
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref());
if keep_open {
self.upsert_open_order(OpenOrder {
order_id,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: filled_qty,
remaining_quantity: remaining_qty,
limit_price: limit_price.expect("limit price for pending limit sell"),
reason: reason.to_string(),
});
} else {
self.clear_open_order(order_id);
}
let status = if keep_open {
OrderStatus::PartiallyFilled
} else if filled_qty < requested_qty {
final_partial_fill_status(partial_fill_reason.as_deref())
} else {
OrderStatus::Filled
};
let order_reason = if keep_open {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("remaining quantity could not be filled");
report.diagnostics.push(format!(
"order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}; remaining open"
));
format!("{reason}: partial fill due to {detail}; remaining quantity pending")
} else if status == OrderStatus::PartiallyFilled {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("remaining quantity could not be filled");
report.diagnostics.push(format!(
"order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}"
));
format!("{reason}: partial fill due to {detail}")
} else if status == OrderStatus::Canceled && filled_qty < requested_qty {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("remaining quantity could not be filled");
report.diagnostics.push(format!(
"order_remainder_canceled symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}"
));
format!("{reason}: partial fill due to {detail}; remaining quantity canceled")
} else {
reason.to_string()
};
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: filled_qty,
status,
reason: order_reason,
});
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Sell,
format!("status={status:?} filled_quantity={filled_qty}"),
);
}
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>,
commission_state: &mut BTreeMap<u64, f64>,
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.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
if current_qty > target_qty {
self.process_sell(
date,
portfolio,
data,
symbol,
current_qty - target_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
false,
true,
report,
)?;
} else if target_qty > current_qty {
self.process_buy(
date,
portfolio,
data,
symbol,
target_qty - current_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
None,
false,
true,
report,
)?;
} else if (current_value - target_value).abs() <= f64::EPSILON {
report.order_events.push(OrderEvent {
date,
order_id: None,
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_target_shares(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_quantity: i32,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let target_qty = target_quantity.max(0) as u32;
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
if current_qty > target_qty {
let raw_sell_qty = current_qty - target_qty;
let sell_qty = if target_qty == 0 {
current_qty
} else {
self.round_buy_quantity(raw_sell_qty, minimum_order_quantity, order_step_size)
.min(current_qty)
};
if sell_qty > 0 {
self.process_sell(
date,
portfolio,
data,
symbol,
sell_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
false,
true,
report,
)?;
}
} else if target_qty > current_qty {
let buy_qty = self.round_buy_quantity(
target_qty - current_qty,
minimum_order_quantity,
order_step_size,
);
if buy_qty > 0 {
self.process_buy(
date,
portfolio,
data,
symbol,
buy_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
None,
false,
true,
report,
)?;
}
} else {
report.order_events.push(OrderEvent {
date,
order_id: None,
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 shares"),
});
}
Ok(())
}
fn process_limit_target_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_value: f64,
limit_price: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
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 target_qty = self.round_buy_quantity(
((target_value.max(0.0)) / price).floor() as u32,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
if current_qty > target_qty {
self.process_sell(
date,
portfolio,
data,
symbol,
current_qty - target_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(limit_price),
true,
true,
report,
)?;
} else if target_qty > current_qty {
self.process_buy(
date,
portfolio,
data,
symbol,
target_qty - current_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
Some(limit_price),
true,
true,
report,
)?;
}
Ok(())
}
fn process_limit_target_shares(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_quantity: i32,
limit_price: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let target_qty = target_quantity.max(0) as u32;
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
if current_qty > target_qty {
let raw_sell_qty = current_qty - target_qty;
let sell_qty = if target_qty == 0 {
current_qty
} else {
self.round_buy_quantity(raw_sell_qty, minimum_order_quantity, order_step_size)
.min(current_qty)
};
if sell_qty > 0 {
self.process_sell(
date,
portfolio,
data,
symbol,
sell_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(limit_price),
true,
true,
report,
)?;
}
} else if target_qty > current_qty {
let buy_qty = self.round_buy_quantity(
target_qty - current_qty,
minimum_order_quantity,
order_step_size,
);
if buy_qty > 0 {
self.process_buy(
date,
portfolio,
data,
symbol,
buy_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
Some(limit_price),
true,
true,
report,
)?;
}
}
Ok(())
}
fn process_target_percent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_percent: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
self.process_target_value(
date,
portfolio,
data,
symbol,
total_equity * target_percent.max(0.0),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
fn process_limit_target_percent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_percent: f64,
limit_price: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
self.process_limit_target_value(
date,
portfolio,
data,
symbol,
total_equity * target_percent.max(0.0),
limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
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>,
commission_state: &mut BTreeMap<u64, f64>,
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),
})?;
if value > 0.0 {
let round_lot = self.round_lot(data, symbol);
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
let price = self.sizing_price(snapshot);
let snapshot_requested_qty = self.round_buy_quantity(
((value.abs()) / price).floor() as u32,
minimum_order_quantity,
order_step_size,
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
portfolio,
data,
symbol,
snapshot_requested_qty,
round_lot,
value.abs(),
reason,
execution_cursors,
*global_execution_cursor,
);
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(value.abs()),
None,
false,
true,
report,
)
} else {
let price = self.sizing_price(snapshot);
let requested_qty = self.round_buy_quantity(
((value.abs()) / price).floor() as u32,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
self.process_sell(
date,
portfolio,
data,
symbol,
requested_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
false,
true,
report,
)
}
}
fn process_limit_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
value: f64,
limit_price: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
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),
})?;
if value > 0.0 {
let round_lot = self.round_lot(data, symbol);
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
let price = self.sizing_price(snapshot);
let snapshot_requested_qty = self.round_buy_quantity(
((value.abs()) / price).floor() as u32,
minimum_order_quantity,
order_step_size,
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
portfolio,
data,
symbol,
snapshot_requested_qty,
round_lot,
value.abs(),
reason,
execution_cursors,
*global_execution_cursor,
);
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(value.abs()),
Some(limit_price),
true,
true,
report,
)
} else {
let price = self.sizing_price(snapshot);
let requested_qty = self.round_buy_quantity(
((value.abs()) / price).floor() as u32,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
self.process_sell(
date,
portfolio,
data,
symbol,
requested_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(limit_price),
true,
true,
report,
)
}
}
fn process_percent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
percent: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
self.process_value(
date,
portfolio,
data,
symbol,
total_equity * percent,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
fn process_limit_percent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
percent: f64,
limit_price: f64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
self.process_limit_value(
date,
portfolio,
data,
symbol,
total_equity * percent,
limit_price,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
fn process_shares(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
quantity: i32,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if quantity == 0 {
return Ok(());
}
if quantity > 0 {
let requested_qty = self.round_buy_quantity(
quantity as u32,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
None,
false,
true,
report,
)
} else {
self.process_sell(
date,
portfolio,
data,
symbol,
quantity.unsigned_abs(),
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
false,
true,
report,
)
}
}
fn process_lots(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
lots: i32,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let round_lot = self.round_lot(data, symbol);
let requested_quantity = lots.saturating_abs() as u32 * round_lot;
let signed_quantity = if lots >= 0 {
requested_quantity as i32
} else {
-(requested_quantity as i32)
};
self.process_shares(
date,
portfolio,
data,
symbol,
signed_quantity,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
fn maybe_expand_periodic_value_buy_quantity(
&self,
_date: NaiveDate,
_portfolio: &PortfolioState,
_data: &DataSet,
_symbol: &str,
requested_qty: u32,
_round_lot: u32,
_value_budget: f64,
_reason: &str,
_execution_cursors: &BTreeMap<String, NaiveDateTime>,
_global_execution_cursor: Option<NaiveDateTime>,
) -> u32 {
requested_qty
}
fn process_buy(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
requested_qty: u32,
order_id: u64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
value_budget: Option<f64>,
limit_price: Option<f64>,
allow_pending_limit: bool,
emit_creation_events: bool,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
if emit_creation_events {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderPendingNew,
order_id,
symbol,
OrderSide::Buy,
format!("requested_quantity={requested_qty} reason={reason}"),
);
}
let rule = self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() {
Some("paused")
| Some("buy disabled by eligibility flags")
| Some("open at or above upper limit") => OrderStatus::Canceled,
_ => OrderStatus::Rejected,
};
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status,
reason: format!("{reason}: {rule_reason}"),
});
Self::emit_order_process_event(
report,
date,
Self::creation_reject_kind(emit_creation_events),
order_id,
symbol,
OrderSide::Buy,
format!("status={status:?} reason={rule_reason}"),
);
self.clear_open_order(order_id);
return Ok(());
}
if emit_creation_events {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderCreationPass,
order_id,
symbol,
OrderSide::Buy,
"buy order passed rule checks",
);
}
let mut partial_fill_reason = None;
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
requested_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
false,
);
let constrained_qty = match market_limited_qty {
Ok(quantity) => {
if quantity < requested_qty {
partial_fill_reason = Some("market liquidity or volume limit".to_string());
}
quantity
}
Err(limit_reason) => {
if allow_pending_limit {
self.upsert_open_order(OpenOrder {
order_id,
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
remaining_quantity: requested_qty,
limit_price: limit_price.expect("limit price for pending limit buy"),
reason: reason.to_string(),
});
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Pending,
reason: format!("{reason}: pending due to {limit_reason}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Buy,
format!("status=Pending reason={limit_reason}"),
);
return Ok(());
}
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: zero_fill_status_for_reason(&limit_reason),
reason: format!("{reason}: {limit_reason}"),
});
Self::emit_order_process_event(
report,
date,
Self::creation_reject_kind(emit_creation_events),
order_id,
symbol,
OrderSide::Buy,
format!(
"status={:?} reason={limit_reason}",
zero_fill_status_for_reason(&limit_reason)
),
);
return Ok(());
}
};
let fill = self.resolve_execution_fill(
date,
symbol,
OrderSide::Buy,
snapshot,
data,
constrained_qty,
self.round_lot(data, symbol),
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
false,
execution_cursors,
None,
Some(portfolio.cash()),
value_budget.map(|budget| budget + 400.0),
limit_price,
);
let (filled_qty, execution_legs) = 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);
}
partial_fill_reason =
merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason);
(fill.quantity, fill.legs)
} else {
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
if !self.price_satisfies_limit(
OrderSide::Buy,
execution_price,
limit_price,
snapshot.effective_price_tick(),
) {
partial_fill_reason = merge_partial_fill_reason(
partial_fill_reason,
Some("limit price not marketable yet"),
);
(0, Vec::new())
} else {
let execution_price =
self.execution_price_with_limit_slippage(execution_price, limit_price);
let filled_qty = self.affordable_buy_quantity(
date,
portfolio.cash(),
value_budget.map(|budget| budget + 400.0),
execution_price,
constrained_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
if filled_qty < constrained_qty {
partial_fill_reason = merge_partial_fill_reason(
partial_fill_reason,
self.buy_reduction_reason(
portfolio.cash(),
value_budget.map(|budget| budget + 400.0),
execution_price,
constrained_qty,
filled_qty,
),
);
}
(
filled_qty,
vec![ExecutionLeg {
price: execution_price,
quantity: filled_qty,
}],
)
}
};
if filled_qty == 0 {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("insufficient cash after fees");
if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) {
self.upsert_open_order(OpenOrder {
order_id,
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
remaining_quantity: requested_qty,
limit_price: limit_price.expect("limit price for pending limit buy"),
reason: reason.to_string(),
});
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Pending,
reason: format!("{reason}: pending due to {detail}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Buy,
format!("status=Pending reason={detail}"),
);
return Ok(());
}
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {detail}"),
});
Self::emit_order_process_event(
report,
date,
Self::creation_reject_kind(emit_creation_events),
order_id,
symbol,
OrderSide::Buy,
format!("status=Rejected reason={detail}"),
);
self.clear_open_order(order_id);
return Ok(());
}
if execution_legs.len() > 1 {
report.diagnostics.push(format!(
"order_split_fill symbol={symbol} side=buy order_id={order_id} fills={}",
execution_legs.len()
));
}
for leg in &execution_legs {
let leg_cash_before = portfolio.cash();
let gross_amount = leg.price * leg.quantity as f64;
let cost = self.cost_model.calculate_with_order_state(
date,
OrderSide::Buy,
gross_amount,
Some(order_id),
commission_state,
);
let cash_out = gross_amount + cost.total();
portfolio.apply_cash_delta(-cash_out);
portfolio
.position_mut(symbol)
.buy(date, leg.quantity, leg.price);
if let Some(position) = portfolio.position_mut_if_exists(symbol) {
position.record_trade_cost(cost.total());
}
report.fill_events.push(FillEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
quantity: leg.quantity,
price: leg.price,
gross_amount,
commission: cost.commission,
stamp_tax: cost.stamp_tax,
net_cash_flow: -cash_out,
reason: reason.to_string(),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::Trade,
order_id,
symbol,
OrderSide::Buy,
format!("filled_quantity={} price={}", leg.quantity, leg.price),
);
report.position_events.push(PositionEvent {
date,
symbol: symbol.to_string(),
delta_quantity: leg.quantity as i32,
quantity_after: portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0),
average_cost: portfolio
.position(symbol)
.map(|pos| pos.average_cost)
.unwrap_or(0.0),
realized_pnl_delta: 0.0,
reason: reason.to_string(),
});
report.account_events.push(AccountEvent {
date,
cash_before: leg_cash_before,
cash_after: portfolio.cash(),
total_equity: self.total_equity_at(
date,
portfolio,
data,
self.account_mark_price_field(),
)?,
note: format!("buy {symbol} {reason}"),
});
}
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let remaining_qty = requested_qty.saturating_sub(filled_qty);
let keep_open = allow_pending_limit
&& remaining_qty > 0
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref());
if keep_open {
self.upsert_open_order(OpenOrder {
order_id,
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: filled_qty,
remaining_quantity: remaining_qty,
limit_price: limit_price.expect("limit price for pending limit buy"),
reason: reason.to_string(),
});
} else {
self.clear_open_order(order_id);
}
let status = if keep_open {
OrderStatus::PartiallyFilled
} else if filled_qty < requested_qty {
final_partial_fill_status(partial_fill_reason.as_deref())
} else {
OrderStatus::Filled
};
let order_reason = if keep_open {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("remaining quantity could not be filled");
report.diagnostics.push(format!(
"order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}; remaining open"
));
format!("{reason}: partial fill due to {detail}; remaining quantity pending")
} else if status == OrderStatus::PartiallyFilled {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("remaining quantity could not be filled");
report.diagnostics.push(format!(
"order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}"
));
format!("{reason}: partial fill due to {detail}")
} else if status == OrderStatus::Canceled && filled_qty < requested_qty {
let detail = partial_fill_reason
.as_deref()
.unwrap_or("remaining quantity could not be filled");
report.diagnostics.push(format!(
"order_remainder_canceled symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}"
));
format!("{reason}: partial fill due to {detail}; remaining quantity canceled")
} else {
reason.to_string()
};
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: filled_qty,
status,
reason: order_reason,
});
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Buy,
format!("status={status:?} filled_quantity={filled_qty}"),
);
}
Ok(())
}
fn total_equity_at(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
field: PriceField,
) -> 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 {
date,
symbol: position.symbol.clone(),
field: match field {
PriceField::DayOpen => "day_open",
PriceField::Open => "open",
PriceField::Close => "close",
PriceField::Last => "last",
},
}
})?;
market_value += price * position.quantity as f64;
}
Ok(portfolio.cash() + market_value)
}
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 minimum_order_quantity(&self, data: &DataSet, symbol: &str) -> u32 {
data.instruments()
.get(symbol)
.map(|instrument| instrument.minimum_order_quantity())
.unwrap_or(self.board_lot_size.max(1))
}
fn order_step_size(&self, data: &DataSet, symbol: &str) -> u32 {
data.instruments()
.get(symbol)
.map(|instrument| instrument.order_step_size())
.unwrap_or(self.board_lot_size.max(1))
}
fn account_mark_price_field(&self) -> PriceField {
if self.is_open_auction_matching() {
PriceField::DayOpen
} else {
PriceField::Open
}
}
fn rebalance_valuation_price_field_name(&self) -> &'static str {
if self.is_open_auction_matching() {
"prev_close"
} else {
price_field_name(self.execution_price_field)
}
}
fn rebalance_valuation_price_for_snapshot(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
) -> Option<f64> {
let price = if self.is_open_auction_matching() {
snapshot.prev_close
} else {
snapshot.price(self.execution_price_field)
};
if price.is_finite() && price > 0.0 {
Some(price)
} else {
None
}
}
fn rebalance_valuation_price_with_overrides(
&self,
date: NaiveDate,
symbol: &str,
data: &DataSet,
valuation_prices: Option<&BTreeMap<String, f64>>,
) -> Result<f64, BacktestError> {
if let Some(prices) = valuation_prices {
if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) {
return Ok(price);
}
return Err(BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: "custom valuation",
});
}
let snapshot = data
.market(date, symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: self.rebalance_valuation_price_field_name(),
})?;
self.rebalance_valuation_price_for_snapshot(snapshot)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: self.rebalance_valuation_price_field_name(),
})
}
fn rebalance_total_equity_at(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
) -> Result<f64, BacktestError> {
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, None)
}
fn rebalance_total_equity_at_with_overrides(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
valuation_prices: Option<&BTreeMap<String, f64>>,
) -> Result<f64, BacktestError> {
let mut market_value = 0.0;
for position in portfolio.positions().values() {
let price = self.rebalance_valuation_price_with_overrides(
date,
&position.symbol,
data,
valuation_prices,
)?;
market_value += price * position.quantity as f64;
}
Ok(portfolio.cash() + market_value)
}
fn required_custom_order_price(
&self,
date: NaiveDate,
symbol: &str,
order_prices: Option<&BTreeMap<String, f64>>,
) -> Result<Option<f64>, BacktestError> {
let Some(prices) = order_prices else {
return Ok(None);
};
if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) {
Ok(Some(price))
} else {
Err(BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: "custom order",
})
}
}
fn round_buy_quantity(
&self,
quantity: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let step = order_step_size.max(1);
let normalized = (quantity / step) * step;
if normalized < minimum_order_quantity.max(1) {
0
} else {
normalized
}
}
fn decrement_order_quantity(
&self,
quantity: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let minimum = minimum_order_quantity.max(1);
if quantity <= minimum {
return 0;
}
let next = quantity.saturating_sub(order_step_size.max(1));
if next < minimum { 0 } else { next }
}
fn affordable_buy_quantity(
&self,
date: NaiveDate,
cash: f64,
gross_limit: Option<f64>,
price: f64,
requested_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let mut quantity =
self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size);
while quantity > 0 {
let gross = price * quantity as f64;
if gross_limit.is_some_and(|limit| gross > limit + 1e-6) {
quantity = self.decrement_order_quantity(
quantity,
minimum_order_quantity,
order_step_size,
);
continue;
}
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross);
if gross + cost.total() <= cash + 1e-6 {
return quantity;
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
0
}
fn buy_reduction_reason(
&self,
cash_limit: f64,
gross_limit: Option<f64>,
price: f64,
requested_qty: u32,
filled_qty: u32,
) -> Option<&'static str> {
if filled_qty >= requested_qty {
return None;
}
if gross_limit.is_some_and(|limit| price * requested_qty as f64 > limit + 1e-6) {
Some("value budget limit")
} else if cash_limit.is_finite() {
Some("insufficient cash after fees")
} else {
None
}
}
fn market_fillable_quantity(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
requested_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
consumed_turnover: u32,
allow_odd_lot_sell: bool,
) -> 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;
if self.liquidity_limit && !self.is_open_auction_matching() {
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());
}
let top_level_limit = if side == OrderSide::Sell && allow_odd_lot_sell {
top_level_liquidity
} else {
self.round_buy_quantity(
top_level_liquidity,
minimum_order_quantity,
order_step_size,
)
};
max_fill = max_fill.min(top_level_limit);
}
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 = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit as u32
} else {
self.round_buy_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
};
if volume_limited == 0 {
return Err("tick volume limit".to_string());
}
max_fill = max_fill.min(volume_limited);
}
Ok(max_fill)
}
fn price_satisfies_limit(
&self,
side: OrderSide,
execution_price: f64,
limit_price: Option<f64>,
price_tick: f64,
) -> bool {
let Some(limit_price) = limit_price else {
return execution_price.is_finite() && execution_price > 0.0;
};
if !execution_price.is_finite() || execution_price <= 0.0 {
return false;
}
let tolerance = price_tick.abs().max(1e-9);
match side {
OrderSide::Buy => execution_price <= limit_price + tolerance,
OrderSide::Sell => execution_price + tolerance >= limit_price,
}
}
fn execution_price_with_limit_slippage(
&self,
execution_price: f64,
limit_price: Option<f64>,
) -> f64 {
match (self.slippage_model, limit_price) {
(SlippageModel::LimitPrice, Some(limit_price)) => limit_price,
_ => execution_price,
}
}
fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool {
!partial_reason.is_some_and(|reason| {
reason.contains("insufficient cash") || reason.contains("value budget")
})
}
fn resolve_execution_fill(
&self,
date: NaiveDate,
symbol: &str,
side: OrderSide,
snapshot: &crate::data::DailyMarketSnapshot,
data: &DataSet,
requested_qty: u32,
round_lot: u32,
minimum_order_quantity: u32,
order_step_size: u32,
allow_odd_lot_sell: bool,
_execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
_global_execution_cursor: Option<NaiveDateTime>,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
limit_price: Option<f64>,
) -> Option<ExecutionFill> {
if self.execution_price_field != PriceField::Last {
return None;
}
let start_cursor = self
.intraday_execution_start_time
.map(|start_time| date.and_time(start_time));
let quotes = data.execution_quotes_on(date, symbol);
if let Some(fill) = self.select_execution_fill(
snapshot,
quotes,
side,
start_cursor,
requested_qty,
round_lot,
minimum_order_quantity,
order_step_size,
allow_odd_lot_sell,
cash_limit,
gross_limit,
limit_price,
) {
return Some(fill);
}
if self.intraday_execution_start_time.is_some() {
let execution_price = self.snapshot_execution_price(snapshot, side);
if !self.price_satisfies_limit(
side,
execution_price,
limit_price,
snapshot.effective_price_tick(),
) {
return None;
}
let execution_price =
self.execution_price_with_limit_slippage(execution_price, limit_price);
let quantity = match side {
OrderSide::Buy => self.affordable_buy_quantity(
date,
cash_limit.unwrap_or(f64::INFINITY),
gross_limit,
execution_price,
requested_qty,
minimum_order_quantity,
order_step_size,
),
OrderSide::Sell => requested_qty,
};
if quantity == 0 {
return None;
}
let next_cursor = self
.intraday_execution_start_time
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill {
quantity,
next_cursor,
legs: vec![ExecutionLeg {
price: execution_price,
quantity,
}],
unfilled_reason: self.buy_reduction_reason(
cash_limit.unwrap_or(f64::INFINITY),
gross_limit,
execution_price,
requested_qty,
quantity,
),
});
}
None
}
fn select_execution_fill(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
quotes: &[IntradayExecutionQuote],
side: OrderSide,
start_cursor: Option<NaiveDateTime>,
requested_qty: u32,
round_lot: u32,
minimum_order_quantity: u32,
order_step_size: u32,
allow_odd_lot_sell: bool,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
limit_price: 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 legs = Vec::new();
let mut budget_block_reason = None;
let mut saw_quote_after_cursor = false;
for quote in quotes {
if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
continue;
}
saw_quote_after_cursor = true;
// 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 Some(quote_price) = self.select_quote_reference_price(snapshot, quote, side) else {
continue;
};
if !self.price_satisfies_limit(
side,
quote_price,
limit_price,
snapshot.effective_price_tick(),
) {
continue;
}
let quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
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);
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
take_qty =
self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size);
}
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) {
budget_block_reason = Some("value budget limit");
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
order_step_size,
);
continue;
}
if candidate_gross <= cash + 1e-6 {
break;
}
budget_block_reason = Some("insufficient cash after fees");
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
order_step_size,
);
}
if take_qty == 0 {
break;
}
}
gross_amount += quote_price * take_qty as f64;
filled_qty += take_qty;
last_timestamp = Some(quote.timestamp);
legs.push(ExecutionLeg {
price: quote_price,
quantity: take_qty,
});
if filled_qty >= requested_qty {
break;
}
}
if filled_qty == 0 {
return None;
}
Some(ExecutionFill {
quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
legs: if self.matching_type == MatchingType::Vwap {
vec![ExecutionLeg {
price: gross_amount / filled_qty as f64,
quantity: filled_qty,
}]
} else {
legs
},
unfilled_reason: if filled_qty < requested_qty {
budget_block_reason.or(if saw_quote_after_cursor {
Some("intraday quote liquidity exhausted")
} else {
Some("no execution quotes after start")
})
} else {
None
},
})
}
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
let _ = reason;
false
}
}
fn matching_type_from_price_field(field: PriceField) -> MatchingType {
match field {
PriceField::DayOpen => MatchingType::OpenAuction,
PriceField::Open => MatchingType::NextBarOpen,
PriceField::Close => MatchingType::CurrentBarClose,
PriceField::Last => MatchingType::NextTickLast,
}
}
fn merge_partial_fill_reason(current: Option<String>, next: Option<&str>) -> Option<String> {
match (current, next) {
(Some(existing), Some(next_reason)) if !existing.contains(next_reason) => {
Some(format!("{existing}; {next_reason}"))
}
(Some(existing), _) => Some(existing),
(None, Some(next_reason)) => Some(next_reason.to_string()),
(None, None) => None,
}
}
fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
match reason {
"tick no volume" | "tick volume limit" => OrderStatus::Canceled,
_ => OrderStatus::Rejected,
}
}
fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus {
match partial_reason {
Some(reason)
if reason.contains("market liquidity or volume limit")
|| reason.contains("intraday quote liquidity exhausted")
|| reason.contains("no execution quotes after start") =>
{
OrderStatus::Canceled
}
_ => OrderStatus::PartiallyFilled,
}
}
fn price_field_name(field: PriceField) -> &'static str {
match field {
PriceField::DayOpen => "day_open",
PriceField::Open => "open",
PriceField::Close => "close",
PriceField::Last => "last",
}
}
fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
if decision.exit_symbols.contains(symbol) {
"exit_hook_sell"
} else {
"rebalance_sell"
}
}