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::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope}; use crate::rules::{EquityRuleHooks, RuleCheck}; use crate::strategy::{ AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, }; #[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, mark_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, decision_date: Option, order_created_date: Option, 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, 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, MinuteLast, MinuteBestOwn, MinuteBestCounterparty, Vwap, Twap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RebalanceCashMode { SamePointNet, SellThenBuy, PreOpenCash, } impl Default for RebalanceCashMode { fn default() -> Self { Self::SellThenBuy } } #[derive(Debug, Clone, Copy, PartialEq)] pub struct DynamicSlippageConfig { pub impact_coefficient: f64, pub volatility_coefficient: f64, pub max_ratio: f64, } impl DynamicSlippageConfig { pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self { Self { impact_coefficient: impact_coefficient.max(0.0), volatility_coefficient: volatility_coefficient.max(0.0), max_ratio: max_ratio.max(0.0), } } pub(crate) fn ratio( &self, snapshot: &crate::data::DailyMarketSnapshot, raw_price: f64, order_value: Option, ) -> f64 { let daily_amount = (snapshot.volume as f64 * raw_price).max(0.0); let impact_ratio = match order_value { Some(value) if value.is_finite() && value > 0.0 && daily_amount > 0.0 => { value / daily_amount } _ => 0.0, }; let volatility_base = if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 { snapshot.prev_close } else { raw_price }; let volatility = if snapshot.high.is_finite() && snapshot.low.is_finite() && volatility_base.is_finite() && volatility_base > 0.0 { ((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0) } else { 0.0 }; let ratio = impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient; ratio.clamp(0.0, self.max_ratio) } } impl Default for DynamicSlippageConfig { fn default() -> Self { Self::new(0.5, 0.3, 0.01) } } #[derive(Debug, Clone, Copy, PartialEq)] pub enum SlippageModel { None, PriceRatio(f64), TickSize(f64), LimitPrice, Dynamic(DynamicSlippageConfig), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AlgoExecutionStyle { Vwap, Twap, } #[derive(Debug, Clone, Copy)] struct AlgoExecutionRequest { style: AlgoExecutionStyle, start_time: Option, end_time: Option, } 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, strict_value_budget: bool, rebalance_cash_mode: RebalanceCashMode, sell_then_buy_delay_slippage_rate: f64, aiquant_execution_rules: bool, same_day_buy_close_mark_at_fill: bool, risk_config: FidcRiskControlConfig, same_day_sold_symbols: RefCell>>, intraday_execution_start_time: Option, runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, runtime_decision_date: Cell>, runtime_order_created_date: Cell>, 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, strict_value_budget: false, rebalance_cash_mode: RebalanceCashMode::default(), sell_then_buy_delay_slippage_rate: 0.0, aiquant_execution_rules: false, same_day_buy_close_mark_at_fill: false, risk_config: FidcRiskControlConfig::default(), same_day_sold_symbols: RefCell::new(BTreeMap::new()), intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), runtime_decision_date: Cell::new(None), runtime_order_created_date: Cell::new(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, strict_value_budget: false, rebalance_cash_mode: RebalanceCashMode::default(), sell_then_buy_delay_slippage_rate: 0.0, aiquant_execution_rules: false, same_day_buy_close_mark_at_fill: false, risk_config: FidcRiskControlConfig::default(), same_day_sold_symbols: RefCell::new(BTreeMap::new()), intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), runtime_decision_date: Cell::new(None), runtime_order_created_date: Cell::new(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_strict_value_budget(mut self, enabled: bool) -> Self { self.strict_value_budget = enabled; self } pub fn with_rebalance_cash_mode(mut self, mode: RebalanceCashMode) -> Self { self.rebalance_cash_mode = mode; self } pub fn with_sell_then_buy_delay_slippage_rate(mut self, rate: f64) -> Self { self.sell_then_buy_delay_slippage_rate = if rate.is_finite() && rate > 0.0 { rate.min(0.999_999) } else { 0.0 }; self } pub fn with_aiquant_execution_rules(mut self, enabled: bool) -> Self { self.aiquant_execution_rules = enabled; self } pub fn with_same_day_buy_close_mark_at_fill(mut self, enabled: bool) -> Self { self.same_day_buy_close_mark_at_fill = enabled; self } pub fn with_risk_config(mut self, config: FidcRiskControlConfig) -> Self { self.volume_limit = config.trading_constraints.volume_limit_enabled; self.volume_percent = config.trading_constraints.volume_percent; self.liquidity_limit = config.trading_constraints.liquidity_limit_enabled; self.risk_config = config; self } pub fn same_day_buy_close_mark_at_fill(&self) -> bool { self.same_day_buy_close_mark_at_fill } 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.execution_price_field = execution_price_field_from_matching_type(matching_type); self } pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self { self.slippage_model = slippage_model; self } pub fn matching_type(&self) -> MatchingType { self.matching_type } pub fn rebalance_cash_mode(&self) -> RebalanceCashMode { self.rebalance_cash_mode } fn effective_rebalance_cash_mode(&self) -> RebalanceCashMode { if self.matching_type == MatchingType::MinuteLast { RebalanceCashMode::SellThenBuy } else { self.rebalance_cash_mode } } pub fn execution_price_field(&self) -> PriceField { self.execution_price_field } pub fn intraday_execution_start_time(&self) -> Option { self.intraday_execution_start_time } 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, unfilled_quantity: order.remaining_quantity, status: OrderStatus::Pending, avg_price: 0.0, transaction_cost: 0.0, 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 value_buy_sizing_price( &self, date: NaiveDate, data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, ) -> f64 { self.value_order_sizing_price(date, data, symbol, snapshot, OrderSide::Buy) } fn value_sell_sizing_price( &self, date: NaiveDate, data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, ) -> f64 { self.value_order_sizing_price(date, data, symbol, snapshot, OrderSide::Sell) } fn target_value_valuation_price( &self, date: NaiveDate, data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, ) -> f64 { if self.aiquant_execution_rules && self.execution_price_field == PriceField::Last { let start_cursor = self .runtime_intraday_start_time .get() .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time)); let matching_type = self.matching_type_for_algo_request(None); if let Some(quote) = self.latest_known_quote_at_or_before( data.execution_quotes_on(date, symbol), start_cursor, snapshot, OrderSide::Buy, matching_type, false, ) { let fallback = self .select_quote_reference_price(snapshot, quote, OrderSide::Buy, matching_type) .unwrap_or(snapshot.last_price); let mark_price = self.quote_mark_price(quote, fallback); if mark_price.is_finite() && mark_price > 0.0 { return mark_price; } } } if snapshot.close.is_finite() && snapshot.close > 0.0 { snapshot.close } else { self.sizing_price(snapshot) } } fn value_order_sizing_price( &self, date: NaiveDate, data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { let start_cursor = self .runtime_intraday_start_time .get() .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time)); let matching_type = self.matching_type_for_algo_request(None); self.latest_known_quote_at_or_before( data.execution_quotes_on(date, symbol), start_cursor, snapshot, side, matching_type, false, ) .and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type)) .unwrap_or_else(|| self.sizing_price(snapshot)) } fn snapshot_execution_price( &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, quantity: Option, ) -> f64 { let raw_price = self.snapshot_raw_execution_price(snapshot, side); self.apply_slippage(snapshot, side, raw_price, quantity) } fn snapshot_raw_execution_price( &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { if self.execution_price_field == PriceField::Last && self.intraday_execution_start_time.is_some() { return snapshot.price(PriceField::Last); } match side { OrderSide::Buy => self.buy_price(snapshot), OrderSide::Sell => self.sell_price(snapshot), } } fn snapshot_mark_price( &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { let price = snapshot.price(self.execution_price_field); if price.is_finite() && price > 0.0 { price } else { self.snapshot_raw_execution_price(snapshot, side) } } 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, quantity: Option, ) -> 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 order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); let mut 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, SlippageModel::Dynamic(config) => { let ratio = config.ratio(snapshot, raw_price, order_value); match side { OrderSide::Buy => raw_price * (1.0 + ratio), OrderSide::Sell => raw_price * (1.0 - ratio), } } }; if side == OrderSide::Buy && self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy && self.sell_then_buy_delay_slippage_rate > 0.0 { adjusted *= 1.0 + self.sell_then_buy_delay_slippage_rate; } 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, quantity: Option, ) -> f64 { self.apply_slippage(snapshot, side, raw_price, quantity) } fn matching_type_for_algo_request( &self, algo_request: Option<&AlgoExecutionRequest>, ) -> MatchingType { match algo_request.map(|request| request.style) { Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap, Some(AlgoExecutionStyle::Twap) => MatchingType::Twap, None => self.matching_type, } } fn select_quote_reference_price( &self, _snapshot: &crate::data::DailyMarketSnapshot, quote: &IntradayExecutionQuote, side: OrderSide, matching_type: MatchingType, ) -> Option { let raw_price = match matching_type { MatchingType::MinuteBestOwn => 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::MinuteBestCounterparty => match side { OrderSide::Buy => quote.buy_price(), OrderSide::Sell => quote.sell_price(), }, MatchingType::MinuteLast | MatchingType::Vwap | MatchingType::Twap => { 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(), }, }?; if raw_price.is_finite() && raw_price > 0.0 { Some(raw_price) } else { None } } fn quote_mark_price(&self, quote: &IntradayExecutionQuote, fallback: f64) -> f64 { if quote.last_price.is_finite() && quote.last_price > 0.0 { quote.last_price } else { fallback } } pub fn execute( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, ) -> Result { self.execute_with_event_dates(date, date, date, portfolio, data, decision) } pub fn execute_with_event_dates( &self, date: NaiveDate, decision_date: NaiveDate, order_created_date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, ) -> Result { let previous_decision_date = self.runtime_decision_date.get(); let previous_order_created_date = self.runtime_order_created_date.get(); self.runtime_decision_date.set(Some(decision_date)); self.runtime_order_created_date .set(Some(order_created_date)); let result = self.execute_with_runtime_dates(date, portfolio, data, decision); self.runtime_decision_date.set(previous_decision_date); self.runtime_order_created_date .set(previous_order_created_date); result } fn execute_with_runtime_dates( &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, None, &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, None, &mut report, )?; } } } portfolio.prune_flat_positions(); Ok(report) } pub fn execute_between( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, start_time: Option, end_time: Option, ) -> Result { self.execute_between_with_event_dates( date, date, date, portfolio, data, decision, start_time, end_time, ) } pub fn execute_between_with_event_dates( &self, date: NaiveDate, decision_date: NaiveDate, order_created_date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, start_time: Option, end_time: Option, ) -> Result { let previous_start_time = self.runtime_intraday_start_time.get(); let previous_end_time = self.runtime_intraday_end_time.get(); self.runtime_intraday_start_time.set(start_time); self.runtime_intraday_end_time.set(end_time); let result = self.execute_with_event_dates( date, decision_date, order_created_date, portfolio, data, decision, ); self.runtime_intraday_start_time.set(previous_start_time); self.runtime_intraday_end_time.set(previous_end_time); result } 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, None, 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::TimedTargetValue { symbol, target_value, style, start_time, end_time, reason, } => self.process_timed_target_value( date, portfolio, data, symbol, *target_value, *style, *start_time, *end_time, 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::AlgoValue { symbol, value, style, start_time, end_time, reason, } => self.process_algo_value( date, portfolio, data, symbol, *value, *style, *start_time, *end_time, reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, report, ), OrderIntent::AlgoPercent { symbol, percent, style, start_time, end_time, reason, } => self.process_algo_percent( date, portfolio, data, symbol, *percent, *style, *start_time, *end_time, 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(()) } OrderIntent::UpdateUniverse { symbols, reason } => { report.diagnostics.push(format!( "engine_control_intent_skipped kind=update_universe count={} reason={}", symbols.len(), reason )); Ok(()) } OrderIntent::Subscribe { symbols, reason } => { report.diagnostics.push(format!( "engine_control_intent_skipped kind=subscribe count={} reason={}", symbols.len(), reason )); Ok(()) } OrderIntent::Unsubscribe { symbols, reason } => { report.diagnostics.push(format!( "engine_control_intent_skipped kind=unsubscribe count={} reason={}", symbols.len(), reason )); Ok(()) } OrderIntent::DepositWithdraw { amount, receiving_days, reason, } => { report.diagnostics.push(format!( "engine_account_intent_skipped kind=deposit_withdraw amount={amount:.2} receiving_days={receiving_days} reason={reason}" )); Ok(()) } OrderIntent::FinanceRepay { amount, reason } => { report.diagnostics.push(format!( "engine_account_intent_skipped kind=finance_repay amount={amount:.2} reason={reason}" )); Ok(()) } OrderIntent::SetManagementFeeRate { rate, reason } => { report.diagnostics.push(format!( "engine_account_intent_skipped kind=set_management_fee_rate rate={rate:.6} reason={reason}" )); Ok(()) } OrderIntent::Futures { intent } => { report.diagnostics.push(format!( "engine_futures_intent_skipped symbol={} direction={} effect={} reason={}", intent.symbol, intent.direction.as_str(), intent.effect.as_str(), intent.reason )); Ok(()) } } } fn latest_known_quote_at_or_before<'a>( &self, quotes: &'a [IntradayExecutionQuote], cursor: Option, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, matching_type: MatchingType, require_executable_liquidity: bool, ) -> Option<&'a IntradayExecutionQuote> { let Some(cursor) = cursor else { return quotes.iter().find(|quote| { self.select_quote_reference_price(snapshot, quote, side, matching_type) .is_some() && (!require_executable_liquidity || self.quote_has_executable_liquidity(quote, side, matching_type)) }); }; let latest = quotes .iter() .filter(|quote| { quote.timestamp <= cursor && self .select_quote_reference_price(snapshot, quote, side, matching_type) .is_some() }) .max_by_key(|quote| quote.timestamp)?; if require_executable_liquidity && !self.quote_has_executable_liquidity(latest, side, matching_type) { return None; } Some(latest) } 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, None, 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, None, 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 current_decision_date(&self, date: NaiveDate) -> NaiveDate { self.runtime_decision_date.get().unwrap_or(date) } fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate { self.runtime_order_created_date.get().unwrap_or(date) } fn annotate_report_range( report: &mut BrokerExecutionReport, order_event_start: usize, fill_event_start: usize, decision_date: NaiveDate, order_created_date: NaiveDate, execution_date: NaiveDate, ) { for event in &mut report.order_events[order_event_start..] { event.decision_date.get_or_insert(decision_date); event.order_created_date.get_or_insert(order_created_date); event.execution_date.get_or_insert(execution_date); } for fill in &mut report.fill_events[fill_event_start..] { fill.decision_date.get_or_insert(decision_date); fill.order_created_date.get_or_insert(order_created_date); fill.execution_date.get_or_insert(execution_date); } } fn clear_open_order(&self, order_id: u64) { self.open_orders .borrow_mut() .retain(|existing| existing.order_id != order_id); } fn mark_same_day_sold(&self, date: NaiveDate, symbol: &str) { self.same_day_sold_symbols .borrow_mut() .entry(date) .or_default() .insert(symbol.to_string()); } fn same_day_rebuy_rejection_reason( &self, date: NaiveDate, symbol: &str, ) -> Option<&'static str> { if !self .risk_config .static_rules .forbid_same_day_rebuy_after_sell { return None; } let sold_today = self .same_day_sold_symbols .borrow() .get(&date) .is_some_and(|symbols| symbols.contains(symbol)); if sold_today { return Some("same_day_rebuy_forbidden"); } None } 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 order_event_start = report.order_events.len(); let fill_event_start = report.fill_events.len(); 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, )?; Self::annotate_report_range( report, order_event_start, fill_event_start, order.decision_date.unwrap_or(date), order.order_created_date.unwrap_or(date), date, ); } 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, decision_date: order.decision_date, order_created_date: order.order_created_date, execution_date: Some(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, decision_date: order.decision_date, order_created_date: order.order_created_date, execution_date: Some(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 reject_missing_market_snapshot_order( &self, report: &mut BrokerExecutionReport, date: NaiveDate, symbol: &str, side: OrderSide, requested_quantity: u32, reason: &str, ) { let order_id = self.reserve_order_id(); let order_reason = format!( "{reason}: rejected because market snapshot is missing for {} price field {}", symbol, price_field_name(self.execution_price_field) ); report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, order_id: Some(order_id), symbol: symbol.to_string(), side, requested_quantity, filled_quantity: 0, status: OrderStatus::Rejected, reason: order_reason.clone(), }); Self::emit_order_process_event( report, date, ProcessEventKind::OrderUnsolicitedUpdate, order_id, symbol, side, format!( "status=Rejected requested_quantity={requested_quantity} filled_quantity=0 reason={order_reason}" ), ); } fn reject_missing_market_or_execution_risk_order( &self, report: &mut BrokerExecutionReport, date: NaiveDate, data: &DataSet, symbol: &str, side: OrderSide, requested_quantity: u32, reason: &str, ) { if let Some(unavailable_reason) = self.missing_market_execution_risk_rejection_reason(date, data, symbol, side) { let order_id = self.reserve_order_id(); Self::reject_unavailable_order( report, date, order_id, symbol, side, requested_quantity, reason, unavailable_reason, false, ); return; } self.reject_missing_market_snapshot_order( report, date, symbol, side, requested_quantity, reason, ); } fn missing_market_execution_risk_rejection_reason( &self, date: NaiveDate, data: &DataSet, symbol: &str, side: OrderSide, ) -> Option<&'static str> { let scope = match side { OrderSide::Buy => RiskCheckScope::Buy, OrderSide::Sell => RiskCheckScope::Sell, }; ChinaAShareRiskControl::active_status_rejection_reason_with_config( date, data.candidate(date, symbol), data.instrument(symbol), &self.risk_config, scope, ) } fn reject_unavailable_order( report: &mut BrokerExecutionReport, date: NaiveDate, order_id: u64, symbol: &str, side: OrderSide, requested_quantity: u32, reason: &str, unavailable_reason: impl Into, emit_creation_events: bool, ) { let unavailable_reason = unavailable_reason.into(); if emit_creation_events { Self::emit_order_process_event( report, date, ProcessEventKind::OrderPendingNew, order_id, symbol, side, format!("requested_quantity={requested_quantity} reason={reason}"), ); } report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, order_id: Some(order_id), symbol: symbol.to_string(), side, requested_quantity, filled_quantity: 0, status: OrderStatus::Rejected, reason: format!("{reason}: {unavailable_reason}"), }); Self::emit_order_process_event( report, date, Self::creation_reject_kind(emit_creation_events), order_id, symbol, side, format!( "status=Rejected requested_quantity={requested_quantity} filled_quantity=0 reason={unavailable_reason}" ), ); } 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() .filter(|weight| weight.abs() > f64::EPSILON) .sum::(); let mut desired_targets = BTreeMap::new(); let mut diagnostics = Vec::new(); for (symbol, weight) in target_weights { if weight.abs() <= f64::EPSILON { continue; } 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 cash_mode = self.effective_rebalance_cash_mode(); 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 && cash_mode != RebalanceCashMode::PreOpenCash { 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, 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 mut best_safety = f64::NEG_INFINITY; let mut record_candidate = |safety_value: f64| -> bool { let safety_value = safety_value.clamp(0.0, 1.0); 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_value).floor() as u32; let target_qty = self .round_buy_quantity( scaled_desired_qty, constraint.minimum_order_quantity, constraint.order_step_size, ) .max(constraint.current_qty) .min(constraint.provisional_target_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 || ((proportion_diff - best_proportion_diff).abs() <= 1e-12 && safety_value > best_safety) { best_targets = candidate_targets; best_proportion_diff = proportion_diff; best_safety = safety_value; } return true; } false }; if !record_candidate(1.0) && record_candidate(0.0) { let mut low = 0.0; let mut high = 1.0; for _ in 0..24 { let mid = (low + high) / 2.0; if record_candidate(mid) { low = mid; } else { high = mid; } } let high_toward_zero = if high > 0.0 { f64::from_bits(high.to_bits().saturating_sub(1)) } else { high }; for safety in [0.0, low, high_toward_zero, high, 1.0] { if (0.0..=1.0).contains(&safety) { record_candidate(safety); } } } if best_safety.is_finite() && best_safety < 1.0 && diagnostics.len() < 16 { diagnostics.push(format!( "rebalance_safety_scaled final_safety={:.4} target_weight_sum={:.4} projected_cash={:.2}", best_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<&TargetPortfolioOrderPricing>, 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 limit_prices = match order_prices { Some(TargetPortfolioOrderPricing::LimitPrices(prices)) => Some(prices), _ => None, }; let algo_request = match order_prices { Some(TargetPortfolioOrderPricing::AlgoOrder { style, start_time, end_time, }) => Some(AlgoExecutionRequest { style: match style { AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap, AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap, }, start_time: *start_time, end_time: *end_time, }), _ => None, }; let mut symbols = BTreeSet::new(); symbols.extend(portfolio.positions().keys().cloned()); symbols.extend(target_quantities.keys().cloned()); symbols.extend( target_weights .iter() .filter(|(_, weight)| weight.abs() > f64::EPSILON) .map(|(symbol, _)| symbol.clone()), ); self.record_denied_target_portfolio_buys( date, portfolio, data, target_weights, &target_quantities, valuation_prices, reason, 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 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, limit_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, algo_request.as_ref(), &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, limit_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, algo_request.as_ref(), &mut local_report, )?; } Self::extend_report(report, local_report); } Ok(()) } fn record_denied_target_portfolio_buys( &self, date: NaiveDate, portfolio: &PortfolioState, data: &DataSet, target_weights: &BTreeMap, target_quantities: &BTreeMap, valuation_prices: Option<&BTreeMap>, reason: &str, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let equity = self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?; for (symbol, weight) in target_weights { if weight.abs() <= f64::EPSILON { continue; } 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 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 desired_qty = self.round_buy_quantity( ((equity * weight) / price).floor() as u32, minimum_order_quantity, order_step_size, ); if desired_qty <= current_qty { continue; } let Some(rule_reason) = self.buy_target_denial_reason( date, portfolio, data, symbol, current_qty, minimum_order_quantity, order_step_size, ) else { continue; }; let order_id = self.reserve_order_id(); let requested_quantity = desired_qty - current_qty; let status = zero_fill_status_for_reason(&rule_reason); report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, order_id: Some(order_id), symbol: symbol.clone(), side: OrderSide::Buy, requested_quantity, filled_quantity: 0, status, reason: format!("{reason}: {rule_reason}"), }); Self::emit_order_process_event( report, date, ProcessEventKind::OrderCreationReject, order_id, symbol, OrderSide::Buy, format!("status={status:?} reason={rule_reason}"), ); if report.diagnostics.len() < 32 { report.diagnostics.push(format!( "target_portfolio_buy_rejected symbol={} requested={} reason={}", symbol, requested_quantity, rule_reason )); } } Ok(()) } fn aiquant_limit_check_price( &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { match (self.execution_price_field, side) { (PriceField::Last, _) => snapshot.price(PriceField::Last), (_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field), (_, OrderSide::Sell) => snapshot.sell_price(self.execution_price_field), } } fn aiquant_order_limit_check_price( &self, date: NaiveDate, data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, algo_request: Option<&AlgoExecutionRequest>, ) -> f64 { let matching_type = self.matching_type_for_algo_request(algo_request); let start_cursor = algo_request .and_then(|request| request.start_time) .or(self.runtime_intraday_start_time.get()) .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time)); self.latest_known_quote_at_or_before( data.execution_quotes_on(date, symbol), start_cursor, snapshot, side, matching_type, false, ) .and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type)) .unwrap_or_else(|| self.aiquant_limit_check_price(snapshot, side)) } #[cfg(test)] fn buy_rule_check( &self, date: NaiveDate, snapshot: &crate::data::DailyMarketSnapshot, candidate: &crate::data::CandidateEligibility, instrument: Option<&Instrument>, ) -> RuleCheck { let check_price = if self.aiquant_execution_rules { self.aiquant_limit_check_price(snapshot, OrderSide::Buy) } else { ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field) }; if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, candidate, snapshot, instrument, check_price, &self.risk_config, ) { return RuleCheck::reject(reason); } if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() { return self .rules .can_buy(date, snapshot, candidate, self.execution_price_field); } RuleCheck::allow() } fn buy_rule_check_for_order( &self, date: NaiveDate, data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, candidate: &crate::data::CandidateEligibility, instrument: Option<&Instrument>, algo_request: Option<&AlgoExecutionRequest>, ) -> RuleCheck { let check_price = if self.aiquant_execution_rules { self.aiquant_order_limit_check_price( date, data, symbol, snapshot, OrderSide::Buy, algo_request, ) } else { ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field) }; if let Some(reason) = self.same_day_rebuy_rejection_reason(date, symbol) { return RuleCheck::reject(reason); } if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, candidate, snapshot, instrument, check_price, &self.risk_config, ) { return RuleCheck::reject(reason); } if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() { return self .rules .can_buy(date, snapshot, candidate, self.execution_price_field); } RuleCheck::allow() } fn sell_rule_check_for_order( &self, date: NaiveDate, data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, candidate: &crate::data::CandidateEligibility, instrument: Option<&Instrument>, position: &crate::portfolio::Position, algo_request: Option<&AlgoExecutionRequest>, ) -> RuleCheck { if self.risk_config.static_rules.respect_allow_buy_sell && !self.aiquant_execution_rules && !candidate.allow_sell { return RuleCheck::reject("sell_disabled"); } let check_price = if self.aiquant_execution_rules { self.aiquant_order_limit_check_price( date, data, symbol, snapshot, OrderSide::Sell, algo_request, ) } else { ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field) }; let adjusted_candidate; let candidate_for_check = if self.aiquant_execution_rules && self.aiquant_sell_allow_flag_is_stale_lower_limit(candidate, snapshot, check_price) { adjusted_candidate = crate::data::CandidateEligibility { allow_sell: true, ..candidate.clone() }; &adjusted_candidate } else { candidate }; if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason_with_config( date, candidate_for_check, snapshot, instrument, Some(position), check_price, &self.risk_config, ) { return RuleCheck::reject(reason); } if !self.aiquant_execution_rules && !self.rules.duplicates_standard_china_risk() { return self.rules.can_sell( date, snapshot, candidate, position, self.execution_price_field, ); } RuleCheck::allow() } fn aiquant_sell_allow_flag_is_stale_lower_limit( &self, candidate: &crate::data::CandidateEligibility, snapshot: &crate::data::DailyMarketSnapshot, check_price: f64, ) -> bool { if candidate.allow_sell || candidate.is_paused || snapshot.paused { return false; } let snapshot_check_price = ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field); snapshot.is_at_lower_limit_price(snapshot_check_price) && !snapshot.is_at_lower_limit_price(check_price) } 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; }; if self.aiquant_execution_rules { let sellable = position .sellable_qty(date) .saturating_sub(self.reserved_open_sell_quantity(symbol, None)); return current_qty.saturating_sub(sellable.min(current_qty)); } 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.sell_rule_check_for_order( date, data, symbol, snapshot, candidate, data.instrument(symbol), position, None, ); 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 { if self.aiquant_execution_rules { return u32::MAX; } 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.buy_rule_check_for_order( date, data, symbol, snapshot, candidate, data.instrument(symbol), None, ); 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.sell_rule_check_for_order( date, data, symbol, snapshot, candidate, data.instrument(symbol), position, None, ); 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.buy_rule_check_for_order( date, data, symbol, snapshot, candidate, data.instrument(symbol), None, ); 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, algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let Some(position) = portfolio.position(symbol) else { return Ok(()); }; let Some(snapshot) = data.market(date, symbol) else { let unavailable_reason = self .missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Sell) .map(str::to_string) .unwrap_or_else(|| { format!( "market snapshot is missing for price field {}", price_field_name(self.execution_price_field) ) }); Self::reject_unavailable_order( report, date, order_id, symbol, OrderSide::Sell, requested_qty, reason, unavailable_reason, emit_creation_events, ); return Ok(()); }; let Some(candidate) = data.candidate(date, symbol) else { Self::reject_unavailable_order( report, date, order_id, symbol, OrderSide::Sell, requested_qty, reason, "candidate eligibility is missing", emit_creation_events, ); 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.sell_rule_check_for_order( date, data, symbol, snapshot, candidate, data.instrument(symbol), position, algo_request, ); 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("sell_disabled") | Some("lower_limit") | Some("open at or below lower limit") => OrderStatus::Canceled, _ => OrderStatus::Rejected, }; report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), 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, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), 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, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: None, order_created_date: None, execution_date: None, 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, algo_request, 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, Some(fillable_qty)); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); (0, Vec::new()) } else 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 { match self.execution_price_with_limit_slippage_or_rejection( snapshot, OrderSide::Sell, execution_price, limit_price, ) { Ok(execution_price) => ( fillable_qty, vec![ExecutionLeg { price: execution_price, mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), quantity: fillable_qty, }], ), Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); (0, Vec::new()) } } } }; 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, decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), 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, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: None, order_created_date: None, execution_date: None, 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_with_mark_price(leg.quantity, leg.price, leg.mark_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, decision_date: None, order_created_date: None, execution_date: None, 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.mark_same_day_sold(date, symbol); 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, decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), 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, decision_date: None, order_created_date: None, execution_date: None, 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 current_qty = portfolio .position(symbol) .map(|pos| pos.quantity) .unwrap_or(0); if target_value <= f64::EPSILON { if current_qty == 0 { report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, order_id: None, symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: 0, filled_quantity: 0, status: OrderStatus::Filled, reason: format!("{reason}: already at target value"), }); return Ok(()); } if data.market(date, symbol).is_none() { self.reject_missing_market_or_execution_risk_order( report, date, data, symbol, OrderSide::Sell, current_qty, reason, ); return Ok(()); } self.process_sell( date, portfolio, data, symbol, current_qty, self.reserve_order_id(), reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, None, false, true, None, report, )?; return Ok(()); } let Some(snapshot) = data.market(date, symbol) else { self.reject_missing_market_or_execution_risk_order( report, date, data, symbol, if current_qty > 0 { OrderSide::Sell } else { OrderSide::Buy }, 0, reason, ); return Ok(()); }; let current_value = if self.aiquant_execution_rules { let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot); valuation_price * current_qty as f64 } else { let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot); valuation_price * current_qty as f64 }; let cash_delta = target_value.max(0.0) - current_value; if cash_delta.abs() > f64::EPSILON { self.process_value( date, portfolio, data, symbol, cash_delta, reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, report, )?; } else { report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, 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(()) } #[allow(clippy::too_many_arguments)] fn process_timed_target_value( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, symbol: &str, target_value: f64, style: AlgoOrderStyle, start_time: Option, end_time: Option, 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 snapshot = data .market(date, symbol) .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 algo_request = AlgoExecutionRequest { style: match style { AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap, AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap, }, start_time, end_time, }; if target_value <= f64::EPSILON { if current_qty == 0 { report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, order_id: None, symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: 0, filled_quantity: 0, status: OrderStatus::Filled, reason: format!("{reason}: already at target value"), }); return Ok(()); } self.process_sell( date, portfolio, data, symbol, current_qty, self.reserve_order_id(), reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, None, false, true, Some(&algo_request), report, )?; return Ok(()); } let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot); let current_value = valuation_price * current_qty as f64; let cash_delta = target_value.max(0.0) - current_value; if cash_delta.abs() > f64::EPSILON { self.process_algo_value( date, portfolio, data, symbol, cash_delta, style, start_time, end_time, reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, report, )?; } else { report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, 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, None, 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, None, report, )?; } } else { report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, 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 = if self.aiquant_execution_rules && limit_price.is_finite() && limit_price > 0.0 { limit_price } else { 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, None, 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, None, 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, None, 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, None, 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 Some(snapshot) = data.market(date, symbol) else { let requested_quantity = if value < 0.0 { portfolio .position(symbol) .map(|position| position.quantity) .unwrap_or(0) } else { 0 }; self.reject_missing_market_or_execution_risk_order( report, date, data, symbol, if value > 0.0 { OrderSide::Buy } else { OrderSide::Sell }, requested_quantity, reason, ); return Ok(()); }; 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.value_buy_sizing_price(date, data, symbol, snapshot); let snapshot_requested_qty = self.value_buy_quantity( date, value.abs(), price, 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, None, report, ) } else { let price = self.value_sell_sizing_price(date, data, symbol, 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, None, 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.value_buy_quantity( date, value.abs(), price, 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, None, 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, None, 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_algo_value( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, symbol: &str, value: f64, style: AlgoOrderStyle, start_time: Option, end_time: Option, 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), })?; let algo_request = AlgoExecutionRequest { style: match style { AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap, AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap, }, start_time, end_time, }; 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.value_buy_quantity( date, value.abs(), price, 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, Some(&algo_request), 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, Some(&algo_request), report, ) } } fn process_algo_percent( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, symbol: &str, percent: f64, style: AlgoOrderStyle, start_time: Option, end_time: Option, 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_algo_value( date, portfolio, data, symbol, total_equity * percent, style, start_time, end_time, 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, algo_request: Option<&AlgoExecutionRequest>, 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, algo_request, 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, algo_request, 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, None, 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 value_buy_gross_limit(&self, value_budget: Option) -> Option { if !self.strict_value_budget { return None; } value_budget.filter(|budget| budget.is_finite() && *budget > 0.0) } 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, algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let Some(snapshot) = data.market(date, symbol) else { let unavailable_reason = self .missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Buy) .map(str::to_string) .unwrap_or_else(|| { format!( "market snapshot is missing for price field {}", price_field_name(self.execution_price_field) ) }); Self::reject_unavailable_order( report, date, order_id, symbol, OrderSide::Buy, requested_qty, reason, unavailable_reason, emit_creation_events, ); return Ok(()); }; let Some(candidate) = data.candidate(date, symbol) else { Self::reject_unavailable_order( report, date, order_id, symbol, OrderSide::Buy, requested_qty, reason, "candidate eligibility is missing", emit_creation_events, ); return Ok(()); }; 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.buy_rule_check_for_order( date, data, symbol, snapshot, candidate, data.instrument(symbol), algo_request, ); 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("buy_disabled") | Some("upper_limit") | Some("open at or above upper limit") => OrderStatus::Canceled, _ => OrderStatus::Rejected, }; report.order_events.push(OrderEvent { date, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), 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, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: None, order_created_date: None, execution_date: None, 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 value_gross_limit = self.value_buy_gross_limit(value_budget); let buy_cash_limit = if self.strict_value_budget { value_budget .filter(|budget| budget.is_finite() && *budget > 0.0) .map(|budget| portfolio.cash().min(budget)) .unwrap_or_else(|| portfolio.cash()) } else { portfolio.cash() }; 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(buy_cash_limit), value_gross_limit, algo_request, 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, Some(constrained_qty)); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); (0, Vec::new()) } else 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 { match self.execution_price_with_limit_slippage_or_rejection( snapshot, OrderSide::Buy, execution_price, limit_price, ) { Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); (0, Vec::new()) } Ok(mut execution_price) => { let mut filled_qty = self.affordable_buy_quantity( date, buy_cash_limit, value_gross_limit, execution_price, constrained_qty, self.minimum_order_quantity(data, symbol), self.order_step_size(data, symbol), ); let mut blocked_by_final_price = false; if filled_qty > 0 { execution_price = self.snapshot_execution_price( snapshot, OrderSide::Buy, Some(filled_qty), ); match self.execution_price_with_limit_slippage_or_rejection( snapshot, OrderSide::Buy, execution_price, limit_price, ) { Ok(price) => execution_price = price, Err(reason) => { partial_fill_reason = merge_partial_fill_reason( partial_fill_reason, Some(reason), ); filled_qty = 0; blocked_by_final_price = true; } } } if blocked_by_final_price { (0, Vec::new()) } else { if filled_qty < constrained_qty { partial_fill_reason = merge_partial_fill_reason( partial_fill_reason, self.buy_reduction_reason( buy_cash_limit, value_gross_limit, execution_price, constrained_qty, filled_qty, ), ); } ( filled_qty, vec![ExecutionLeg { price: execution_price, mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), 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, decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), 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, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: None, order_created_date: None, execution_date: None, 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(detail), 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={:?} 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=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_with_mark_price( date, leg.quantity, leg.price, leg.mark_price, ); if let Some(position) = portfolio.position_mut_if_exists(symbol) { position.record_buy_trade_cost(leg.quantity, cost.total()); } report.fill_events.push(FillEvent { date, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), 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, decision_date: None, order_created_date: None, execution_date: None, 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) .or_else(|| data.price_on_or_before(date, &position.symbol, field)) .or_else(|| { (position.last_price.is_finite() && position.last_price > 0.0) .then_some(position.last_price) }) .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() { "day_open" } 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.price(PriceField::DayOpen) } 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", }); } if let Some(snapshot) = data.market(date, symbol) { return self .rebalance_valuation_price_for_snapshot(snapshot) .ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.to_string(), field: self.rebalance_valuation_price_field_name(), }); } data.price_on_or_before(date, symbol, PriceField::Close) .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 = if valuation_prices.is_some() { self.rebalance_valuation_price_with_overrides( date, &position.symbol, data, valuation_prices, )? } else if let Some(snapshot) = data.market(date, &position.symbol) { self.rebalance_valuation_price_for_snapshot(snapshot) .ok_or_else(|| BacktestError::MissingPrice { date, symbol: position.symbol.clone(), field: self.rebalance_valuation_price_field_name(), })? } else { data.price_on_or_before(date, &position.symbol, PriceField::Close) .or_else(|| { (position.last_price.is_finite() && position.last_price > 0.0) .then_some(position.last_price) }) .ok_or_else(|| BacktestError::MissingPrice { date, symbol: position.symbol.clone(), field: self.rebalance_valuation_price_field_name(), })? }; 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 value_buy_quantity( &self, date: NaiveDate, value_budget: f64, price: f64, minimum_order_quantity: u32, order_step_size: u32, ) -> u32 { if !value_budget.is_finite() || value_budget <= 0.0 || !price.is_finite() || price <= 0.0 { return 0; } let minimum = minimum_order_quantity.max(1); let raw_quantity = (value_budget / price).floor() as u32; let mut quantity = self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size); while quantity >= minimum { if self.estimated_buy_cash_out(date, price, quantity) <= value_budget + 1e-6 { return quantity; } quantity = self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } 0 } 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); } let uses_intraday_quantity = self.matching_type_uses_intraday_quotes(); let available_market_volume = if uses_intraday_quantity { snapshot.minute_volume } else { snapshot.volume }; let no_volume_reason = if uses_intraday_quantity { "minute no volume" } else { "daily no volume" }; let volume_limit_reason = if uses_intraday_quantity { "minute volume limit" } else { "daily volume limit" }; let mut max_fill = requested_qty; if self.inactive_limit && (snapshot.paused || (!uses_intraday_quantity && available_market_volume == 0)) { return Err(if snapshot.paused { "paused".to_string() } else { no_volume_reason.to_string() }); } if uses_intraday_quantity { return Ok(max_fill); } if self.liquidity_limit && uses_intraday_quantity && !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 = ((available_market_volume as f64) * self.volume_percent).floor() as i64 - consumed_turnover as i64; if raw_limit <= 0 { return Err(volume_limit_reason.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(volume_limit_reason.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_limit_rejection_reason( &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, execution_price: f64, ) -> Option<&'static str> { if !execution_price.is_finite() || execution_price <= 0.0 { return None; } match side { OrderSide::Buy if self.risk_config.static_rules.reject_upper_limit_buy && snapshot.is_at_upper_limit_price(execution_price) => { Some("open at or above upper limit") } OrderSide::Sell if self.risk_config.static_rules.reject_lower_limit_sell && snapshot.is_at_lower_limit_price(execution_price) => { Some("open at or below lower limit") } _ => None, } } 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 execution_price_with_limit_slippage_or_rejection( &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, execution_price: f64, limit_price: Option, ) -> Result { let adjusted = self.execution_price_with_limit_slippage(execution_price, limit_price); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, adjusted) { Err(reason) } else { Ok(adjusted) } } 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") || reason.contains("open at or above upper limit") || reason.contains("open at or below lower limit") }) } 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, algo_request: Option<&AlgoExecutionRequest>, limit_price: Option, ) -> Option { let matching_type = self.matching_type_for_algo_request(algo_request); let use_intraday_quotes = algo_request.is_some() || self.execution_price_field == PriceField::Last; if !use_intraday_quotes { return None; } let runtime_start_time = self.runtime_intraday_start_time.get(); let runtime_end_time = self.runtime_intraday_end_time.get(); let start_cursor = algo_request .and_then(|request| request.start_time) .or(runtime_start_time) .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time)); let end_cursor = algo_request .and_then(|request| request.end_time) .or(runtime_end_time) .map(|end_time| date.and_time(end_time)); let quotes = data.execution_quotes_on(date, symbol); if let Some(fill) = self.select_execution_fill( snapshot, quotes, side, matching_type, start_cursor, end_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 algo_request.is_some() || self.intraday_execution_start_time.is_some() { let next_cursor = algo_request .and_then(|request| request.start_time) .or(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: 0, next_cursor, legs: Vec::new(), unfilled_reason: Some(self.empty_intraday_quote_reason( quotes, start_cursor, end_cursor, matching_type == MatchingType::MinuteLast && start_cursor.is_some(), )), }); } None } fn empty_intraday_quote_reason( &self, quotes: &[IntradayExecutionQuote], start_cursor: Option, end_cursor: Option, use_decision_time_quote: bool, ) -> &'static str { if use_decision_time_quote { let saw_quote_at_or_before_start = start_cursor .is_some_and(|cursor| quotes.iter().any(|quote| quote.timestamp <= cursor)); if saw_quote_at_or_before_start { return "intraday quote liquidity exhausted"; } return "no execution quotes at or before start"; } let saw_quote_in_window = quotes.iter().any(|quote| { !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) }); if saw_quote_in_window { "intraday quote liquidity exhausted" } else { "no execution quotes after start" } } fn select_execution_fill( &self, snapshot: &crate::data::DailyMarketSnapshot, quotes: &[IntradayExecutionQuote], side: OrderSide, matching_type: MatchingType, start_cursor: Option, end_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 quote_quantity_limited = self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor); let lot = round_lot.max(1); let exact_time_order_quote = matching_type != MatchingType::MinuteLast && start_cursor.is_some() && end_cursor.is_some() && start_cursor == end_cursor; let use_decision_time_quote = start_cursor.is_some() && (matching_type == MatchingType::MinuteLast || exact_time_order_quote); let eligible_quotes: Vec<&IntradayExecutionQuote> = if use_decision_time_quote { self.latest_known_quote_at_or_before( quotes, start_cursor, snapshot, side, matching_type, quote_quantity_limited, ) .into_iter() .collect() } else { quotes .iter() .filter(|quote| { !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) && (!quote_quantity_limited || self.quote_has_executable_liquidity(quote, side, matching_type)) }) .collect() }; let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; let mut mark_amount = 0.0_f64; let mut last_timestamp = None; let mut legs = Vec::new(); let mut budget_block_reason = None; let mut execution_block_reason = None; let mut execution_block_timestamp = None; let mut saw_non_blocked_execution_price = false; let saw_quote_after_cursor = !eligible_quotes.is_empty(); for (quote_index, quote) in eligible_quotes.iter().enumerate() { // Approximate platform-native 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. let Some(raw_quote_price) = self.select_quote_reference_price(snapshot, quote, side, matching_type) else { continue; }; let mark_price = self.quote_mark_price(quote, raw_quote_price); let remaining_qty = requested_qty.saturating_sub(filled_qty); if remaining_qty == 0 { break; } let missing_level1_depth = Self::quote_lacks_level1_depth(quote); let mut available_qty = if quote_quantity_limited && !missing_level1_depth { let top_level_liquidity = match side { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, }; top_level_liquidity .saturating_mul(lot as u64) .min(u32::MAX as u64) as u32 } else { remaining_qty }; if self.volume_limit { let raw_limit = ((quote.volume_delta as f64) * self.volume_percent).floor() as u32; let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { raw_limit } else { self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size) }; available_qty = available_qty.min(volume_limited); } if available_qty == 0 { continue; } let mut take_qty = if matching_type == MatchingType::Twap { let remaining_quotes = (eligible_quotes.len() - quote_index) as u32; let scheduled_qty = ((remaining_qty as f64) / remaining_quotes.max(1) as f64).ceil() as u32; remaining_qty.min(available_qty).min(scheduled_qty.max(1)) } else { 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; } let mut quote_price = self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) { execution_block_reason.get_or_insert(reason); execution_block_timestamp = Some(quote.timestamp); continue; } saw_non_blocked_execution_price = true; if !self.price_satisfies_limit( side, quote_price, limit_price, snapshot.effective_price_tick(), ) { continue; } if let Some(cash) = cash_limit { while take_qty > 0 { quote_price = self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); if !quote_price.is_finite() || quote_price <= 0.0 { budget_block_reason = Some("invalid execution price"); take_qty = 0; break; } quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) { execution_block_reason.get_or_insert(reason); execution_block_timestamp = Some(quote.timestamp); take_qty = 0; break; } 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; } let candidate_cost = self .cost_model .calculate(snapshot.date, OrderSide::Buy, candidate_gross) .total(); if candidate_gross + candidate_cost <= 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; } } quote_price = self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) { execution_block_reason.get_or_insert(reason); execution_block_timestamp = Some(quote.timestamp); continue; } gross_amount += quote_price * take_qty as f64; mark_amount += mark_price * take_qty as f64; filled_qty += take_qty; last_timestamp = Some(quote.timestamp); legs.push(ExecutionLeg { price: quote_price, mark_price, quantity: take_qty, }); if filled_qty >= requested_qty { break; } } if filled_qty == 0 { if let Some(reason) = execution_block_reason && !saw_non_blocked_execution_price { return Some(ExecutionFill { quantity: 0, next_cursor: execution_block_timestamp .expect("blocked execution quote timestamp") + Duration::seconds(1), legs: Vec::new(), unfilled_reason: Some(reason), }); } return None; } Some(ExecutionFill { quantity: filled_qty, next_cursor: last_timestamp.unwrap() + Duration::seconds(1), legs: if matching_type == MatchingType::Vwap { vec![ExecutionLeg { price: gross_amount / filled_qty as f64, mark_price: mark_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 quote_has_executable_liquidity( &self, quote: &IntradayExecutionQuote, side: OrderSide, matching_type: MatchingType, ) -> bool { if quote.volume_delta != 0 { return true; } if matches!(matching_type, MatchingType::Vwap | MatchingType::Twap) { return false; } match side { OrderSide::Buy => quote.ask1_volume > 0, OrderSide::Sell => quote.bid1_volume > 0, } } fn quote_lacks_level1_depth(quote: &IntradayExecutionQuote) -> bool { quote.volume_delta > 0 && quote.bid1_volume == 0 && quote.ask1_volume == 0 } fn matching_type_uses_intraday_quotes(&self) -> bool { matches!( self.matching_type, MatchingType::MinuteLast | MatchingType::MinuteBestOwn | MatchingType::MinuteBestCounterparty | MatchingType::Vwap | MatchingType::Twap ) } fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool { match matching_type { MatchingType::OpenAuction | MatchingType::CurrentBarClose | MatchingType::NextBarOpen => false, MatchingType::MinuteLast => self.liquidity_limit, MatchingType::MinuteBestOwn | MatchingType::MinuteBestCounterparty | MatchingType::Vwap | MatchingType::Twap => true, } } fn quote_quantity_limited_for_window( &self, matching_type: MatchingType, start_cursor: Option, end_cursor: Option, ) -> bool { if matching_type == MatchingType::Twap && !self.volume_limit && !self.liquidity_limit && start_cursor.is_some() && start_cursor == end_cursor { return false; } self.quote_quantity_limited(matching_type) } 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::MinuteLast, } } fn execution_price_field_from_matching_type(matching_type: MatchingType) -> PriceField { match matching_type { MatchingType::OpenAuction => PriceField::DayOpen, MatchingType::CurrentBarClose => PriceField::Close, MatchingType::NextBarOpen => PriceField::Open, MatchingType::MinuteLast | MatchingType::MinuteBestOwn | MatchingType::MinuteBestCounterparty | MatchingType::Vwap | MatchingType::Twap => PriceField::Last, } } 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 { "minute no volume" | "minute volume limit" | "daily no volume" | "daily volume limit" | "intraday quote liquidity exhausted" | "no execution quotes at or before start" | "no execution quotes after start" | "upper_limit" | "lower_limit" | "open at or above upper limit" | "open at or below lower 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 at or before start") || reason.contains("no execution quotes after start") || reason.contains("upper_limit") || reason.contains("lower_limit") || reason.contains("open at or above upper limit") || reason.contains("open at or below lower limit") => { 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" } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use super::{ BrokerExecutionReport, BrokerSimulator, MatchingType, RebalanceCashMode, SlippageModel, }; use crate::cost::ChinaAShareCostModel; use crate::data::{ BenchmarkSnapshot, CandidateEligibility, DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceField, }; use crate::events::{OrderSide, OrderStatus}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; use crate::strategy::{AlgoOrderStyle, OrderIntent, StrategyDecision}; fn limit_test_snapshot() -> DailyMarketSnapshot { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); DailyMarketSnapshot { date, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-02 09:33:00".to_string()), day_open: 10.0, open: 10.0, high: 10.5, low: 9.5, close: 10.0, last_price: 10.0, bid1: 10.0, ask1: 10.0, prev_close: 10.0, volume: 1_000_000, minute_volume: 10_000, bid1_volume: 1_000, ask1_volume: 1_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, } } fn limit_test_quote(last_price: f64, bid1: f64, ask1: f64) -> IntradayExecutionQuote { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); IntradayExecutionQuote { date, symbol: "000001.SZ".to_string(), timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), last_price, bid1, ask1, bid1_volume: 1_000, ask1_volume: 1_000, volume_delta: 1_000, amount_delta: last_price * 1_000.0, trading_phase: Some("continuous".to_string()), } } fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); CandidateEligibility { date, symbol: "000001.SZ".to_string(), is_st: false, is_star_st: false, is_new_listing: false, is_paused: false, allow_buy, allow_sell, is_kcb: false, is_one_yuan: false, risk_level_code: None, } } fn limit_test_instrument() -> Instrument { Instrument { symbol: "000001.SZ".to_string(), name: "PingAn".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), } } fn limit_test_benchmark() -> BenchmarkSnapshot { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1000.0, prev_close: 1000.0, volume: 1_000_000, } } fn dated_limit_test_snapshot(date: chrono::NaiveDate) -> DailyMarketSnapshot { let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.timestamp = Some(format!("{date} 09:31:00")); snapshot } fn dated_limit_test_candidate( date: chrono::NaiveDate, is_st: bool, is_paused: bool, allow_buy: bool, allow_sell: bool, ) -> CandidateEligibility { let mut candidate = limit_test_candidate(allow_buy, allow_sell); candidate.date = date; candidate.is_st = is_st; candidate.is_paused = is_paused; candidate } fn dated_limit_test_benchmark(date: chrono::NaiveDate) -> BenchmarkSnapshot { let mut benchmark = limit_test_benchmark(); benchmark.date = date; benchmark } fn next_open_buy_decision() -> StrategyDecision { StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: "000001.SZ".to_string(), quantity: 100, reason: "signal_day_buy".to_string(), }], ..StrategyDecision::default() } } fn next_open_sell_decision() -> StrategyDecision { StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: "000001.SZ".to_string(), quantity: -100, reason: "signal_day_sell".to_string(), }], ..StrategyDecision::default() } } #[test] fn minute_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(false) .with_liquidity_limit(false); assert!(!broker.quote_quantity_limited(MatchingType::MinuteLast)); assert!(broker.quote_quantity_limited(MatchingType::MinuteBestCounterparty)); } #[test] fn minute_last_quote_quantity_cap_depends_on_liquidity_limit() { let volume_limited = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(true) .with_liquidity_limit(false); let liquidity_limited = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(false) .with_liquidity_limit(true); assert!(!volume_limited.quote_quantity_limited(MatchingType::MinuteLast)); assert!(liquidity_limited.quote_quantity_limited(MatchingType::MinuteLast)); } #[test] fn minute_last_volume_limit_caps_quote_volume_delta_without_liquidity_limit() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::MinuteLast) .with_volume_limit(true) .with_volume_percent(0.25) .with_liquidity_limit(false); let mut quote = limit_test_quote(10.0, 9.99, 10.01); quote.timestamp = date.and_hms_opt(10, 15, 0).expect("valid timestamp"); quote.volume_delta = 4_900; quote.ask1_volume = 0; quote.bid1_volume = 0; let fill = broker .select_execution_fill( &limit_test_snapshot(), &[quote], OrderSide::Buy, MatchingType::MinuteLast, Some(date.and_hms_opt(10, 15, 0).expect("valid timestamp")), None, 3_200, 100, 100, 100, false, None, None, None, ) .expect("minute last fill"); assert_eq!(fill.quantity, 1_200); } #[test] fn minute_last_uses_volume_delta_when_level1_depth_missing() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::MinuteLast) .with_volume_limit(true) .with_volume_percent(0.25) .with_liquidity_limit(true); let mut quote = limit_test_quote(10.0, 0.0, 0.0); quote.timestamp = date.and_hms_opt(10, 15, 0).expect("valid timestamp"); quote.volume_delta = 600; quote.ask1_volume = 0; quote.bid1_volume = 0; let fill = broker .select_execution_fill( &limit_test_snapshot(), &[quote], OrderSide::Buy, MatchingType::MinuteLast, Some(date.and_hms_opt(10, 15, 0).expect("valid timestamp")), None, 400, 100, 100, 100, false, None, None, None, ) .expect("bar-derived minute quote fill"); assert_eq!(fill.quantity, 100); assert_eq!(fill.legs.len(), 1); assert_eq!(fill.legs[0].price, 10.0); } #[test] fn minute_last_exact_time_does_not_fallback_to_older_liquid_quote() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::MinuteLast) .with_volume_limit(true) .with_volume_percent(0.25) .with_liquidity_limit(true); let mut quote_0931 = limit_test_quote(10.0, 9.99, 10.01); quote_0931.timestamp = date.and_hms_opt(9, 31, 0).expect("valid timestamp"); quote_0931.volume_delta = 10_000; quote_0931.ask1_volume = 10_000; quote_0931.bid1_volume = 10_000; let mut quote_1015 = limit_test_quote(10.2, 10.19, 10.21); quote_1015.timestamp = date.and_hms_opt(10, 15, 0).expect("valid timestamp"); quote_1015.volume_delta = 0; quote_1015.ask1_volume = 10_000; quote_1015.bid1_volume = 10_000; let fill = broker.select_execution_fill( &limit_test_snapshot(), &[quote_0931, quote_1015], OrderSide::Sell, MatchingType::MinuteLast, Some(date.and_hms_opt(10, 15, 0).expect("valid timestamp")), Some(date.and_hms_opt(10, 15, 0).expect("valid timestamp")), 1_000, 100, 100, 100, false, None, None, None, ); assert!(fill.is_none(), "{fill:?}"); } #[test] fn minute_last_volume_limit_rejects_sub_lot_quote_volume() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::MinuteLast) .with_volume_limit(true) .with_volume_percent(0.25) .with_liquidity_limit(false); let mut quote = limit_test_quote(10.0, 9.99, 10.01); quote.timestamp = date.and_hms_opt(10, 15, 0).expect("valid timestamp"); quote.volume_delta = 100; quote.ask1_volume = 10_000; quote.bid1_volume = 10_000; let fill = broker.select_execution_fill( &limit_test_snapshot(), &[quote], OrderSide::Buy, MatchingType::MinuteLast, Some(date.and_hms_opt(10, 15, 0).expect("valid timestamp")), None, 3_200, 100, 100, 100, false, None, None, None, ); assert!(fill.is_none()); } #[test] fn daily_matching_does_not_require_intraday_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(true) .with_liquidity_limit(true); assert!(!broker.quote_quantity_limited(MatchingType::OpenAuction)); assert!(!broker.quote_quantity_limited(MatchingType::CurrentBarClose)); assert!(!broker.quote_quantity_limited(MatchingType::NextBarOpen)); assert!(broker.quote_quantity_limited(MatchingType::MinuteLast)); assert!(broker.quote_quantity_limited(MatchingType::MinuteBestCounterparty)); } #[test] fn next_open_buy_risk_uses_execution_date_not_signal_date() { let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(false) .with_liquidity_limit(false); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![ dated_limit_test_snapshot(signal_date), dated_limit_test_snapshot(execution_date), ], Vec::new(), vec![ dated_limit_test_candidate(signal_date, true, true, false, false), dated_limit_test_candidate(execution_date, false, false, true, true), ], vec![ dated_limit_test_benchmark(signal_date), dated_limit_test_benchmark(execution_date), ], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_buy_decision(), ) .expect("execute next open buy"); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].date, execution_date); assert_eq!(report.fill_events[0].quantity, 100); assert_eq!( portfolio .position("000001.SZ") .map(|position| position.quantity), Some(100) ); } #[test] fn next_open_buy_risk_rejects_execution_date_st_even_if_signal_date_was_clean() { let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(false) .with_liquidity_limit(false); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![ dated_limit_test_snapshot(signal_date), dated_limit_test_snapshot(execution_date), ], Vec::new(), vec![ dated_limit_test_candidate(signal_date, false, false, true, true), dated_limit_test_candidate(execution_date, true, false, true, true), ], vec![ dated_limit_test_benchmark(signal_date), dated_limit_test_benchmark(execution_date), ], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_buy_decision(), ) .expect("execute next open buy"); assert!(report.fill_events.is_empty()); assert!(portfolio.position("000001.SZ").is_none()); let rejected_order = report .order_events .iter() .find(|event| event.side == OrderSide::Buy) .expect("buy order event"); assert_eq!(rejected_order.date, execution_date); assert_eq!(rejected_order.status, OrderStatus::Rejected); assert!( rejected_order.reason.contains("st"), "{}", rejected_order.reason ); } #[test] fn next_open_buy_risk_rejects_execution_date_delisted_even_without_market_snapshot() { let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(false) .with_liquidity_limit(false); let mut instrument = limit_test_instrument(); instrument.delisted_at = Some(execution_date); let data = DataSet::from_components_with_actions_and_quotes( vec![instrument], vec![dated_limit_test_snapshot(signal_date)], Vec::new(), vec![ dated_limit_test_candidate(signal_date, false, false, true, true), dated_limit_test_candidate(execution_date, false, false, true, true), ], vec![ dated_limit_test_benchmark(signal_date), dated_limit_test_benchmark(execution_date), ], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_buy_decision(), ) .expect("execute next open buy"); assert!(report.fill_events.is_empty()); assert!(portfolio.position("000001.SZ").is_none()); let rejected_order = report .order_events .iter() .find(|event| event.side == OrderSide::Buy) .expect("buy order event"); assert_eq!(rejected_order.date, execution_date); assert_eq!(rejected_order.status, OrderStatus::Rejected); assert!( rejected_order.reason.contains("inactive_or_delisted"), "{}", rejected_order.reason ); assert!( !rejected_order.reason.contains("market snapshot is missing"), "{}", rejected_order.reason ); } #[test] fn next_open_buy_limit_risk_uses_open_not_close() { let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false); let mut execution_snapshot = dated_limit_test_snapshot(execution_date); execution_snapshot.open = 10.0; execution_snapshot.day_open = 10.0; execution_snapshot.close = execution_snapshot.upper_limit; execution_snapshot.last_price = execution_snapshot.upper_limit; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![execution_snapshot], Vec::new(), vec![dated_limit_test_candidate( execution_date, false, false, true, true, )], vec![dated_limit_test_benchmark(execution_date)], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_buy_decision(), ) .expect("execute next open buy"); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].price, 10.0); assert_eq!( portfolio .position("000001.SZ") .map(|position| position.quantity), Some(100) ); } #[test] fn next_open_buy_limit_risk_rejects_open_upper_limit_even_when_close_is_clean() { let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false); let mut execution_snapshot = dated_limit_test_snapshot(execution_date); execution_snapshot.open = execution_snapshot.upper_limit; execution_snapshot.day_open = execution_snapshot.upper_limit; execution_snapshot.close = 10.0; execution_snapshot.last_price = 10.0; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![execution_snapshot], Vec::new(), vec![dated_limit_test_candidate( execution_date, false, false, true, true, )], vec![dated_limit_test_benchmark(execution_date)], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_buy_decision(), ) .expect("execute next open buy"); assert!(report.fill_events.is_empty()); assert!(portfolio.position("000001.SZ").is_none()); let rejected_order = report .order_events .iter() .find(|event| event.side == OrderSide::Buy) .expect("buy order event"); assert_eq!(rejected_order.status, OrderStatus::Canceled); assert!( rejected_order .reason .contains("open at or above upper limit"), "{}", rejected_order.reason ); } #[test] fn next_open_sell_risk_uses_execution_date_not_signal_date() { let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(false) .with_liquidity_limit(false); let mut signal_snapshot = dated_limit_test_snapshot(signal_date); signal_snapshot.open = signal_snapshot.lower_limit; signal_snapshot.day_open = signal_snapshot.lower_limit; signal_snapshot.last_price = signal_snapshot.lower_limit; signal_snapshot.close = signal_snapshot.lower_limit; signal_snapshot.paused = true; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![signal_snapshot, dated_limit_test_snapshot(execution_date)], Vec::new(), vec![ dated_limit_test_candidate(signal_date, false, true, false, false), dated_limit_test_candidate(execution_date, false, false, true, true), ], vec![ dated_limit_test_benchmark(signal_date), dated_limit_test_benchmark(execution_date), ], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); portfolio .position_mut("000001.SZ") .buy(signal_date, 100, 10.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_sell_decision(), ) .expect("execute next open sell"); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].date, execution_date); assert_eq!(report.fill_events[0].quantity, 100); assert!(portfolio.position("000001.SZ").is_none()); } #[test] fn next_open_sell_risk_rejects_execution_date_lower_limit_even_if_signal_date_was_clean() { let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(false) .with_liquidity_limit(false); let mut execution_snapshot = dated_limit_test_snapshot(execution_date); execution_snapshot.open = execution_snapshot.lower_limit; execution_snapshot.day_open = execution_snapshot.lower_limit; execution_snapshot.last_price = execution_snapshot.lower_limit; execution_snapshot.close = execution_snapshot.lower_limit; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![dated_limit_test_snapshot(signal_date), execution_snapshot], Vec::new(), vec![ dated_limit_test_candidate(signal_date, false, false, true, true), dated_limit_test_candidate(execution_date, false, false, true, true), ], vec![ dated_limit_test_benchmark(signal_date), dated_limit_test_benchmark(execution_date), ], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); portfolio .position_mut("000001.SZ") .buy(signal_date, 100, 10.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_sell_decision(), ) .expect("execute next open sell"); assert!(report.fill_events.is_empty()); assert_eq!( portfolio .position("000001.SZ") .map(|position| position.quantity), Some(100) ); let rejected_order = report .order_events .iter() .find(|event| event.side == OrderSide::Sell) .expect("sell order event"); assert_eq!(rejected_order.date, execution_date); assert_eq!(rejected_order.status, OrderStatus::Canceled); assert!( rejected_order .reason .contains("open at or below lower limit"), "{}", rejected_order.reason ); } #[test] fn next_open_sell_limit_risk_uses_open_not_close() { let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false); let mut execution_snapshot = dated_limit_test_snapshot(execution_date); execution_snapshot.open = 10.0; execution_snapshot.day_open = 10.0; execution_snapshot.close = execution_snapshot.lower_limit; execution_snapshot.last_price = execution_snapshot.lower_limit; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![execution_snapshot], Vec::new(), vec![dated_limit_test_candidate( execution_date, false, false, true, true, )], vec![dated_limit_test_benchmark(execution_date)], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); portfolio .position_mut("000001.SZ") .buy(signal_date, 100, 10.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_sell_decision(), ) .expect("execute next open sell"); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].price, 10.0); assert!(portfolio.position("000001.SZ").is_none()); } #[test] fn next_open_sell_limit_risk_rejects_open_lower_limit_even_when_close_is_clean() { let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false); let mut execution_snapshot = dated_limit_test_snapshot(execution_date); execution_snapshot.open = execution_snapshot.lower_limit; execution_snapshot.day_open = execution_snapshot.lower_limit; execution_snapshot.close = 10.0; execution_snapshot.last_price = 10.0; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![execution_snapshot], Vec::new(), vec![dated_limit_test_candidate( execution_date, false, false, true, true, )], vec![dated_limit_test_benchmark(execution_date)], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(20_000.0); portfolio .position_mut("000001.SZ") .buy(signal_date, 100, 10.0); let report = broker .execute( execution_date, &mut portfolio, &data, &next_open_sell_decision(), ) .expect("execute next open sell"); assert!(report.fill_events.is_empty()); assert_eq!( portfolio .position("000001.SZ") .map(|position| position.quantity), Some(100) ); let rejected_order = report .order_events .iter() .find(|event| event.side == OrderSide::Sell) .expect("sell order event"); assert_eq!(rejected_order.status, OrderStatus::Canceled); assert!( rejected_order .reason .contains("open at or below lower limit"), "{}", rejected_order.reason ); } #[test] fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() { let mut snapshot = limit_test_snapshot(); snapshot.minute_volume = 0; snapshot.volume = 1_000_000; snapshot.bid1_volume = 0; snapshot.ask1_volume = 0; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::CurrentBarClose) .with_volume_limit(true) .with_liquidity_limit(true); let fillable = broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); assert_eq!(fillable, Ok(5_000)); } #[test] fn volume_limit_uses_floor_for_odd_lot_sell() { let mut snapshot = limit_test_snapshot(); snapshot.minute_volume = 0; snapshot.volume = 3; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::CurrentBarClose) .with_volume_limit(true) .with_volume_percent(0.5) .with_liquidity_limit(false); let fillable = broker.market_fillable_quantity(&snapshot, OrderSide::Sell, 10, 100, 100, 0, true); assert_eq!(fillable, Ok(1)); } #[test] fn current_bar_close_volume_limit_rejects_daily_zero_volume() { let mut snapshot = limit_test_snapshot(); snapshot.minute_volume = 0; snapshot.volume = 0; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::CurrentBarClose) .with_volume_limit(true) .with_liquidity_limit(false); let fillable = broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); assert_eq!(fillable, Err("daily no volume".to_string())); } #[test] fn minute_last_prefilter_does_not_use_snapshot_minute_volume() { let mut snapshot = limit_test_snapshot(); snapshot.minute_volume = 0; snapshot.volume = 1_000_000; snapshot.bid1_volume = 0; snapshot.ask1_volume = 0; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_matching_type(MatchingType::MinuteLast) .with_volume_limit(true) .with_volume_percent(0.25) .with_liquidity_limit(false); let fillable = broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); assert_eq!(fillable, Ok(5_000)); } #[test] fn target_value_valuation_uses_daily_snapshot_but_value_order_sizing_uses_intraday_minute() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time()); let mut snapshot = limit_test_snapshot(); snapshot.last_price = 9.0; snapshot.close = 10.0; let mut quote = limit_test_quote(11.0, 10.99, 11.01); quote.timestamp = date.and_hms_opt(9, 32, 58).expect("valid timestamp"); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot.clone()], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), vec![quote], ) .expect("valid dataset"); let snapshot = data.market(date, "000001.SZ").expect("market snapshot"); assert_eq!( broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot), 10.0 ); assert_eq!( broker.value_sell_sizing_price(date, &data, "000001.SZ", snapshot), 11.0 ); } #[test] fn target_portfolio_smart_ignores_zero_weight_symbols_without_market_snapshot() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_volume_limit(false) .with_liquidity_limit(false); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![limit_test_snapshot()], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(1_000_000.0); let mut target_weights = BTreeMap::new(); target_weights.insert("000001.SZ".to_string(), 0.50); target_weights.insert("001239.SZ".to_string(), 0.0); let mut report = BrokerExecutionReport::default(); broker .process_target_portfolio_smart( date, &mut portfolio, &data, &target_weights, None, None, "date_conditioned_target_weights", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("zero weight missing snapshot symbol ignored"); assert!(portfolio.position("000001.SZ").is_some()); assert!(portfolio.position("001239.SZ").is_none()); assert!( report .fill_events .iter() .all(|event| event.symbol != "001239.SZ") ); } #[test] fn target_portfolio_smart_records_buy_rejection_when_target_is_blacklisted() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let mut risk_config = FidcRiskControlConfig::default(); risk_config .static_rules .blacklisted_symbols .insert("000001.SZ".to_string()); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_volume_limit(false) .with_liquidity_limit(false) .with_risk_config(risk_config); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![limit_test_snapshot()], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(1_000_000.0); let mut target_weights = BTreeMap::new(); target_weights.insert("000001.SZ".to_string(), 0.50); let mut report = BrokerExecutionReport::default(); broker .process_target_portfolio_smart( date, &mut portfolio, &data, &target_weights, None, None, "target_weights_test", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("blacklisted target buy should be rejected, not silently dropped"); assert!(portfolio.position("000001.SZ").is_none()); assert!(report.fill_events.is_empty()); let rejected = report .order_events .iter() .find(|event| event.symbol == "000001.SZ") .expect("blacklisted target should produce rejected order event"); assert_eq!(rejected.side, OrderSide::Buy); assert_eq!(rejected.status, OrderStatus::Rejected); assert_eq!(rejected.filled_quantity, 0); assert!(rejected.requested_quantity > 0); assert!( rejected.reason.contains("blacklisted"), "{}", rejected.reason ); } #[test] fn aiquant_target_portfolio_smart_defers_buy_risk_during_target_sizing() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![limit_test_snapshot()], Vec::new(), vec![limit_test_candidate(false, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let portfolio = PortfolioState::new(1_000_000.0); let mut target_weights = BTreeMap::new(); target_weights.insert("000001.SZ".to_string(), 0.50); let default_broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_volume_limit(false) .with_liquidity_limit(false); let (default_targets, _) = default_broker .target_quantities(date, &portfolio, &data, &target_weights) .expect("default target quantities"); assert_eq!(default_targets.get("000001.SZ").copied().unwrap_or(0), 0); let aiquant_broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_aiquant_execution_rules(true) .with_volume_limit(false) .with_liquidity_limit(false); let (aiquant_targets, _) = aiquant_broker .target_quantities(date, &portfolio, &data, &target_weights) .expect("aiquant target quantities"); assert_eq!(aiquant_targets.get("000001.SZ").copied(), Some(50_000)); } #[test] fn aiquant_target_portfolio_smart_rejects_deferred_buy_risk_at_order_stage() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![limit_test_snapshot()], Vec::new(), vec![limit_test_candidate(false, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_aiquant_execution_rules(true) .with_volume_limit(false) .with_liquidity_limit(false); let mut portfolio = PortfolioState::new(1_000_000.0); let mut target_weights = BTreeMap::new(); target_weights.insert("000001.SZ".to_string(), 0.50); let mut report = BrokerExecutionReport::default(); broker .process_target_portfolio_smart( date, &mut portfolio, &data, &target_weights, None, None, "aiquant_deferred_buy_risk", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("deferred buy risk is handled at order stage"); assert!(portfolio.position("000001.SZ").is_none()); assert!(report.fill_events.is_empty()); let rejected = report .order_events .iter() .find(|event| event.symbol == "000001.SZ") .expect("buy-disabled target should be rejected at order stage"); assert_eq!(rejected.side, OrderSide::Buy); assert_ne!(rejected.status, OrderStatus::Filled); assert_eq!(rejected.filled_quantity, 0); assert!(rejected.reason.contains("buy_disabled"), "{rejected:?}"); } #[test] fn aiquant_target_portfolio_smart_keeps_batch_semantics_after_failed_full_close_sell() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); let symbols = ["000001.SZ", "000002.SZ"]; let instruments = symbols .iter() .map(|symbol| Instrument { symbol: (*symbol).to_string(), name: (*symbol).to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }) .collect::>(); let snapshots = symbols .iter() .map(|symbol| { let mut snapshot = limit_test_snapshot(); snapshot.symbol = (*symbol).to_string(); if *symbol == "000001.SZ" { snapshot.day_open = 9.0; snapshot.open = 9.0; snapshot.low = 9.0; snapshot.close = 9.0; snapshot.last_price = 9.0; snapshot.bid1 = 9.0; snapshot.ask1 = 9.0; snapshot.lower_limit = 9.0; } snapshot }) .collect::>(); let candidates = symbols .iter() .map(|symbol| { let mut candidate = limit_test_candidate(true, true); candidate.symbol = (*symbol).to_string(); candidate }) .collect::>(); let data = DataSet::from_components_with_actions_and_quotes( instruments, snapshots, Vec::new(), candidates, vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_aiquant_execution_rules(true) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut portfolio = PortfolioState::new(100_000.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); portfolio .position_mut("000002.SZ") .buy(prev_date, 1_000, 10.0); 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(); broker .process_target_value( date, &mut portfolio, &data, "000001.SZ", 0.0, "stop_loss_exit", &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, &mut commission_state, &mut report, ) .expect("failed lower-limit full close should be recorded"); assert_eq!( portfolio .position("000001.SZ") .map(|position| position.quantity), Some(1_000) ); assert!(report.order_events.iter().any(|event| { event.symbol == "000001.SZ" && event.side == OrderSide::Sell && event.status == OrderStatus::Canceled && event.filled_quantity == 0 })); let mut target_weights = BTreeMap::new(); target_weights.insert("000001.SZ".to_string(), 0.50); target_weights.insert("000002.SZ".to_string(), 0.50); broker .process_target_portfolio_smart( date, &mut portfolio, &data, &target_weights, None, None, "signal_target_weights", &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, &mut commission_state, &mut report, ) .expect("failed full close must not change batch target-portfolio semantics"); assert!( portfolio .position("000001.SZ") .is_some_and(|position| position.quantity > 1_000), "{:?}", report.fill_events ); assert!( portfolio .position("000002.SZ") .is_some_and(|position| position.quantity > 1_000), "{:?}", report.fill_events ); assert!( report.order_events.iter().all(|event| { !(event.symbol == "000001.SZ" && event.side == OrderSide::Buy && event.reason.contains("same_day_rebuy_forbidden")) }), "{:?}", report.order_events ); assert!( report .diagnostics .iter() .all(|line| { !line.contains("fallback_after_failed_full_close") }) ); } #[test] fn target_portfolio_smart_scales_buys_when_full_targets_exceed_cash_by_fees() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let symbols = ["000001.SZ", "000002.SZ"]; let instruments = symbols .iter() .map(|symbol| Instrument { symbol: (*symbol).to_string(), name: (*symbol).to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }) .collect::>(); let snapshots = symbols .iter() .map(|symbol| { let mut snapshot = limit_test_snapshot(); snapshot.symbol = (*symbol).to_string(); snapshot }) .collect::>(); let candidates = symbols .iter() .map(|symbol| { let mut candidate = limit_test_candidate(true, true); candidate.symbol = (*symbol).to_string(); candidate }) .collect::>(); let data = DataSet::from_components_with_actions_and_quotes( instruments, snapshots, Vec::new(), candidates, vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let portfolio = PortfolioState::new(10_000.0); let mut target_weights = BTreeMap::new(); target_weights.insert("000001.SZ".to_string(), 0.50); target_weights.insert("000002.SZ".to_string(), 0.50); let (target_quantities, diagnostics) = broker .target_quantities(date, &portfolio, &data, &target_weights) .expect("target quantities"); assert_eq!(target_quantities.get("000001.SZ").copied(), Some(400)); assert_eq!(target_quantities.get("000002.SZ").copied(), Some(400)); assert!( diagnostics .iter() .any(|line| line.contains("rebalance_safety_scaled")), "{diagnostics:?}" ); assert!( diagnostics .iter() .any(|line| line.contains("rebalance_buy_reduced") && line.contains("provisional=500") && line.contains("final=400")), "{diagnostics:?}" ); } #[test] fn target_portfolio_smart_pre_open_cash_does_not_spend_same_batch_sell_proceeds() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); let symbols = ["000001.SZ", "000002.SZ"]; let instruments = symbols .iter() .map(|symbol| Instrument { symbol: (*symbol).to_string(), name: (*symbol).to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }) .collect::>(); let snapshots = symbols .iter() .map(|symbol| { let mut snapshot = limit_test_snapshot(); snapshot.symbol = (*symbol).to_string(); snapshot }) .collect::>(); let candidates = symbols .iter() .map(|symbol| { let mut candidate = limit_test_candidate(true, true); candidate.symbol = (*symbol).to_string(); candidate }) .collect::>(); let data = DataSet::from_components_with_actions_and_quotes( instruments, snapshots, Vec::new(), candidates, vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut target_weights = BTreeMap::new(); target_weights.insert("000002.SZ".to_string(), 1.0); let sell_then_buy_broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut sell_then_buy_portfolio = PortfolioState::new(0.0); sell_then_buy_portfolio .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); let mut sell_then_buy_report = BrokerExecutionReport::default(); sell_then_buy_broker .process_target_portfolio_smart( date, &mut sell_then_buy_portfolio, &data, &target_weights, None, None, "rebalance_cash_mode_test", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut sell_then_buy_report, ) .expect("sell_then_buy rebalance"); assert!(sell_then_buy_portfolio.position("000001.SZ").is_none()); assert!( sell_then_buy_portfolio .position("000002.SZ") .is_some_and(|position| position.quantity > 0), "{:?}", sell_then_buy_report.fill_events ); let pre_open_cash_broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_rebalance_cash_mode(RebalanceCashMode::PreOpenCash) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut pre_open_cash_portfolio = PortfolioState::new(0.0); pre_open_cash_portfolio .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); let mut pre_open_cash_report = BrokerExecutionReport::default(); pre_open_cash_broker .process_target_portfolio_smart( date, &mut pre_open_cash_portfolio, &data, &target_weights, None, None, "rebalance_cash_mode_test", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut pre_open_cash_report, ) .expect("pre_open_cash rebalance"); assert!(pre_open_cash_portfolio.position("000001.SZ").is_none()); assert!(pre_open_cash_portfolio.position("000002.SZ").is_none()); assert!( pre_open_cash_report .fill_events .iter() .any(|fill| fill.symbol == "000001.SZ" && fill.side == OrderSide::Sell), "{:?}", pre_open_cash_report.fill_events ); assert!( pre_open_cash_report .fill_events .iter() .all(|fill| !(fill.symbol == "000002.SZ" && fill.side == OrderSide::Buy)), "{:?}", pre_open_cash_report.fill_events ); } #[test] fn target_portfolio_smart_open_auction_uses_day_open_for_valuation() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let mut snapshot = limit_test_snapshot(); snapshot.day_open = 12.0; snapshot.open = 12.0; snapshot.last_price = 12.0; snapshot.prev_close = 10.0; snapshot.upper_limit = 20.0; snapshot.lower_limit = 1.0; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::DayOpen, ) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let portfolio = PortfolioState::new(20_000.0); let mut target_weights = BTreeMap::new(); target_weights.insert("000001.SZ".to_string(), 1.0); let (target_quantities, diagnostics) = broker .target_quantities(date, &portfolio, &data, &target_weights) .expect("target quantities"); assert_eq!( target_quantities.get("000001.SZ").copied(), Some(1_600), "{diagnostics:?}" ); } #[test] fn target_portfolio_smart_custom_valuation_does_not_create_limit_orders() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let mut snapshot = limit_test_snapshot(); snapshot.day_open = 12.0; snapshot.open = 12.0; snapshot.last_price = 12.0; snapshot.prev_close = 10.0; snapshot.upper_limit = 20.0; snapshot.lower_limit = 1.0; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::DayOpen, ) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_000.0); let mut target_weights = BTreeMap::new(); target_weights.insert("000001.SZ".to_string(), 1.0); let mut valuation_prices = BTreeMap::new(); valuation_prices.insert("000001.SZ".to_string(), 12.0); let mut report = BrokerExecutionReport::default(); broker .process_target_portfolio_smart( date, &mut portfolio, &data, &target_weights, None, Some(&valuation_prices), "custom_valuation_market_order_test", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("custom valuation should not turn rebalance into limit order"); assert!( report .order_events .iter() .any(|event| event.symbol == "000001.SZ" && event.status == OrderStatus::Filled), "{:?}", report.order_events ); assert!( report .order_events .iter() .all(|event| !event.reason.contains("limit price not marketable yet")), "{:?}", report.order_events ); assert!( portfolio .position("000001.SZ") .is_some_and(|position| position.quantity == 1_600), "{:?}", portfolio.position("000001.SZ") ); } #[test] fn target_value_zero_rejects_sell_when_market_snapshot_missing() { let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let missing_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let symbol = "000001.SZ"; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_volume_limit(false) .with_liquidity_limit(false); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![limit_test_snapshot()], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(100_000.0); portfolio.position_mut(symbol).buy(trade_date, 1_000, 10.0); let mut report = BrokerExecutionReport::default(); broker .process_target_value( missing_date, &mut portfolio, &data, symbol, 0.0, "target_rebalance_exit", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("missing market snapshot should reject order, not abort backtest"); assert_eq!( portfolio.position(symbol).map(|pos| pos.quantity), Some(1_000) ); assert_eq!(report.fill_events.len(), 0); assert_eq!(report.order_events.len(), 1); let order = &report.order_events[0]; assert_eq!(order.side, OrderSide::Sell); assert_eq!(order.status, OrderStatus::Rejected); assert_eq!(order.requested_quantity, 1_000); assert_eq!(order.filled_quantity, 0); assert!(order.reason.contains("market snapshot is missing")); } #[test] fn target_portfolio_smart_keeps_position_when_sell_snapshot_missing() { let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let missing_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); let symbol = "000001.SZ"; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_volume_limit(false) .with_liquidity_limit(false); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![limit_test_snapshot()], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(100_000.0); portfolio.position_mut(symbol).buy(trade_date, 1_000, 10.0); let mut report = BrokerExecutionReport::default(); broker .process_target_portfolio_smart( missing_date, &mut portfolio, &data, &BTreeMap::new(), None, None, "target_portfolio_rebalance", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("missing market snapshot should keep position, not abort rebalance"); assert_eq!( portfolio.position(symbol).map(|pos| pos.quantity), Some(1_000) ); assert!(report.fill_events.is_empty()); assert!(report.order_events.is_empty()); assert!( report .diagnostics .iter() .any(|item| item.contains("rebalance_target_clipped") && item.contains("000001.SZ")), "{:?}", report.diagnostics ); } #[test] fn timed_target_value_zero_sells_full_position_at_requested_time_quote() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); let symbol = "000001.SZ"; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time()) .with_matching_type(MatchingType::MinuteLast) .with_slippage_model(SlippageModel::PriceRatio(0.001)) .with_volume_limit(false) .with_liquidity_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.symbol = symbol.to_string(); snapshot.last_price = 4.21; snapshot.close = 4.21; snapshot.bid1 = 4.20; snapshot.ask1 = 4.22; snapshot.prev_close = 4.15; snapshot.upper_limit = 4.98; snapshot.lower_limit = 3.32; let mut quote_0931 = limit_test_quote(4.11, 4.10, 4.12); quote_0931.symbol = symbol.to_string(); quote_0931.timestamp = date.and_hms_opt(9, 31, 0).expect("valid timestamp"); let mut quote_1018 = limit_test_quote(4.21, 4.20, 4.22); quote_1018.symbol = symbol.to_string(); quote_1018.timestamp = date.and_hms_opt(10, 18, 0).expect("valid timestamp"); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), vec![quote_0931, quote_1018], ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(1_000_000.0); portfolio.position_mut(symbol).buy(prev_date, 72_600, 4.0); portfolio.apply_cash_delta(-290_400.0); let mut report = BrokerExecutionReport::default(); broker .process_timed_target_value( date, &mut portfolio, &data, symbol, 0.0, AlgoOrderStyle::Twap, Some(date.and_hms_opt(9, 31, 0).unwrap().time()), Some(date.and_hms_opt(9, 31, 0).unwrap().time()), "risk_forced_exit", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("timed target value"); assert!( !report.fill_events.is_empty(), "order_events={:?}", report.order_events ); let fill = report.fill_events.last().expect("fill event"); assert_eq!(fill.quantity, 72_600); assert!((fill.price - 4.10589).abs() < 1e-6, "{fill:?}"); assert_eq!( portfolio .position(symbol) .map(|position| position.quantity) .unwrap_or(0), 0 ); } #[test] fn aiquant_target_value_delta_uses_scheduled_mark_price() { let date = chrono::NaiveDate::from_ymd_opt(2023, 5, 8).expect("valid date"); let symbol = "603101.SH"; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel { commission_rate: 0.0003, stamp_tax_rate_before_change: 0.0005, stamp_tax_rate_after_change: 0.0005, minimum_commission: 5.0, ..ChinaAShareCostModel::default() }, ChinaEquityRuleHooks, PriceField::Last, ) .with_intraday_execution_start_time(date.and_hms_opt(10, 40, 0).unwrap().time()) .with_slippage_model(SlippageModel::PriceRatio(0.002)) .with_strict_value_budget(true) .with_aiquant_execution_rules(true) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let snapshot = DailyMarketSnapshot { date, symbol: symbol.to_string(), timestamp: Some("2023-05-08 15:00:00".to_string()), day_open: 5.86, open: 5.86, high: 5.90, low: 5.76, close: 5.81, last_price: 5.81, bid1: 5.82, ask1: 5.83, prev_close: 5.85, volume: 1_000_000, minute_volume: 262, bid1_volume: 54, ask1_volume: 143, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 6.44, lower_limit: 5.27, price_tick: 0.01, }; let quote = IntradayExecutionQuote { date, symbol: symbol.to_string(), timestamp: date.and_hms_opt(10, 39, 59).expect("valid timestamp"), last_price: 5.83, bid1: 5.82, ask1: 5.83, bid1_volume: 54, ask1_volume: 143, volume_delta: 262, amount_delta: 152_501.0, trading_phase: Some("continuous".to_string()), }; let data = DataSet::from_components_with_actions_and_quotes( vec![Instrument { symbol: symbol.to_string(), name: symbol.to_string(), board: "SH".to_string(), round_lot: 100, listed_at: Some(date), delisted_at: None, status: "active".to_string(), }], vec![snapshot], Vec::new(), vec![CandidateEligibility { date, symbol: symbol.to_string(), is_st: false, is_star_st: false, is_new_listing: false, is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false, risk_level_code: None, }], vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1002.0, prev_close: 998.0, volume: 1_000_000, }], Vec::new(), vec![quote], ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(8_261_416.62); portfolio.position_mut(symbol).buy(date, 21_200, 5.8817); let mut report = BrokerExecutionReport::default(); broker .process_target_value( date, &mut portfolio, &data, symbol, 9_996_284.62 * 0.5 / 40.0, "daily_position_target_adjust", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("process target value"); assert_eq!( portfolio.position(symbol).map(|pos| pos.quantity), Some(21_400) ); if let Some(order) = report.order_events.last() { assert_eq!(order.requested_quantity, 200); assert_eq!(order.filled_quantity, 200); } } #[test] fn minute_last_execution_uses_latest_quote_before_decision_time() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let snapshot = limit_test_snapshot(); let mut quote = limit_test_quote(10.8, 10.79, 10.81); quote.timestamp = date.and_hms_opt(9, 32, 58).expect("valid timestamp"); let quote_timestamp = quote.timestamp; let decision_time = date.and_hms_opt(9, 33, 0).expect("valid timestamp"); let fill = broker .select_execution_fill( &snapshot, &[quote], OrderSide::Sell, MatchingType::MinuteLast, Some(decision_time), Some(decision_time), 200, 100, 100, 100, false, None, None, None, ) .expect("fill from latest known quote before decision time"); assert_eq!(fill.quantity, 200); assert_eq!(fill.legs.len(), 1); assert_eq!(fill.legs[0].price, 10.8); assert_eq!( fill.next_cursor, quote_timestamp + chrono::Duration::seconds(1) ); } #[test] fn value_buy_process_uses_latest_quote_before_decision_time() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time()) .with_slippage_model(SlippageModel::PriceRatio(0.002)) .with_strict_value_budget(true) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.day_open = 8.70; snapshot.open = 8.70; snapshot.high = 8.95; snapshot.low = 8.60; snapshot.last_price = 8.94; snapshot.bid1 = 8.93; snapshot.ask1 = 8.94; snapshot.close = 8.94; snapshot.upper_limit = 9.72; snapshot.lower_limit = 7.96; let mut quote = limit_test_quote(8.69, 8.69, 8.70); quote.timestamp = date.and_hms_opt(9, 32, 55).expect("valid timestamp"); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), vec![quote], ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(10_000_000.0); let mut report = BrokerExecutionReport::default(); broker .process_value( date, &mut portfolio, &data, "000001.SZ", 125_000.0, "periodic_rebalance_buy", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("value buy processed"); let position = portfolio.position("000001.SZ").unwrap_or_else(|| { panic!( "position created from latest known quote; events={:?}", report.order_events ) }); assert_eq!(position.quantity, 14_300); assert_eq!(report.order_events.len(), 1); assert_eq!(report.order_events[0].filled_quantity, 14_300); } #[test] fn value_buy_rejects_when_slippage_clamps_execution_price_to_upper_limit() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_slippage_model(SlippageModel::PriceRatio(0.002)) .with_strict_value_budget(true) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.day_open = 10.98; snapshot.open = 10.98; snapshot.last_price = 10.98; snapshot.close = 10.98; snapshot.bid1 = 10.98; snapshot.ask1 = 10.98; snapshot.upper_limit = 11.0; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(10_000_000.0); let mut report = BrokerExecutionReport::default(); broker .process_value( date, &mut portfolio, &data, "000001.SZ", 125_000.0, "periodic_rebalance_buy", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("value buy processed"); assert!(portfolio.position("000001.SZ").is_none()); assert_eq!(report.order_events.len(), 1); assert_eq!(report.order_events[0].filled_quantity, 0); assert!( report.order_events[0] .reason .contains("open at or above upper limit") ); } #[test] fn strict_value_buy_budget_includes_commission_at_execution_price() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time()) .with_slippage_model(SlippageModel::PriceRatio(0.002)) .with_strict_value_budget(true) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.day_open = 7.14; snapshot.open = 7.14; snapshot.high = 7.20; snapshot.low = 7.10; snapshot.last_price = 7.14; snapshot.bid1 = 7.14; snapshot.ask1 = 7.15; snapshot.close = 7.14; snapshot.upper_limit = 7.85; snapshot.lower_limit = 6.43; let mut quote = limit_test_quote(7.14, 7.14, 7.15); quote.timestamp = date.and_hms_opt(9, 32, 55).expect("valid timestamp"); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], Vec::new(), vec![quote], ) .expect("valid dataset"); let value_budget = 125_216.8131; let mut portfolio = PortfolioState::new(10_000_000.0); let mut report = BrokerExecutionReport::default(); broker .process_value( date, &mut portfolio, &data, "000001.SZ", value_budget, "periodic_rebalance_buy", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("value buy processed"); let fill = report.fill_events.first().expect("fill event"); assert_eq!(fill.quantity, 17_400); assert!(fill.gross_amount + fill.commission <= value_budget + 1e-6); assert!((fill.price - 7.15428).abs() < 1e-6); } #[test] fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_volume_limit(false) .with_liquidity_limit(false); let cursor = chrono::NaiveDate::from_ymd_opt(2025, 11, 3) .unwrap() .and_hms_opt(9, 31, 0) .unwrap(); assert!(!broker.quote_quantity_limited_for_window( MatchingType::Twap, Some(cursor), Some(cursor) )); assert!(broker.quote_quantity_limited_for_window( MatchingType::Twap, Some(cursor), Some(cursor + chrono::Duration::minutes(1)) )); } #[test] fn intraday_execution_rejects_buy_at_upper_limit_price() { let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let snapshot = limit_test_snapshot(); let quote = limit_test_quote(11.0, 10.99, 11.0); let start = quote.timestamp; let fill = broker .select_execution_fill( &snapshot, &[quote], OrderSide::Buy, MatchingType::MinuteLast, Some(start), None, 100, 100, 100, 100, false, None, None, None, ) .expect("zero fill with rejection reason"); assert_eq!(fill.quantity, 0); assert_eq!(fill.unfilled_reason, Some("open at or above upper limit")); } #[test] fn aiquant_rules_keep_structured_buy_risk_while_using_aiquant_limit_price() { let mut snapshot = limit_test_snapshot(); snapshot.open = 11.0; snapshot.day_open = 11.0; snapshot.last_price = 10.98; snapshot.ask1 = 11.0; let candidate = limit_test_candidate(false, true); let date = snapshot.date; let default_broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ); let default_rule = default_broker.buy_rule_check(date, &snapshot, &candidate, None); assert!(!default_rule.allowed); assert_eq!(default_rule.reason.as_deref(), Some("buy_disabled")); let aiquant_broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_aiquant_execution_rules(true); let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &candidate, None); assert!(!aiquant_rule.allowed); assert_eq!(aiquant_rule.reason.as_deref(), Some("buy_disabled")); let tradable_candidate = limit_test_candidate(true, true); let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &tradable_candidate, None); assert!(aiquant_rule.allowed); let lower_limit_buyable_candidate = limit_test_candidate(true, false); let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &lower_limit_buyable_candidate, None); assert!(aiquant_rule.allowed); } #[test] fn intraday_execution_rejects_sell_at_lower_limit_price() { let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let snapshot = limit_test_snapshot(); let quote = limit_test_quote(9.0, 9.0, 9.01); let start = quote.timestamp; let fill = broker .select_execution_fill( &snapshot, &[quote], OrderSide::Sell, MatchingType::MinuteLast, Some(start), None, 100, 100, 100, 100, false, None, None, None, ) .expect("zero fill with rejection reason"); assert_eq!(fill.quantity, 0); assert_eq!(fill.unfilled_reason, Some("open at or below lower limit")); } #[test] fn aiquant_sell_rule_uses_intraday_quote_when_daily_snapshot_is_stale_lower_limit() { let date = chrono::NaiveDate::from_ymd_opt(2024, 4, 17).expect("valid date"); let prev_date = chrono::NaiveDate::from_ymd_opt(2024, 4, 16).expect("valid date"); let symbol = "000001.SZ"; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_aiquant_execution_rules(true) .with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time()) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false) .with_slippage_model(SlippageModel::None); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.timestamp = Some("2024-04-17 10:18:00".to_string()); snapshot.day_open = 9.0; snapshot.open = 9.0; snapshot.high = 10.0; snapshot.low = 9.0; snapshot.close = 9.9; snapshot.last_price = 9.0; snapshot.bid1 = 9.0; snapshot.ask1 = 9.0; snapshot.prev_close = 10.0; snapshot.upper_limit = 11.0; snapshot.lower_limit = 9.0; let mut candidate = limit_test_candidate(true, false); candidate.date = date; let mut quote = limit_test_quote(10.0, 9.99, 10.0); quote.date = date; quote.timestamp = date.and_hms_opt(10, 18, 0).unwrap(); quote.last_price = 10.0; quote.bid1 = 9.99; quote.ask1 = 10.0; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![candidate], vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1000.0, prev_close: 1000.0, volume: 1_000_000, }], Vec::new(), vec![quote], ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(100.0); portfolio.position_mut(symbol).buy(prev_date, 7_200, 8.48); let mut report = BrokerExecutionReport::default(); broker .process_target_value( date, &mut portfolio, &data, symbol, 0.0, "stop_loss_exit", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("target sell processed"); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].quantity, 7_200); assert!(portfolio.position(symbol).is_none()); assert!( report .order_events .iter() .all(|event| !event.reason.contains("open at or below lower limit")), "{:?}", report.order_events ); } #[test] fn aiquant_sell_rule_still_blocks_real_allow_sell_false() { let date = chrono::NaiveDate::from_ymd_opt(2024, 4, 17).expect("valid date"); let prev_date = chrono::NaiveDate::from_ymd_opt(2024, 4, 16).expect("valid date"); let symbol = "000001.SZ"; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_aiquant_execution_rules(true) .with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time()) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false) .with_slippage_model(SlippageModel::None); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.timestamp = Some("2024-04-17 10:18:00".to_string()); snapshot.day_open = 10.0; snapshot.open = 10.0; snapshot.high = 10.2; snapshot.low = 9.8; snapshot.close = 10.0; snapshot.last_price = 10.0; snapshot.bid1 = 10.0; snapshot.ask1 = 10.0; snapshot.prev_close = 10.0; snapshot.upper_limit = 11.0; snapshot.lower_limit = 9.0; let mut candidate = limit_test_candidate(true, false); candidate.date = date; let mut quote = limit_test_quote(10.0, 9.99, 10.0); quote.date = date; quote.timestamp = date.and_hms_opt(10, 18, 0).unwrap(); quote.last_price = 10.0; quote.bid1 = 9.99; quote.ask1 = 10.0; let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![candidate], vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1000.0, prev_close: 1000.0, volume: 1_000_000, }], Vec::new(), vec![quote], ) .expect("valid dataset"); let mut portfolio = PortfolioState::new(100.0); portfolio.position_mut(symbol).buy(prev_date, 7_200, 8.48); let mut report = BrokerExecutionReport::default(); broker .process_target_value( date, &mut portfolio, &data, symbol, 0.0, "stop_loss_exit", &mut BTreeMap::new(), &mut BTreeMap::new(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("target sell processed"); assert!(report.fill_events.is_empty()); assert_eq!( portfolio.position(symbol).map(|position| position.quantity), Some(7_200) ); let rejected_order = report .order_events .iter() .find(|event| event.side == OrderSide::Sell) .expect("sell order event"); assert_eq!(rejected_order.status, OrderStatus::Canceled); assert!( rejected_order.reason.contains("sell_disabled"), "{}", rejected_order.reason ); } }