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}; use crate::portfolio::PortfolioState; use crate::rules::EquityRuleHooks; use crate::strategy::{OrderIntent, StrategyDecision}; #[derive(Debug, Default)] pub struct BrokerExecutionReport { pub order_events: Vec, pub fill_events: Vec, pub position_events: Vec, pub account_events: Vec, } #[derive(Debug, Clone, Copy)] struct ExecutionFill { price: f64, quantity: u32, next_cursor: NaiveDateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MatchingType { CurrentBarClose, NextBarOpen, NextTickLast, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum SlippageModel { None, PriceRatio(f64), TickSize(f64), } pub struct BrokerSimulator { cost_model: C, rules: R, board_lot_size: u32, execution_price_field: PriceField, slippage_model: SlippageModel, volume_percent: f64, volume_limit: bool, inactive_limit: bool, liquidity_limit: bool, intraday_execution_start_time: Option, } impl BrokerSimulator { pub fn new(cost_model: C, rules: R) -> Self { Self { cost_model, rules, board_lot_size: 100, 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, } } pub fn new_with_execution_price( cost_model: C, rules: R, execution_price_field: PriceField, ) -> Self { Self { cost_model, rules, board_lot_size: 100, execution_price_field, slippage_model: SlippageModel::None, volume_percent: 0.25, volume_limit: true, inactive_limit: true, liquidity_limit: true, intraday_execution_start_time: None, } } pub fn with_volume_limit(mut self, enabled: bool) -> Self { self.volume_limit = enabled; self } pub fn with_inactive_limit(mut self, enabled: bool) -> Self { self.inactive_limit = enabled; self } pub fn with_liquidity_limit(mut self, enabled: bool) -> Self { self.liquidity_limit = enabled; self } pub fn with_volume_percent(mut self, volume_percent: f64) -> Self { self.volume_percent = volume_percent; self } pub fn with_intraday_execution_start_time(mut self, start_time: NaiveTime) -> Self { self.intraday_execution_start_time = Some(start_time); self } pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self { self.slippage_model = slippage_model; self } } 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 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; } 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, } } }; 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 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::; if !decision.order_intents.is_empty() { for intent in &decision.order_intents { self.process_order_intent( date, portfolio, data, intent, &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, &mut report, )?; } portfolio.prune_flat_positions(); return Ok(report); } let target_quantities = if decision.rebalance { self.target_quantities(date, portfolio, data, &decision.target_weights)? } else { BTreeMap::new() }; 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, sell_reason(decision, &symbol), &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, &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, "rebalance_buy", &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, None, &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, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { match intent { OrderIntent::TargetValue { symbol, target_value, reason, } => self.process_target_value( date, portfolio, data, symbol, *target_value, reason, intraday_turnover, execution_cursors, global_execution_cursor, report, ), OrderIntent::Value { symbol, value, reason, } => self.process_value( date, portfolio, data, symbol, *value, reason, intraday_turnover, execution_cursors, global_execution_cursor, report, ), } } fn target_quantities( &self, date: NaiveDate, portfolio: &PortfolioState, data: &DataSet, target_weights: &BTreeMap, ) -> Result, BacktestError> { let equity = self.total_equity_at(date, portfolio, data, PriceField::Open)?; let mut targets = BTreeMap::new(); for (symbol, weight) in target_weights { let price = data .price(date, symbol, self.execution_price_field) .ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.clone(), field: price_field_name(self.execution_price_field), })?; let raw_qty = ((equity * weight) / price).floor() as u32; let rounded_qty = self.round_buy_quantity(raw_qty, self.round_lot(data, symbol)); targets.insert(symbol.clone(), rounded_qty); } Ok(targets) } fn process_sell( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, symbol: &str, requested_qty: u32, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, 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(()); }; let rule = self.rules.can_sell( date, snapshot, candidate, position, self.execution_price_field, ); if !rule.allowed { let status = match rule.reason.as_deref() { Some("paused") | Some("sell disabled by eligibility flags") | Some("open at or below lower limit") => OrderStatus::Canceled, _ => OrderStatus::Rejected, }; report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, filled_quantity: 0, status, reason: format!("{reason}: {}", rule.reason.unwrap_or_default()), }); return Ok(()); } let sellable = position.sellable_qty(date); let market_limited_qty = self.market_fillable_quantity( snapshot, OrderSide::Sell, requested_qty.min(sellable), self.round_lot(data, symbol), *intraday_turnover.get(symbol).unwrap_or(&0), ); let filled_qty = match market_limited_qty { Ok(quantity) => quantity.min(sellable), Err(limit_reason) => { report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, filled_quantity: 0, status: OrderStatus::Rejected, reason: format!("{reason}: {limit_reason}"), }); return Ok(()); } }; if filled_qty == 0 { report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, filled_quantity: 0, status: OrderStatus::Rejected, reason: format!("{reason}: no sellable quantity"), }); return Ok(()); } let cash_before = portfolio.cash(); let fill = self.resolve_execution_fill( date, symbol, OrderSide::Sell, snapshot, data, filled_qty, self.round_lot(data, symbol), execution_cursors, None, None, None, ); let (filled_qty, execution_price) = if let Some(fill) = fill { execution_cursors.insert(symbol.to_string(), fill.next_cursor); if self.uses_serial_execution_cursor(reason) { *global_execution_cursor = Some(fill.next_cursor); } (fill.quantity, fill.price) } else { (filled_qty, self.sell_price(snapshot)) }; let gross_amount = execution_price * filled_qty as f64; let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount); let net_cash = gross_amount - cost.total(); let realized_pnl = portfolio .position_mut(symbol) .sell(filled_qty, execution_price) .map_err(BacktestError::Execution)?; portfolio.apply_cash_delta(net_cash); portfolio.prune_flat_positions(); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let status = if filled_qty < requested_qty { OrderStatus::PartiallyFilled } else { OrderStatus::Filled }; report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, filled_quantity: filled_qty, status, reason: reason.to_string(), }); report.fill_events.push(FillEvent { date, symbol: symbol.to_string(), side: OrderSide::Sell, quantity: filled_qty, price: execution_price, gross_amount, commission: cost.commission, stamp_tax: cost.stamp_tax, net_cash_flow: net_cash, reason: reason.to_string(), }); report.position_events.push(PositionEvent { date, symbol: symbol.to_string(), delta_quantity: -(filled_qty 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, cash_after: portfolio.cash(), total_equity: self.total_equity_at(date, portfolio, data, PriceField::Open)?, note: format!("sell {symbol} {reason}"), }); 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, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let price = data .market(date, symbol) .map(|snapshot| self.sizing_price(snapshot)) .ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.to_string(), field: price_field_name(self.execution_price_field), })?; let current_qty = portfolio .position(symbol) .map(|pos| pos.quantity) .unwrap_or(0); let current_value = price * current_qty as f64; let target_qty = self.round_buy_quantity( ((target_value.max(0.0)) / price).floor() as u32, self.round_lot(data, symbol), ); if current_qty > target_qty { self.process_sell( date, portfolio, data, symbol, current_qty - target_qty, reason, intraday_turnover, execution_cursors, global_execution_cursor, report, )?; } else if target_qty > current_qty { self.process_buy( date, portfolio, data, symbol, target_qty - current_qty, reason, intraday_turnover, execution_cursors, global_execution_cursor, None, report, )?; } else if (current_value - target_value).abs() <= f64::EPSILON { report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: if current_qty > 0 { OrderSide::Sell } else { OrderSide::Buy }, requested_quantity: 0, filled_quantity: 0, status: OrderStatus::Filled, reason: format!("{reason}: already at target value"), }); } Ok(()) } fn process_value( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, symbol: &str, value: f64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, 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 price = self.sizing_price(snapshot); let snapshot_requested_qty = self.round_buy_quantity(((value.abs()) / price).floor() as u32, round_lot); 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, reason, intraday_turnover, execution_cursors, global_execution_cursor, Some(value.abs()), report, ) } else { let price = self.sizing_price(snapshot); let requested_qty = self.round_buy_quantity( ((value.abs()) / price).floor() as u32, self.round_lot(data, symbol), ); self.process_sell( date, portfolio, data, symbol, requested_qty, reason, intraday_turnover, execution_cursors, global_execution_cursor, report, ) } } fn estimate_value_buy_quantity( &self, date: NaiveDate, portfolio: &PortfolioState, data: &DataSet, symbol: &str, round_lot: u32, value_budget: f64, intraday_turnover: &BTreeMap, execution_cursors: &BTreeMap, global_execution_cursor: Option, ) -> Option { if self.execution_price_field != PriceField::Last { return None; } let snapshot = data.market(date, symbol)?; let market_limited_qty = self .market_fillable_quantity( snapshot, OrderSide::Buy, u32::MAX, round_lot, *intraday_turnover.get(symbol).unwrap_or(&0), ) .ok()?; let max_requested_qty = market_limited_qty; 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(estimated) = self.select_buy_sizing_fill( snapshot, quotes, start_cursor, max_requested_qty, round_lot, Some(portfolio.cash()), Some(value_budget), ) { return Some(estimated.quantity); } let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); let fallback_qty = self.affordable_buy_quantity( portfolio.cash(), Some(value_budget), execution_price, max_requested_qty, round_lot, ); if fallback_qty > 0 { Some(fallback_qty) } else { None } } 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 select_buy_sizing_fill( &self, snapshot: &crate::data::DailyMarketSnapshot, quotes: &[IntradayExecutionQuote], start_cursor: Option, requested_qty: u32, round_lot: u32, cash_limit: Option, gross_limit: 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; for quote in quotes { if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) { continue; } let fallback_quote_price = if quote.last_price.is_finite() && quote.last_price > 0.0 { Some(quote.last_price) } else { quote.buy_price() }; if quote.volume_delta == 0 { continue; } let Some(raw_quote_price) = fallback_quote_price else { continue; }; let quote_price = self.quote_execution_price(snapshot, OrderSide::Buy, raw_quote_price); if !quote_price.is_finite() || quote_price <= 0.0 { continue; } let available_qty = quote .ask1_volume .saturating_mul(lot as u64) .min(u32::MAX as u64) as u32; if available_qty == 0 { continue; } let remaining_qty = requested_qty.saturating_sub(filled_qty); if remaining_qty == 0 { break; } let mut take_qty = remaining_qty.min(available_qty); take_qty = self.round_buy_quantity(take_qty, lot); if take_qty == 0 { continue; } if let Some(cash) = cash_limit { while take_qty > 0 { let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { take_qty = take_qty.saturating_sub(lot); continue; } let candidate_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross); if candidate_gross + candidate_cost.total() <= cash + 1e-6 { break; } take_qty = take_qty.saturating_sub(lot); } if take_qty == 0 { break; } } gross_amount += quote_price * take_qty as f64; filled_qty += take_qty; last_timestamp = Some(quote.timestamp); if filled_qty >= requested_qty { break; } } if filled_qty == 0 { return None; } Some(ExecutionFill { price: gross_amount / filled_qty as f64, quantity: filled_qty, next_cursor: last_timestamp.unwrap() + Duration::seconds(1), }) } fn process_buy( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, symbol: &str, requested_qty: u32, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, value_budget: Option, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let snapshot = data.require_market(date, symbol)?; let candidate = data.require_candidate(date, symbol)?; let rule = self .rules .can_buy(date, snapshot, candidate, self.execution_price_field); if !rule.allowed { report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Buy, requested_quantity: requested_qty, filled_quantity: 0, status: OrderStatus::Rejected, reason: format!("{reason}: {}", rule.reason.unwrap_or_default()), }); return Ok(()); } let market_limited_qty = self.market_fillable_quantity( snapshot, OrderSide::Buy, requested_qty, self.round_lot(data, symbol), *intraday_turnover.get(symbol).unwrap_or(&0), ); let constrained_qty = match market_limited_qty { Ok(quantity) => quantity, Err(limit_reason) => { report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Buy, requested_quantity: requested_qty, filled_quantity: 0, status: OrderStatus::Rejected, reason: format!("{reason}: {limit_reason}"), }); return Ok(()); } }; let fill = self.resolve_execution_fill( date, symbol, OrderSide::Buy, snapshot, data, constrained_qty, self.round_lot(data, symbol), execution_cursors, None, Some(portfolio.cash()), value_budget.map(|budget| budget + 400.0), ); let (filled_qty, execution_price) = if let Some(fill) = fill { execution_cursors.insert(symbol.to_string(), fill.next_cursor); if self.uses_serial_execution_cursor(reason) { *global_execution_cursor = Some(fill.next_cursor); } (fill.quantity, fill.price) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); let filled_qty = self.affordable_buy_quantity( portfolio.cash(), value_budget.map(|budget| budget + 400.0), execution_price, constrained_qty, self.round_lot(data, symbol), ); (filled_qty, execution_price) }; if filled_qty == 0 { report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Buy, requested_quantity: requested_qty, filled_quantity: 0, status: OrderStatus::Rejected, reason: format!("{reason}: insufficient cash after fees"), }); return Ok(()); } let cash_before = portfolio.cash(); let gross_amount = execution_price * filled_qty as f64; let cost = self.cost_model.calculate(OrderSide::Buy, gross_amount); let cash_out = gross_amount + cost.total(); portfolio.apply_cash_delta(-cash_out); portfolio .position_mut(symbol) .buy(date, filled_qty, execution_price); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let status = if filled_qty < requested_qty { OrderStatus::PartiallyFilled } else { OrderStatus::Filled }; report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Buy, requested_quantity: requested_qty, filled_quantity: filled_qty, status, reason: reason.to_string(), }); report.fill_events.push(FillEvent { date, symbol: symbol.to_string(), side: OrderSide::Buy, quantity: filled_qty, price: execution_price, gross_amount, commission: cost.commission, stamp_tax: cost.stamp_tax, net_cash_flow: -cash_out, reason: reason.to_string(), }); report.position_events.push(PositionEvent { date, symbol: symbol.to_string(), delta_quantity: filled_qty 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, cash_after: portfolio.cash(), total_equity: self.total_equity_at(date, portfolio, data, PriceField::Open)?, note: format!("buy {symbol} {reason}"), }); 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::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 round_buy_quantity(&self, quantity: u32, round_lot: u32) -> u32 { let lot = round_lot.max(1); (quantity / lot) * lot } fn affordable_buy_quantity( &self, cash: f64, gross_limit: Option, price: f64, requested_qty: u32, round_lot: u32, ) -> u32 { let lot = round_lot.max(1); let mut quantity = self.round_buy_quantity(requested_qty, lot); while quantity > 0 { let gross = price * quantity as f64; if gross_limit.is_some_and(|limit| gross > limit + 1e-6) { quantity = quantity.saturating_sub(lot); continue; } let cost = self.cost_model.calculate(OrderSide::Buy, gross); if gross + cost.total() <= cash + 1e-6 { return quantity; } quantity = quantity.saturating_sub(lot); } 0 } fn market_fillable_quantity( &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, requested_qty: u32, round_lot: u32, consumed_turnover: u32, ) -> 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; let lot = round_lot.max(1); if self.liquidity_limit { let top_level_liquidity = match side { OrderSide::Buy => snapshot.liquidity_for_buy(), OrderSide::Sell => snapshot.liquidity_for_sell(), } .min(u32::MAX as u64) as u32; if top_level_liquidity == 0 { return Err("no quote liquidity".to_string()); } max_fill = max_fill.min(self.round_buy_quantity(top_level_liquidity, lot)); } if self.volume_limit { let raw_limit = ((snapshot.tick_volume as f64) * self.volume_percent).round() as i64 - consumed_turnover as i64; if raw_limit <= 0 { return Err("tick volume limit".to_string()); } let volume_limited = self.round_buy_quantity(raw_limit as u32, lot); if volume_limited == 0 { return Err("tick volume limit".to_string()); } max_fill = max_fill.min(volume_limited); } Ok(max_fill) } fn resolve_execution_fill( &self, date: NaiveDate, symbol: &str, side: OrderSide, snapshot: &crate::data::DailyMarketSnapshot, data: &DataSet, requested_qty: u32, round_lot: u32, execution_cursors: &mut BTreeMap, global_execution_cursor: Option, cash_limit: Option, gross_limit: Option, ) -> Option { if self.execution_price_field != PriceField::Last { return None; } if self.intraday_execution_start_time.is_some() { let execution_price = self.snapshot_execution_price(snapshot, side); let quantity = match side { OrderSide::Buy => self.affordable_buy_quantity( cash_limit.unwrap_or(f64::INFINITY), gross_limit, execution_price, requested_qty, round_lot, ), 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 { price: execution_price, quantity, next_cursor, }); } 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, cash_limit, gross_limit, ) { return Some(fill); } None } fn select_execution_fill( &self, snapshot: &crate::data::DailyMarketSnapshot, quotes: &[IntradayExecutionQuote], side: OrderSide, start_cursor: Option, requested_qty: u32, round_lot: u32, cash_limit: Option, gross_limit: 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; for quote in quotes { if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) { continue; } // 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; }; let top_level_liquidity = match side { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, }; let available_qty = top_level_liquidity .saturating_mul(lot as u64) .min(u32::MAX as u64) as u32; if available_qty == 0 { continue; } let remaining_qty = requested_qty.saturating_sub(filled_qty); if remaining_qty == 0 { break; } let mut take_qty = remaining_qty.min(available_qty); take_qty = self.round_buy_quantity(take_qty, lot); if take_qty == 0 { continue; } if let Some(cash) = cash_limit { while take_qty > 0 { let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { take_qty = take_qty.saturating_sub(lot); continue; } if candidate_gross <= cash + 1e-6 { break; } take_qty = take_qty.saturating_sub(lot); } if take_qty == 0 { break; } } gross_amount += quote_price * take_qty as f64; filled_qty += take_qty; last_timestamp = Some(quote.timestamp); if filled_qty >= requested_qty { break; } } if filled_qty == 0 { return None; } Some(ExecutionFill { price: gross_amount / filled_qty as f64, quantity: filled_qty, next_cursor: last_timestamp.unwrap() + Duration::seconds(1), }) } fn uses_serial_execution_cursor(&self, reason: &str) -> bool { let _ = reason; false } } fn price_field_name(field: PriceField) -> &'static str { match field { PriceField::Open => "open", PriceField::Close => "close", PriceField::Last => "last", } } fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { if decision.exit_symbols.contains(symbol) { "exit_hook_sell" } else { "rebalance_sell" } }