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, pub fill_events: Vec, pub position_events: Vec, pub account_events: Vec, pub process_events: Vec, pub diagnostics: Vec, } #[derive(Debug, Clone, Copy)] struct ExecutionLeg { price: f64, quantity: u32, } #[derive(Debug, Clone)] struct ExecutionFill { quantity: u32, next_cursor: NaiveDateTime, legs: Vec, 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 { 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, next_order_id: Cell, open_orders: RefCell>, } impl BrokerSimulator { 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 { 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 BrokerSimulator 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 { 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 { let mut report = BrokerExecutionReport::default(); let mut intraday_turnover = BTreeMap::::new(); let mut execution_cursors = BTreeMap::::new(); let mut global_execution_cursor = None::; let mut commission_state = BTreeMap::::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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, emit_creation_events: bool, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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) -> 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, ) { 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, ) -> Result<(BTreeMap, Vec), 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, valuation_prices: Option<&BTreeMap>, ) -> Result<(BTreeMap, Vec), BacktestError> { let equity = self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?; let target_weight_sum = target_weights.values().copied().sum::(); 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::>(); 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::(); 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, order_prices: Option<&BTreeMap>, valuation_prices: Option<&BTreeMap>, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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 { 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 { 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, limit_price: Option, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, 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, _global_execution_cursor: Option, ) -> 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, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, value_budget: Option, limit_price: Option, 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 { 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 { 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>, ) -> Result { 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 { 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>, ) -> Result { 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>, ) -> Result, 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, 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, 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 { 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, 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 { 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, _global_execution_cursor: Option, cash_limit: Option, gross_limit: Option, limit_price: Option, ) -> Option { 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, requested_qty: u32, round_lot: u32, minimum_order_quantity: u32, order_step_size: u32, allow_odd_lot_sell: bool, cash_limit: Option, gross_limit: Option, limit_price: Option, ) -> Option { 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, next: Option<&str>) -> Option { 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" } }