use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet}; use std::ops::{Deref, DerefMut}; 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::fixed_point::FixedMoney; 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, OrderTimeInForce, 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, } impl BrokerExecutionReport { fn validate(&self) -> Result<(), BacktestError> { for event in &self.order_events { event.validate().map_err(BacktestError::Execution)?; } for event in &self.fill_events { event.validate().map_err(BacktestError::Execution)?; } Ok(()) } } #[derive(Debug, Clone, Copy)] struct ExecutionLeg { price: f64, mark_price: f64, quantity: u32, execution_start_timestamp: Option, execution_timestamp: Option, } #[derive(Debug, Clone)] struct ExecutionFill { quantity: u32, next_cursor: NaiveDateTime, legs: Vec, liquidity_consumption: Vec, unfilled_reason: Option<&'static str>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum QuoteBookSide { Bid, Ask, } impl QuoteBookSide { fn for_order_side(side: OrderSide) -> Self { match side { OrderSide::Buy => Self::Ask, OrderSide::Sell => Self::Bid, } } } #[derive(Debug, Clone, Copy)] struct QuoteDepthConsumption { price_bits: u64, displayed_quantity: u32, consumed_quantity: u32, } #[derive(Debug, Clone)] struct QuoteLiquidityConsumption { symbol: String, timestamp: NaiveDateTime, book_side: QuoteBookSide, depth_price_bits: u64, displayed_quantity: u32, consume_depth: bool, consume_volume: bool, quantity: u32, } #[derive(Debug, Default)] struct IntradayExecutionLedger { cursors: BTreeMap, depth_consumption: BTreeMap; 2]>, volume_consumption: BTreeMap>, } impl IntradayExecutionLedger { fn depth_slot(side: QuoteBookSide) -> usize { match side { QuoteBookSide::Bid => 0, QuoteBookSide::Ask => 1, } } fn depth_consumed( &self, symbol: &str, side: QuoteBookSide, price_bits: u64, displayed_quantity: u32, ) -> u32 { self.depth_consumption .get(symbol) .and_then(|sides| sides[Self::depth_slot(side)]) .filter(|state| { state.price_bits == price_bits && state.displayed_quantity == displayed_quantity }) .map(|state| state.consumed_quantity.min(displayed_quantity)) .unwrap_or(0) } fn volume_consumed(&self, symbol: &str, timestamp: NaiveDateTime) -> u32 { self.volume_consumption .get(symbol) .and_then(|quotes| quotes.get(×tamp)) .copied() .unwrap_or(0) } fn apply_liquidity_consumption(&mut self, consumptions: &[QuoteLiquidityConsumption]) { for consumption in consumptions { if consumption.quantity == 0 { continue; } if consumption.consume_depth { let sides = self .depth_consumption .entry(consumption.symbol.clone()) .or_insert([None, None]); let slot = &mut sides[Self::depth_slot(consumption.book_side)]; match slot { Some(state) if state.price_bits == consumption.depth_price_bits && state.displayed_quantity == consumption.displayed_quantity => { state.consumed_quantity = state .consumed_quantity .saturating_add(consumption.quantity) .min(state.displayed_quantity); } _ => { *slot = Some(QuoteDepthConsumption { price_bits: consumption.depth_price_bits, displayed_quantity: consumption.displayed_quantity, consumed_quantity: consumption .quantity .min(consumption.displayed_quantity), }); } } } if consumption.consume_volume { let consumed = self .volume_consumption .entry(consumption.symbol.clone()) .or_default() .entry(consumption.timestamp) .or_default(); *consumed = consumed.saturating_add(consumption.quantity); } } } } impl Deref for IntradayExecutionLedger { type Target = BTreeMap; fn deref(&self) -> &Self::Target { &self.cursors } } impl DerefMut for IntradayExecutionLedger { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.cursors } } #[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, time_in_force: OrderTimeInForce, commission_remaining: Option, execution_cursor: Option, reason: String, } #[derive(Debug, Default)] struct BrokerExecutionSession { date: Option, intraday_turnover: BTreeMap, execution_cursors: IntradayExecutionLedger, global_execution_cursor: Option, commission_state: BTreeMap, } impl BrokerExecutionSession { fn activate(&mut self, date: NaiveDate) { if self.date != Some(date) { *self = Self { date: Some(date), ..Self::default() }; } } } #[derive(Debug, Clone)] struct TargetConstraint { symbol: String, current_qty: u32, desired_qty: u32, provisional_target_qty: u32, price: f64, buy_execution_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)] enum EquityExecutionPhase { ContinuousAuction, PostCloseFixedPrice, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RebalanceCashMode { SamePointNet, SellThenBuy, PreOpenCash, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RemainderPolicy { Cancel, KeepUntilClose, KeepUntilCanceled, FillOrKill, } 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, 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>, runtime_decision_total_equity: Cell>, runtime_target_position_limit: Cell>, runtime_time_in_force: Cell>, next_order_id: Cell, open_orders: RefCell>, execution_session: 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: true, rebalance_cash_mode: RebalanceCashMode::default(), sell_then_buy_delay_slippage_rate: 0.0, 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), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), execution_session: RefCell::new(BrokerExecutionSession::default()), } } 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: true, rebalance_cash_mode: RebalanceCashMode::default(), sell_then_buy_delay_slippage_rate: 0.0, 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), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), execution_session: RefCell::new(BrokerExecutionSession::default()), } } 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 { assert!( enabled, "strict value budget is mandatory for FIDC order sizing" ); self.strict_value_budget = true; 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_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 } /// Override the price source after selecting the order-matching contract. /// /// A scheduled daily `current_bar_close` order uses the latest completed /// intraday quote at its actual trigger time, while an unscheduled daily /// order uses the official daily close. Keeping this as an explicit final /// builder step prevents `with_matching_type` from silently erasing the /// resolved runner contract. pub fn with_execution_price_field(mut self, execution_price_field: PriceField) -> Self { self.execution_price_field = execution_price_field; 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 } } fn submission_time(&self) -> Option { self.runtime_intraday_start_time .get() .or(self.intraday_execution_start_time) } fn execution_phase_for_submission( &self, date: NaiveDate, order_created_date: Option, submission_time: Option, ) -> EquityExecutionPhase { let effective_date = NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid effective date"); let window_start = NaiveTime::from_hms_opt(15, 0, 0).expect("valid window start"); let window_end = NaiveTime::from_hms_opt(15, 30, 0).expect("valid window end"); if date >= effective_date && order_created_date == Some(date) && !matches!( self.matching_type, MatchingType::OpenAuction | MatchingType::NextBarOpen ) && submission_time.is_some_and(|time| time >= window_start && time <= window_end) { EquityExecutionPhase::PostCloseFixedPrice } else { EquityExecutionPhase::ContinuousAuction } } fn execution_phase(&self, date: NaiveDate) -> EquityExecutionPhase { self.execution_phase_for_submission( date, self.runtime_order_created_date.get(), self.submission_time(), ) } fn is_post_close_fixed_price(&self, date: NaiveDate) -> bool { self.execution_phase(date) == EquityExecutionPhase::PostCloseFixedPrice } fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField { if self.is_post_close_fixed_price(date) { PriceField::Close } else { self.execution_price_field } } fn post_close_execution_window( &self, date: NaiveDate, ) -> Option<(NaiveDateTime, NaiveDateTime)> { self.post_close_execution_quote_window_for_submission( date, self.runtime_order_created_date.get(), self.submission_time(), ) .map(|(start, end)| (date.and_time(start), date.and_time(end))) } fn post_close_execution_quote_window_for_submission( &self, date: NaiveDate, order_created_date: Option, submission_time: Option, ) -> Option<(NaiveTime, NaiveTime)> { if self.execution_phase_for_submission(date, order_created_date, submission_time) != EquityExecutionPhase::PostCloseFixedPrice { return None; } let matching_start = NaiveTime::from_hms_opt(15, 5, 0).expect("valid matching start"); let matching_end = NaiveTime::from_hms_opt(15, 30, 0).expect("valid matching end"); let submitted_at = submission_time?; Some((submitted_at.max(matching_start), matching_end)) } pub(crate) fn post_close_execution_quote_window_for_order( &self, execution_date: NaiveDate, order_created_date: NaiveDate, submission_time: Option, ) -> Option<(NaiveTime, NaiveTime)> { self.post_close_execution_quote_window_for_submission( execution_date, Some(order_created_date), submission_time, ) } fn effective_remainder_policy( &self, date: NaiveDate, allow_pending_limit: bool, ) -> RemainderPolicy { if self.is_post_close_fixed_price(date) { return match self.runtime_time_in_force.get() { Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill, _ => RemainderPolicy::Cancel, }; } match self.runtime_time_in_force.get() { Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill, Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled, Some(OrderTimeInForce::Day) if allow_pending_limit => RemainderPolicy::KeepUntilClose, Some(OrderTimeInForce::Day) => RemainderPolicy::Cancel, Some(OrderTimeInForce::Ioc) => RemainderPolicy::Cancel, None if allow_pending_limit => RemainderPolicy::KeepUntilClose, None => RemainderPolicy::Cancel, } } fn pending_time_in_force(remainder_policy: RemainderPolicy) -> OrderTimeInForce { match remainder_policy { RemainderPolicy::KeepUntilClose => OrderTimeInForce::Day, RemainderPolicy::KeepUntilCanceled => OrderTimeInForce::Gtc, RemainderPolicy::Cancel | RemainderPolicy::FillOrKill => { unreachable!("non-pending policy cannot create an open order") } } } fn keeps_remainder_open(remainder_policy: RemainderPolicy) -> bool { matches!( remainder_policy, RemainderPolicy::KeepUntilClose | RemainderPolicy::KeepUntilCanceled ) } 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() } pub fn has_open_orders(&self) -> bool { !self.open_orders.borrow().is_empty() } } impl BrokerSimulator where C: CostModel, R: EquityRuleHooks, { fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { snapshot.buy_price(self.effective_execution_price_field(snapshot.date)) } fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { snapshot.sell_price(self.effective_execution_price_field(snapshot.date)) } fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { snapshot.price(self.effective_execution_price_field(snapshot.date)) } 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.is_post_close_fixed_price(date) { return snapshot.close; } if self.matching_type == MatchingType::NextBarOpen { let execution_price = snapshot.price(PriceField::Open); if execution_price.is_finite() && execution_price > 0.0 { return execution_price; } } if 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 cash_rebalance_intent_side( &self, date: NaiveDate, portfolio: &PortfolioState, data: &DataSet, intent: &OrderIntent, ) -> Option { let target_value_side = |symbol: &str, target_value: f64| { let current_qty = portfolio .position(symbol) .map(|position| position.quantity) .unwrap_or(0); let current_value = data .market(date, symbol) .map(|snapshot| { self.target_value_valuation_price(date, data, symbol, snapshot) * current_qty as f64 }) .unwrap_or(0.0); if target_value.max(0.0) + f64::EPSILON < current_value { OrderSide::Sell } else { OrderSide::Buy } }; match intent.unwrapped() { OrderIntent::Shares { quantity, .. } | OrderIntent::LimitShares { quantity, .. } => { Some(if *quantity < 0 { OrderSide::Sell } else { OrderSide::Buy }) } OrderIntent::Lots { lots, .. } | OrderIntent::LimitLots { lots, .. } => { Some(if *lots < 0 { OrderSide::Sell } else { OrderSide::Buy }) } OrderIntent::TargetShares { symbol, target_quantity, .. } | OrderIntent::LimitTargetShares { symbol, target_quantity, .. } => { let current_qty = portfolio .position(symbol) .map(|position| position.quantity as i64) .unwrap_or(0); Some(if i64::from(*target_quantity).max(0) < current_qty { OrderSide::Sell } else { OrderSide::Buy }) } OrderIntent::TargetValue { symbol, target_value, .. } | OrderIntent::TimedTargetValue { symbol, target_value, .. } | OrderIntent::LimitTargetValue { symbol, target_value, .. } => Some(target_value_side(symbol, *target_value)), OrderIntent::Value { value, .. } | OrderIntent::LimitValue { value, .. } => { Some(if *value < 0.0 { OrderSide::Sell } else { OrderSide::Buy }) } OrderIntent::Percent { percent, .. } | OrderIntent::LimitPercent { percent, .. } => { Some(if *percent < 0.0 { OrderSide::Sell } else { OrderSide::Buy }) } OrderIntent::TargetPercent { symbol, target_percent, .. } | OrderIntent::LimitTargetPercent { symbol, target_percent, .. } => { let total_equity = self .runtime_decision_total_equity .get() .filter(|value| value.is_finite() && *value >= 0.0) .unwrap_or_else(|| portfolio.total_equity()); Some(target_value_side( symbol, total_equity * target_percent.max(0.0), )) } _ => None, } } fn target_position_intent(intent: &OrderIntent) -> Option<(&str, bool)> { match intent.unwrapped() { OrderIntent::TargetShares { symbol, target_quantity, .. } | OrderIntent::LimitTargetShares { symbol, target_quantity, .. } => Some((symbol, *target_quantity > 0)), OrderIntent::TargetValue { symbol, target_value, .. } | OrderIntent::TimedTargetValue { symbol, target_value, .. } | OrderIntent::LimitTargetValue { symbol, target_value, .. } => Some((symbol, target_value.is_finite() && *target_value > 0.0)), OrderIntent::TargetPercent { symbol, target_percent, .. } | OrderIntent::LimitTargetPercent { symbol, target_percent, .. } => Some((symbol, target_percent.is_finite() && *target_percent > 0.0)), _ => None, } } fn infer_target_position_limit( &self, portfolio: &PortfolioState, intents: &[&OrderIntent], ) -> Option { if intents.is_empty() || intents .iter() .any(|intent| Self::target_position_intent(intent).is_none()) { return None; } let held_symbols = portfolio .positions() .iter() .filter(|(_, position)| position.quantity > 0) .map(|(symbol, _)| symbol.clone()) .collect::>(); let mut exit_symbols = BTreeSet::new(); let mut entry_symbols = BTreeSet::new(); for intent in intents { let Some((symbol, has_positive_target)) = Self::target_position_intent(intent) else { continue; }; if held_symbols.contains(symbol) && !has_positive_target { exit_symbols.insert(symbol.to_string()); } else if !held_symbols.contains(symbol) && has_positive_target { entry_symbols.insert(symbol.to_string()); } } for symbol in exit_symbols.clone() { if entry_symbols.contains(&symbol) { exit_symbols.remove(&symbol); entry_symbols.remove(&symbol); } } if exit_symbols.is_empty() || entry_symbols.is_empty() { return None; } Some( held_symbols .len() .saturating_sub(exit_symbols.len()) .saturating_add(entry_symbols.len()), ) } fn positive_position_count(portfolio: &PortfolioState) -> usize { portfolio .positions() .values() .filter(|position| position.quantity > 0) .count() } fn value_order_sizing_price( &self, date: NaiveDate, data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { if self.is_post_close_fixed_price(date) { return snapshot.close; } 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.is_post_close_fixed_price(snapshot.date) { return snapshot.close; } 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.effective_execution_price_field(snapshot.date)); 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); } if self.is_post_close_fixed_price(snapshot.date) { 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 { if self.is_post_close_fixed_price(snapshot.date) { return (snapshot.close.is_finite() && snapshot.close > 0.0).then_some(snapshot.close); } 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 { self.execute_with_event_dates_and_decision_equity( date, decision_date, order_created_date, None, portfolio, data, decision, ) } pub fn execute_with_event_dates_and_decision_equity( &self, date: NaiveDate, decision_date: NaiveDate, order_created_date: NaiveDate, decision_total_equity: Option, 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(); let previous_decision_total_equity = self.runtime_decision_total_equity.get(); self.runtime_decision_date.set(Some(decision_date)); self.runtime_order_created_date .set(Some(order_created_date)); self.runtime_decision_total_equity .set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0)); 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); self.runtime_decision_total_equity .set(previous_decision_total_equity); result } fn execute_with_runtime_dates( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, ) -> Result { let mut session = std::mem::take(&mut *self.execution_session.borrow_mut()); session.activate(date); let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session); *self.execution_session.borrow_mut() = session; result } fn execute_with_daily_session( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, session: &mut BrokerExecutionSession, ) -> Result { let mut report = BrokerExecutionReport::default(); self.process_open_orders( date, portfolio, data, &mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor, &mut session.commission_state, &mut report, )?; if !decision.order_intents.is_empty() { let mut ordered_intents = decision.order_intents.iter().collect::>(); if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash && ordered_intents.iter().all(|intent| { self.cash_rebalance_intent_side(date, portfolio, data, intent) .is_some() }) { ordered_intents.sort_by_key(|intent| { if self.cash_rebalance_intent_side(date, portfolio, data, intent) == Some(OrderSide::Sell) { 0 } else { 1 } }); } let previous_target_position_limit = self .runtime_target_position_limit .replace(self.infer_target_position_limit(portfolio, &ordered_intents)); for intent in ordered_intents { let result = self.process_order_intent( date, portfolio, data, intent, &mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor, &mut session.commission_state, &mut report, ); if let Err(error) = result { self.runtime_target_position_limit .set(previous_target_position_limit); return Err(error); } } self.runtime_target_position_limit .set(previous_target_position_limit); portfolio.prune_flat_positions(); report.validate()?; 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; let fill_start = report.fill_events.len(); let order_start = report.order_events.len(); self.process_sell( date, portfolio, data, &symbol, requested_qty, self.reserve_order_id(), sell_reason(decision, &symbol), &mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor, &mut session.commission_state, None, false, true, None, &mut report, )?; let filled_quantity = report.fill_events[fill_start..] .iter() .filter(|fill| fill.symbol == symbol && fill.side == OrderSide::Sell) .map(|fill| fill.quantity) .sum::(); if filled_quantity < requested_qty && report.diagnostics.len() < 32 { let denial_reason = report.order_events[order_start..] .iter() .rev() .find(|event| event.symbol == symbol && event.side == OrderSide::Sell) .map(|event| event.reason.as_str()) .unwrap_or("sell_not_fully_filled"); report.diagnostics.push(format!( "rebalance_target_denied symbol={} side=sell requested={} filled={} reason={}", symbol, requested_qty, filled_quantity, denial_reason )); } } } 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; if !self.can_afford_minimum_buy(date, portfolio, data, &symbol) { if report.diagnostics.len() < 32 { report.diagnostics.push(format!( "rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells", symbol, target_qty, current_qty, current_qty )); } continue; } self.process_buy( date, portfolio, data, &symbol, requested_qty, self.reserve_order_id(), "rebalance_buy", &mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor, &mut session.commission_state, None, None, false, true, None, &mut report, )?; } } } portfolio.prune_flat_positions(); report.validate()?; 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 { self.execute_between_with_event_dates_and_decision_equity( date, decision_date, order_created_date, None, portfolio, data, decision, start_time, end_time, ) } #[allow(clippy::too_many_arguments)] pub fn execute_between_with_event_dates_and_decision_equity( &self, date: NaiveDate, decision_date: NaiveDate, order_created_date: NaiveDate, decision_total_equity: Option, 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_and_decision_equity( date, decision_date, order_created_date, decision_total_equity, 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 IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { if let OrderIntent::WithTimeInForce { intent: wrapped, time_in_force, } = intent { if self.runtime_time_in_force.get().is_some() { return Err(BacktestError::Execution( "nested time-in-force wrappers are not allowed".to_string(), )); } if !wrapped.supports_time_in_force(*time_in_force) { return Err(BacktestError::Execution(format!( "time_in_force={} is not supported for this order intent", time_in_force.as_str() ))); } let previous = self.runtime_time_in_force.replace(Some(*time_in_force)); let result = self.process_order_intent( date, portfolio, data, wrapped, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, report, ); self.runtime_time_in_force.set(previous); return result; } match intent { OrderIntent::WithTimeInForce { .. } => unreachable!("wrapper handled above"), 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::ModifyOrder { order_id, new_total_quantity, new_limit_price, reason, } => { self.modify_open_order( date, portfolio, data, *order_id, *new_total_quantity, *new_limit_price, 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 IntradayExecutionLedger, 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 IntradayExecutionLedger, 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 IntradayExecutionLedger, 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(); if let Some(existing) = open_orders .iter_mut() .find(|existing| existing.order_id == open_order.order_id) { *existing = open_order; } else { 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 emit_fill_or_kill_canceled( report: &mut BrokerExecutionReport, date: NaiveDate, order_id: u64, symbol: &str, side: OrderSide, requested_quantity: u32, possible_quantity: u32, reason: &str, ) { let detail = format!( "{reason}: FOK not fully fillable requested={requested_quantity} possible={possible_quantity}" ); 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::Canceled, reason: detail.clone(), }); Self::emit_order_process_event( report, date, ProcessEventKind::OrderUnsolicitedUpdate, order_id, symbol, side, format!("status=Canceled reason={detail}"), ); report.diagnostics.push(format!( "fok_order_canceled symbol={symbol} side={} requested={requested_quantity} possible={possible_quantity}", side.as_str() )); } 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 reserved_open_buy_quantity(&self, symbol: &str, exclude_order_id: Option) -> u32 { self.open_orders .borrow() .iter() .filter(|order| { order.side == OrderSide::Buy && 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 IntradayExecutionLedger, 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(); if let Some(commission_remaining) = order.commission_remaining { commission_state.insert(order.order_id, commission_remaining); } if let Some(cursor) = order.execution_cursor { execution_cursors .entry(order.symbol.clone()) .and_modify(|existing| *existing = (*existing).max(cursor)) .or_insert(cursor); if self.uses_serial_execution_cursor(&order.reason) && global_execution_cursor.is_none_or(|existing| cursor > existing) { *global_execution_cursor = Some(cursor); } } let signed_quantity = if order.side == OrderSide::Buy { order.remaining_quantity as i32 } else { -(order.remaining_quantity as i32) }; let previous_time_in_force = self .runtime_time_in_force .replace(Some(order.time_in_force)); let execution_result = 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.runtime_time_in_force.set(previous_time_in_force); execution_result?; let attempt_filled = report.fill_events[fill_event_start..] .iter() .filter(|fill| fill.order_id == Some(order.order_id)) .map(|fill| fill.quantity) .sum::(); let cumulative_filled = order.filled_quantity.saturating_add(attempt_filled); let remaining_quantity = order.requested_quantity.saturating_sub(cumulative_filled); let mut remains_open = false; { let mut open_orders = self.open_orders.borrow_mut(); if let Some(reopened) = open_orders .iter_mut() .find(|reopened| reopened.order_id == order.order_id) { remains_open = remaining_quantity > 0; reopened.decision_date = order.decision_date; reopened.order_created_date = order.order_created_date; reopened.requested_quantity = order.requested_quantity; reopened.filled_quantity = cumulative_filled; reopened.remaining_quantity = remaining_quantity; reopened.time_in_force = order.time_in_force; reopened.commission_remaining = commission_state.get(&order.order_id).copied(); reopened.execution_cursor = execution_cursors.get(&order.symbol).copied(); } if !remains_open { open_orders.retain(|open| open.order_id != order.order_id); } } if report.order_events.len() == order_event_start && !remains_open { 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: cumulative_filled, status: OrderStatus::Canceled, reason: format!( "{}: open order remainder canceled because no executable position remained", order.reason ), }); Self::emit_order_process_event( report, date, ProcessEventKind::OrderUnsolicitedUpdate, order.order_id, &order.symbol, order.side, "status=Canceled reason=no executable position remained", ); } for event in &mut report.order_events[order_event_start..] { if event.order_id != Some(order.order_id) { continue; } event.requested_quantity = order.requested_quantity; event.filled_quantity = cumulative_filled; if remains_open { event.status = if cumulative_filled == 0 { OrderStatus::Pending } else { OrderStatus::PartiallyFilled }; } else if cumulative_filled > 0 && event.status == OrderStatus::Rejected { event.status = OrderStatus::Canceled; } } 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"), }); } } #[allow(clippy::too_many_arguments)] fn modify_open_order( &self, date: NaiveDate, portfolio: &PortfolioState, data: &DataSet, order_id: u64, new_total_quantity: Option, new_limit_price: Option, reason: &str, report: &mut BrokerExecutionReport, ) { let Some(existing) = self .open_orders .borrow() .iter() .find(|order| order.order_id == order_id) .cloned() else { Self::emit_open_order_update_rejected( report, date, order_id, None, None, reason, "not_found", ); return; }; Self::emit_order_process_event( report, date, ProcessEventKind::OrderPendingUpdate, order_id, &existing.symbol, existing.side, format!("reason={reason}"), ); let target_total_quantity = new_total_quantity.unwrap_or(existing.requested_quantity); let target_limit_price = new_limit_price.unwrap_or(existing.limit_price); if target_total_quantity == existing.requested_quantity && target_limit_price.to_bits() == existing.limit_price.to_bits() { Self::emit_open_order_update_rejected( report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, "no_fields_changed", ); return; } if target_total_quantity <= existing.filled_quantity { Self::emit_open_order_update_rejected( report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, &format!( "new_total_quantity_must_exceed_filled_quantity new_total={} filled={}", target_total_quantity, existing.filled_quantity ), ); return; } if !target_limit_price.is_finite() || target_limit_price <= 0.0 { Self::emit_open_order_update_rejected( report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, "limit_price_must_be_positive", ); return; } let Some(snapshot) = data.market(date, &existing.symbol) else { Self::emit_open_order_update_rejected( report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, "market_snapshot_missing_for_update_validation", ); return; }; let price_tick = snapshot.effective_price_tick().max(1e-9); let tick_aligned_price = (target_limit_price / price_tick).round() * price_tick; if (target_limit_price - tick_aligned_price).abs() > price_tick * 1e-6 { Self::emit_open_order_update_rejected( report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, &format!( "limit_price_not_tick_aligned price={} tick={}", target_limit_price, price_tick ), ); return; } if (snapshot.lower_limit.is_finite() && snapshot.lower_limit > 0.0 && target_limit_price + price_tick * 1e-6 < snapshot.lower_limit) || (snapshot.upper_limit.is_finite() && snapshot.upper_limit > 0.0 && target_limit_price > snapshot.upper_limit + price_tick * 1e-6) { Self::emit_open_order_update_rejected( report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, &format!( "limit_price_outside_daily_range price={} lower={} upper={}", target_limit_price, snapshot.lower_limit, snapshot.upper_limit ), ); return; } let target_remaining_quantity = target_total_quantity.saturating_sub(existing.filled_quantity); if existing.side == OrderSide::Buy { let minimum_order_quantity = self.minimum_order_quantity(data, &existing.symbol); let order_step_size = self.order_step_size(data, &existing.symbol); if self.round_buy_quantity( target_remaining_quantity, minimum_order_quantity, order_step_size, ) != target_remaining_quantity { Self::emit_open_order_update_rejected( report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, &format!( "remaining_quantity_not_lot_aligned remaining={} minimum={} step={}", target_remaining_quantity, minimum_order_quantity, order_step_size ), ); return; } } else { let position_quantity = portfolio .position(&existing.symbol) .map(|position| position.quantity) .unwrap_or(0); let reserved_by_other_orders = self.reserved_open_sell_quantity(&existing.symbol, Some(order_id)); let available_quantity = position_quantity.saturating_sub(reserved_by_other_orders); if target_remaining_quantity > available_quantity { Self::emit_open_order_update_rejected( report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, &format!( "sell_quantity_exceeds_available remaining={} available={} other_reserved={}", target_remaining_quantity, available_quantity, reserved_by_other_orders ), ); return; } } let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits() || target_total_quantity > existing.requested_quantity; { let mut open_orders = self.open_orders.borrow_mut(); let index = open_orders .iter() .position(|order| order.order_id == order_id) .expect("open order disappeared during synchronous update"); let order = &mut open_orders[index]; order.requested_quantity = target_total_quantity; order.remaining_quantity = target_remaining_quantity; order.limit_price = target_limit_price; if resets_queue_priority { let amended = open_orders.remove(index); open_orders.push(amended); } } report.order_events.push(OrderEvent { date, decision_date: existing.decision_date, order_created_date: existing.order_created_date, execution_date: None, order_id: Some(order_id), symbol: existing.symbol.clone(), side: existing.side, requested_quantity: target_total_quantity, filled_quantity: existing.filled_quantity, status: if existing.filled_quantity == 0 { OrderStatus::Pending } else { OrderStatus::PartiallyFilled }, reason: format!( "{reason}: order updated old_total={} new_total={} old_limit={} new_limit={} queue_priority_reset={}", existing.requested_quantity, target_total_quantity, existing.limit_price, target_limit_price, resets_queue_priority ), }); Self::emit_order_process_event( report, date, ProcessEventKind::OrderUpdatePass, order_id, &existing.symbol, existing.side, format!( "old_total={} new_total={} filled={} remaining={} old_limit={} new_limit={} queue_priority_reset={}", existing.requested_quantity, target_total_quantity, existing.filled_quantity, target_remaining_quantity, existing.limit_price, target_limit_price, resets_queue_priority ), ); } #[allow(clippy::too_many_arguments)] fn emit_open_order_update_rejected( report: &mut BrokerExecutionReport, date: NaiveDate, order_id: u64, symbol: Option<&str>, side: Option, reason: &str, detail: &str, ) { report.process_events.push(ProcessEvent { date, kind: ProcessEventKind::OrderUpdateReject, order_id: Some(order_id), symbol: symbol.map(ToString::to_string), side, detail: format!("reason={reason} status={detail}"), }); } 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 { if order.time_in_force == OrderTimeInForce::Gtc { self.upsert_open_order(order); continue; } let market_close_reason = format!( "DAY order expired at market close: {} remaining_quantity={}", order.symbol, order.remaining_quantity ); 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::Expired, reason: market_close_reason.clone(), }); Self::emit_order_process_event( &mut report, date, ProcessEventKind::OrderUnsolicitedUpdate, order.order_id, &order.symbol, order.side, format!( "status=Expired 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 = if valuation_prices.is_none() { self.target_total_equity_at(date, portfolio, data)? } else { self.runtime_decision_total_equity .get() .filter(|equity| equity.is_finite() && *equity >= 0.0) .map(Ok) .unwrap_or_else(|| { 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 current_qty = portfolio .position(symbol) .map(|position| position.quantity) .unwrap_or(0); let target_value = (equity * weight).max(0.0); let current_value = price * current_qty as f64; let minimum_order_quantity = self.minimum_order_quantity(data, symbol); let order_step_size = self.order_step_size(data, symbol); let desired_qty = if target_value > current_value + f64::EPSILON { let buy_budget = target_value - current_value; current_qty.saturating_add(self.target_buy_quantity_for_budget( date, data, symbol, buy_budget, price, minimum_order_quantity, order_step_size, )) } else { self.round_buy_quantity( (target_value / price).floor() as u32, minimum_order_quantity, order_step_size, ) }; desired_targets.insert(symbol.clone(), desired_qty); } 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); let buy_quantity = provisional_target_qty.saturating_sub(current_qty); let sell_quantity = current_qty.saturating_sub(provisional_target_qty); let buy_execution_price = data .market(date, &symbol) .map(|snapshot| { self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(buy_quantity)) }) .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) .unwrap_or(price); let sell_execution_price = data .market(date, &symbol) .map(|snapshot| { self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(sell_quantity)) }) .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) .unwrap_or(price); 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, sell_execution_price, current_qty.saturating_sub(provisional_target_qty), ); } constraints.push(TargetConstraint { symbol: symbol.clone(), current_qty, desired_qty, provisional_target_qty, price, buy_execution_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.buy_execution_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 Self::fixed_cash_fits(buy_cash_out, projected_cash) { 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 IntradayExecutionLedger, 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, )?; } let filled_quantity = local_report .fill_events .iter() .filter(|fill| fill.symbol == *symbol && fill.side == OrderSide::Sell) .map(|fill| fill.quantity) .sum::(); if filled_quantity < sell_qty && local_report.diagnostics.len() < 32 { let denial_reason = local_report .order_events .iter() .rev() .find(|event| event.symbol == *symbol && event.side == OrderSide::Sell) .map(|event| event.reason.as_str()) .unwrap_or("sell_not_fully_filled"); local_report.diagnostics.push(format!( "rebalance_target_denied symbol={} side=sell requested={} filled={} reason={}", symbol, sell_qty, filled_quantity, denial_reason )); } 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; if !self.can_afford_minimum_buy(date, portfolio, data, symbol) { if report.diagnostics.len() < 32 { report.diagnostics.push(format!( "rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells", symbol, target_qty, current_qty, current_qty )); } continue; } 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 = if valuation_prices.is_none() { self.target_total_equity_at(date, portfolio, data)? } else { self.runtime_decision_total_equity .get() .filter(|equity| equity.is_finite() && *equity >= 0.0) .map(Ok) .unwrap_or_else(|| { 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 execution_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 execution_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.execution_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 = self.execution_limit_check_price(snapshot, OrderSide::Buy); 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.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 = self.execution_order_limit_check_price( date, data, symbol, snapshot, OrderSide::Buy, algo_request, ); 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.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 { let check_price = self.execution_order_limit_check_price( date, data, symbol, snapshot, OrderSide::Sell, algo_request, ); let adjusted_candidate; let candidate_for_check = if self.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.rules.duplicates_standard_china_risk() { return self.rules.can_sell( date, snapshot, candidate, position, self.execution_price_field, ); } RuleCheck::allow() } fn 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; }; let sellable = position .sellable_qty(date) .saturating_sub(self.reserved_open_sell_quantity(symbol, None)); current_qty.saturating_sub(sellable.min(current_qty)) } 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 { u32::MAX } fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 { if quantity == 0 { return 0.0; } let gross = Self::fixed_gross_amount(price, quantity); let cost = self .cost_model .calculate(date, OrderSide::Sell, gross.to_f64()); gross .checked_sub(cost.fixed_total()) .expect("fixed-point sell proceeds underflow") .to_f64() } 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 = Self::fixed_gross_amount(price, quantity); let cost = self .cost_model .calculate(date, OrderSide::Buy, gross.to_f64()); gross .checked_add(cost.fixed_total()) .expect("fixed-point buy cash overflow") .to_f64() } fn fixed_gross_amount(price: f64, quantity: u32) -> FixedMoney { FixedMoney::from_f64(price * quantity as f64) .expect("execution gross amount must be finite fixed-point money") } fn fixed_cash_fits(value: f64, limit: f64) -> bool { FixedMoney::f64_fits_within(value, limit).unwrap_or(false) } fn can_afford_minimum_buy( &self, date: NaiveDate, portfolio: &PortfolioState, data: &DataSet, symbol: &str, ) -> bool { let Some(snapshot) = data.market(date, symbol) else { return true; }; let minimum_order_quantity = self.minimum_order_quantity(data, symbol); let order_step_size = self.order_step_size(data, symbol); let minimum_buy_quantity = self.round_buy_quantity( minimum_order_quantity, minimum_order_quantity, order_step_size, ); if minimum_buy_quantity == 0 { return false; } let minimum_execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity)); Self::fixed_cash_fits( self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity), portfolio.cash(), ) } 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 IntradayExecutionLedger, 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 remainder_policy = self.effective_remainder_policy(date, allow_pending_limit); 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(()); } let size_check_price = limit_price.unwrap_or_else(|| { self.execution_order_limit_check_price( date, data, symbol, snapshot, OrderSide::Sell, algo_request, ) }); if let Some(rule_reason) = ChinaAShareRiskControl::order_size_rejection_reason_with_config( OrderSide::Sell, requested_qty, position.quantity, size_check_price, &self.risk_config, ) { 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}: {rule_reason}"), }); Self::emit_order_process_event( report, date, Self::creation_reject_kind(emit_creation_events), order_id, symbol, OrderSide::Sell, format!("status=Rejected 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 remainder_policy == RemainderPolicy::FillOrKill { Self::emit_fill_or_kill_canceled( report, date, order_id, symbol, OrderSide::Sell, requested_qty, 0, reason, ); return Ok(()); } if Self::keeps_remainder_open(remainder_policy) { 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"), time_in_force: Self::pending_time_in_force(remainder_policy), commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), 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 remainder_policy == RemainderPolicy::FillOrKill { Self::emit_fill_or_kill_canceled( report, date, order_id, symbol, OrderSide::Sell, requested_qty, 0, reason, ); return Ok(()); } if Self::keeps_remainder_open(remainder_policy) { 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"), time_in_force: Self::pending_time_in_force(remainder_policy), commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), 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, next_cursor, liquidity_consumption) = if let Some(fill) = fill { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); ( fill.quantity, fill.legs, Some(fill.next_cursor), fill.liquidity_consumption, ) } 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(), None, 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(), None, 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, execution_start_timestamp: None, execution_timestamp: None, }], None, Vec::new(), ), Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); (0, Vec::new(), None, Vec::new()) } } } }; if remainder_policy == RemainderPolicy::FillOrKill && filled_qty < requested_qty { self.clear_open_order(order_id); Self::emit_fill_or_kill_canceled( report, date, order_id, symbol, OrderSide::Sell, requested_qty, filled_qty, reason, ); return Ok(()); } if let Some(next_cursor) = next_cursor { execution_cursors.insert(symbol.to_string(), next_cursor); if self.uses_serial_execution_cursor(reason) { *global_execution_cursor = Some(next_cursor); } } if filled_qty == 0 { let detail = partial_fill_reason .as_deref() .unwrap_or("limit price not marketable yet"); if Self::keeps_remainder_open(remainder_policy) && 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"), time_in_force: Self::pending_time_in_force(remainder_policy), commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), 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_money = Self::fixed_gross_amount(leg.price, leg.quantity); let gross_amount = gross_money.to_f64(); let cost = self.cost_model.calculate_with_order_state( date, OrderSide::Sell, gross_amount, Some(order_id), commission_state, ); let net_cash = gross_money .checked_sub(cost.fixed_total()) .expect("fixed-point sell proceeds underflow") .to_f64(); 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) .map_err(BacktestError::Execution)?; report.fill_events.push(FillEvent { date, decision_date: None, order_created_date: None, execution_date: None, execution_start_timestamp: leg.execution_start_timestamp, execution_timestamp: leg.execution_timestamp, 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, transfer_fee: cost.transfer_fee, 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(); execution_cursors.apply_liquidity_consumption(&liquidity_consumption); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); let keep_open = Self::keeps_remainder_open(remainder_policy) && 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"), time_in_force: Self::pending_time_in_force(remainder_policy), commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); } else { self.clear_open_order(order_id); } let status = if keep_open { OrderStatus::PartiallyFilled } else if filled_qty < requested_qty { OrderStatus::Canceled } 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::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 IntradayExecutionLedger, 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 { 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 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_value( date, portfolio, data, symbol, cash_delta, reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, report, )?; } 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 IntradayExecutionLedger, 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 && current_qty == 0 { 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 target_value <= f64::EPSILON { 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, )?; } 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 IntradayExecutionLedger, 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, )?; } } 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 IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let price = if 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 IntradayExecutionLedger, 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 IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let total_equity = self.target_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 IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let total_equity = self.target_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 IntradayExecutionLedger, 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, ); if requested_qty == 0 { report.diagnostics.push(format!( "value_order_skipped symbol={symbol} side=buy reason=below_executable_quantity value={} minimum_order_quantity={} order_step_size={} intent_reason={reason}", value.abs(), minimum_order_quantity, order_step_size, )); return Ok(()); } 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 minimum_order_quantity = self.minimum_order_quantity(data, symbol); let order_step_size = self.order_step_size(data, symbol); let requested_qty = self.round_buy_quantity( ((value.abs()) / price).floor() as u32, minimum_order_quantity, order_step_size, ); if requested_qty == 0 { report.diagnostics.push(format!( "value_order_skipped symbol={symbol} side=sell reason=below_executable_quantity value={} minimum_order_quantity={} order_step_size={} intent_reason={reason}", value.abs(), minimum_order_quantity, order_step_size, )); return Ok(()); } 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 IntradayExecutionLedger, 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 IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let total_equity = self.target_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 IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let total_equity = self.target_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 IntradayExecutionLedger, 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 IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let total_equity = self.target_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 IntradayExecutionLedger, 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 IntradayExecutionLedger, 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: &IntradayExecutionLedger, _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 IntradayExecutionLedger, 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 remainder_policy = self.effective_remainder_policy(date, allow_pending_limit); if portfolio .position(symbol) .is_none_or(|position| position.quantity == 0) && self .runtime_target_position_limit .get() .is_some_and(|limit| Self::positive_position_count(portfolio) >= limit) { let position_count = Self::positive_position_count(portfolio); let position_limit = self.runtime_target_position_limit.get().unwrap_or(0); Self::reject_unavailable_order( report, date, order_id, symbol, OrderSide::Buy, requested_qty, reason, "target position slot unavailable after failed exit", emit_creation_events, ); report.diagnostics.push(format!( "target_position_slot_rejected symbol={symbol} current_positions={position_count} target_position_limit={position_limit}" )); return Ok(()); } 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(()); } let current_position_quantity = portfolio .position(symbol) .map(|position| position.quantity) .unwrap_or(0) .saturating_add(self.reserved_open_buy_quantity(symbol, Some(order_id))); let size_check_price = limit_price.unwrap_or_else(|| { self.execution_order_limit_check_price( date, data, symbol, snapshot, OrderSide::Buy, algo_request, ) }); if let Some(rule_reason) = ChinaAShareRiskControl::order_size_rejection_reason_with_config( OrderSide::Buy, requested_qty, current_position_quantity, size_check_price, &self.risk_config, ) { 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::Rejected, 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=Rejected 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 remainder_policy == RemainderPolicy::FillOrKill { Self::emit_fill_or_kill_canceled( report, date, order_id, symbol, OrderSide::Buy, requested_qty, 0, reason, ); return Ok(()); } if Self::keeps_remainder_open(remainder_policy) { 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"), time_in_force: Self::pending_time_in_force(remainder_policy), commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), 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, next_cursor, liquidity_consumption) = if let Some(fill) = fill { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); ( fill.quantity, fill.legs, Some(fill.next_cursor), fill.liquidity_consumption, ) } 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(), None, 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(), None, 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(), None, 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(), None, 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, execution_start_timestamp: None, execution_timestamp: None, }], None, Vec::new(), ) } } } } }; if remainder_policy == RemainderPolicy::FillOrKill && filled_qty < requested_qty { self.clear_open_order(order_id); Self::emit_fill_or_kill_canceled( report, date, order_id, symbol, OrderSide::Buy, requested_qty, filled_qty, reason, ); return Ok(()); } if let Some(next_cursor) = next_cursor { execution_cursors.insert(symbol.to_string(), next_cursor); if self.uses_serial_execution_cursor(reason) { *global_execution_cursor = Some(next_cursor); } } if filled_qty == 0 { let detail = partial_fill_reason .as_deref() .unwrap_or("insufficient cash after fees"); if Self::keeps_remainder_open(remainder_policy) && 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"), time_in_force: Self::pending_time_in_force(remainder_policy), commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), 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_money = Self::fixed_gross_amount(leg.price, leg.quantity); let gross_amount = gross_money.to_f64(); let cost = self.cost_model.calculate_with_order_state( date, OrderSide::Buy, gross_amount, Some(order_id), commission_state, ); let cash_out = gross_money .checked_add(cost.fixed_total()) .expect("fixed-point buy cash overflow") .to_f64(); portfolio .apply_cash_delta(-cash_out) .map_err(BacktestError::Execution)?; 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, execution_start_timestamp: leg.execution_start_timestamp, execution_timestamp: leg.execution_timestamp, 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, transfer_fee: cost.transfer_fee, 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}"), }); } execution_cursors.apply_liquidity_consumption(&liquidity_consumption); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); let keep_open = Self::keeps_remainder_open(remainder_policy) && 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"), time_in_force: Self::pending_time_in_force(remainder_policy), commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); } else { self.clear_open_order(order_id); } let status = if keep_open { OrderStatus::PartiallyFilled } else if filled_qty < requested_qty { OrderStatus::Canceled } 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::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 target_total_equity_at( &self, date: NaiveDate, portfolio: &PortfolioState, data: &DataSet, ) -> Result { if let Some(equity) = self .runtime_decision_total_equity .get() .filter(|equity| equity.is_finite() && *equity >= 0.0) { return Ok(equity); } self.rebalance_total_equity_at(date, portfolio, data) } 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::fixed_cash_fits( self.estimated_buy_cash_out(date, price, quantity), value_budget, ) { return quantity; } quantity = self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } 0 } #[allow(clippy::too_many_arguments)] fn target_buy_quantity_for_budget( &self, date: NaiveDate, data: &DataSet, symbol: &str, value_budget: f64, fallback_price: f64, minimum_order_quantity: u32, order_step_size: u32, ) -> u32 { let snapshot = data.market(date, symbol); let mut quantity = self.value_buy_quantity( date, value_budget, fallback_price, minimum_order_quantity, order_step_size, ); for _ in 0..8 { let execution_price = snapshot .map(|snapshot| { self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) }) .filter(|price| price.is_finite() && *price > 0.0) .unwrap_or(fallback_price); let resolved = self.value_buy_quantity( date, value_budget, execution_price, minimum_order_quantity, order_step_size, ); if resolved == quantity { return quantity; } quantity = resolved; } while quantity >= minimum_order_quantity.max(1) { let execution_price = snapshot .map(|snapshot| { self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) }) .filter(|price| price.is_finite() && *price > 0.0) .unwrap_or(fallback_price); if Self::fixed_cash_fits( self.estimated_buy_cash_out(date, execution_price, quantity), value_budget, ) { 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| !Self::fixed_cash_fits(gross, limit)) { quantity = self.decrement_order_quantity( quantity, minimum_order_quantity, order_step_size, ); continue; } let cost = self.cost_model.calculate(date, OrderSide::Buy, gross); let cash_out = FixedMoney::checked_sum_f64([gross, cost.total()]) .expect("buy cash must be finite fixed-point money") .to_f64(); if Self::fixed_cash_fits(cash_out, cash) { 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| !Self::fixed_cash_fits(price * requested_qty as f64, limit)) { 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_ledger: &mut IntradayExecutionLedger, _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 post_close_window = self.post_close_execution_window(date); let use_intraday_quotes = post_close_window.is_some() || 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 = post_close_window.map(|window| window.0).or_else(|| { 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 = post_close_window.map(|window| window.1).or_else(|| { algo_request .and_then(|request| request.end_time) .or(runtime_end_time) .map(|end_time| date.and_time(end_time)) }); let end_cursor = if end_cursor.is_none() && matching_type == MatchingType::CurrentBarClose && self.execution_price_field == PriceField::Last { start_cursor } else { end_cursor }; let quotes = data.execution_quotes_on(date, symbol); if let Some(fill) = self.select_execution_fill_with_ledger( symbol, 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, execution_ledger, ) { return Some(fill); } if post_close_window.is_some() || algo_request.is_some() || runtime_start_time.is_some() || runtime_end_time.is_some() || self.intraday_execution_start_time.is_some() { let next_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) + 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(), liquidity_consumption: 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" } } #[cfg(test)] 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 { self.select_execution_fill_with_ledger( &snapshot.symbol, 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, &IntradayExecutionLedger::default(), ) } #[allow(clippy::too_many_arguments)] fn select_execution_fill_with_ledger( &self, symbol: &str, 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, execution_ledger: &IntradayExecutionLedger, ) -> 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 = !self.is_post_close_fixed_price(snapshot.date) && 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 first_timestamp = None; 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(); let book_side = QuoteBookSide::for_order_side(side); let mut depth_state = execution_ledger .depth_consumption .get(symbol) .and_then(|sides| sides[IntradayExecutionLedger::depth_slot(book_side)]); let mut pending_volume_consumption = BTreeMap::::new(); let mut liquidity_consumption = Vec::new(); 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 consume_depth = quote_quantity_limited && !missing_level1_depth; let top_level_price = match side { OrderSide::Buy => quote.ask1, OrderSide::Sell => quote.bid1, }; let displayed_quantity = match side { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, } .saturating_mul(lot as u64) .min(u32::MAX as u64) as u32; let depth_price_bits = top_level_price.to_bits(); let mut available_qty = if consume_depth { let consumed = depth_state .filter(|state| { state.price_bits == depth_price_bits && state.displayed_quantity == displayed_quantity }) .map(|state| state.consumed_quantity.min(displayed_quantity)) .unwrap_or_else(|| { execution_ledger.depth_consumed( symbol, book_side, depth_price_bits, displayed_quantity, ) }); depth_state = Some(QuoteDepthConsumption { price_bits: depth_price_bits, displayed_quantity, consumed_quantity: consumed, }); displayed_quantity.saturating_sub(consumed) } 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) }; let consumed = execution_ledger .volume_consumed(symbol, quote.timestamp) .saturating_add( pending_volume_consumption .get("e.timestamp) .copied() .unwrap_or(0), ); available_qty = available_qty.min(volume_limited.saturating_sub(consumed)); } 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| !Self::fixed_cash_fits(candidate_gross, limit)) { 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(); let candidate_cash = FixedMoney::checked_sum_f64([candidate_gross, candidate_cost]) .expect("buy cash must be finite fixed-point money") .to_f64(); if Self::fixed_cash_fits(candidate_cash, cash) { 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; first_timestamp.get_or_insert(quote.timestamp); last_timestamp = Some(quote.timestamp); legs.push(ExecutionLeg { price: quote_price, mark_price, quantity: take_qty, execution_start_timestamp: Some(quote.timestamp), execution_timestamp: Some(quote.timestamp), }); if consume_depth { let state = depth_state .as_mut() .expect("depth state must exist when depth consumption is enabled"); state.consumed_quantity = state .consumed_quantity .saturating_add(take_qty) .min(state.displayed_quantity); } if self.volume_limit { let consumed = pending_volume_consumption .entry(quote.timestamp) .or_default(); *consumed = consumed.saturating_add(take_qty); } if consume_depth || self.volume_limit { liquidity_consumption.push(QuoteLiquidityConsumption { symbol: symbol.to_string(), timestamp: quote.timestamp, book_side, depth_price_bits, displayed_quantity, consume_depth, consume_volume: self.volume_limit, 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(), liquidity_consumption: 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, execution_start_timestamp: first_timestamp, execution_timestamp: last_timestamp, }] } else { legs }, liquidity_consumption, 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 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 chrono::NaiveTime; use super::{ BrokerExecutionReport, BrokerSimulator, EquityExecutionPhase, IntradayExecutionLedger, MatchingType, OpenOrder, RebalanceCashMode, SlippageModel, }; use crate::cost::ChinaAShareCostModel; use crate::data::{ BenchmarkSnapshot, CandidateEligibility, DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceField, }; use crate::events::{OrderSide, OrderStatus}; use crate::fixed_point::FixedMoney; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision}; fn test_open_order(order_id: u64) -> OpenOrder { OpenOrder { order_id, decision_date: None, order_created_date: None, symbol: "000001.SZ".to_string(), side: OrderSide::Buy, requested_quantity: 200, filled_quantity: 0, remaining_quantity: 200, limit_price: 10.0, time_in_force: OrderTimeInForce::Gtc, commission_remaining: None, execution_cursor: None, reason: format!("order_{order_id}"), } } #[test] fn open_order_upsert_replaces_in_place_and_preserves_queue_position() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks); broker.upsert_open_order(test_open_order(1)); broker.upsert_open_order(test_open_order(2)); let mut amended = test_open_order(1); amended.filled_quantity = 100; amended.remaining_quantity = 100; broker.upsert_open_order(amended); assert_eq!( broker .open_orders .borrow() .iter() .map(|order| order.order_id) .collect::>(), vec![1, 2] ); assert_eq!(broker.open_order_views()[0].filled_quantity, 100); } 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 target_position_slot_test_data(block_exit: bool) -> DataSet { let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; let instruments = symbols .iter() .map(|symbol| { let mut instrument = limit_test_instrument(); instrument.symbol = (*symbol).to_string(); instrument.name = (*symbol).to_string(); instrument }) .collect::>(); let snapshots = symbols .iter() .map(|symbol| { let mut snapshot = limit_test_snapshot(); snapshot.symbol = (*symbol).to_string(); if block_exit && *symbol == "000001.SZ" { snapshot.day_open = snapshot.lower_limit; snapshot.open = snapshot.lower_limit; snapshot.last_price = snapshot.lower_limit; snapshot.bid1 = snapshot.lower_limit; snapshot.ask1 = snapshot.lower_limit; } snapshot }) .collect::>(); let candidates = symbols .iter() .map(|symbol| { let mut candidate = limit_test_candidate(true, true); candidate.symbol = (*symbol).to_string(); candidate }) .collect::>(); DataSet::from_components_with_actions_and_quotes( instruments, snapshots, Vec::new(), candidates, vec![limit_test_benchmark()], Vec::new(), Vec::new(), ) .expect("valid target-position slot dataset") } 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 post_close_phase_is_derived_from_actual_same_day_submission_time() { let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date"); let before_effective = chrono::NaiveDate::from_ymd_opt(2026, 7, 3).expect("valid date"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::CurrentBarClose) .with_slippage_model(SlippageModel::PriceRatio(0.25)); broker .runtime_intraday_start_time .set(NaiveTime::from_hms_opt(15, 0, 0)); assert_eq!( broker.execution_phase(date), EquityExecutionPhase::ContinuousAuction, "a configured clock without an actual same-day order creation event must not select the post-close route" ); broker.runtime_order_created_date.set(Some(date)); let mut snapshot = dated_limit_test_snapshot(date); snapshot.close = 10.0; snapshot.upper_limit = 20.0; for (hour, minute) in [(14, 59), (15, 31)] { broker .runtime_intraday_start_time .set(NaiveTime::from_hms_opt(hour, minute, 0)); assert_eq!( broker.execution_phase(date), EquityExecutionPhase::ContinuousAuction ); assert_eq!( broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), 12.5 ); } for (hour, minute) in [(15, 0), (15, 5), (15, 30)] { broker .runtime_intraday_start_time .set(NaiveTime::from_hms_opt(hour, minute, 0)); assert_eq!( broker.execution_phase(date), EquityExecutionPhase::PostCloseFixedPrice ); assert_eq!( broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), 10.0 ); } broker .runtime_order_created_date .set(Some(before_effective)); assert_eq!( broker.execution_phase(before_effective), EquityExecutionPhase::ContinuousAuction ); let next_open = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::NextBarOpen) .with_intraday_execution_start_time( NaiveTime::from_hms_opt(15, 0, 0).expect("valid signal time"), ); next_open .runtime_order_created_date .set(Some(date.pred_opt().expect("previous date"))); assert_eq!( next_open.execution_phase(date), EquityExecutionPhase::ContinuousAuction, "a 15:00 signal for next-open execution is not a same-day post-close order" ); } #[test] fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() { let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date"); let mut snapshot = dated_limit_test_snapshot(date); snapshot.close = 10.0; snapshot.last_price = 12.0; snapshot.bid1 = 11.99; snapshot.ask1 = 12.01; snapshot.upper_limit = 20.0; snapshot.lower_limit = 1.0; let mut quote_before_matching = limit_test_quote(12.0, 11.99, 12.01); quote_before_matching.date = date; quote_before_matching.timestamp = date.and_hms_opt(15, 4, 0).expect("valid timestamp"); quote_before_matching.volume_delta = 100_000; let mut quote_at_matching = quote_before_matching.clone(); quote_at_matching.timestamp = date.and_hms_opt(15, 5, 0).expect("valid timestamp"); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot], Vec::new(), vec![dated_limit_test_candidate(date, false, false, true, true)], vec![dated_limit_test_benchmark(date)], Vec::new(), vec![quote_before_matching, quote_at_matching], ) .expect("valid post-close dataset"); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::CurrentBarClose) .with_slippage_model(SlippageModel::PriceRatio(0.25)) .with_volume_limit(false) .with_liquidity_limit(false); let mut portfolio = PortfolioState::new(100_000.0); let report = broker .execute_between( date, &mut portfolio, &data, &next_open_buy_decision(), NaiveTime::from_hms_opt(15, 0, 0), NaiveTime::from_hms_opt(15, 0, 0), ) .expect("post-close order executes"); assert_eq!(report.fill_events.len(), 1, "{report:?}"); let fill = &report.fill_events[0]; assert_eq!(fill.price, 10.0, "fixed-price trading uses official close"); assert_eq!( fill.execution_timestamp, date.and_hms_opt(15, 5, 0), "15:00 submission must not consume the 15:04 quote" ); assert!(!broker.has_open_orders()); } #[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 scheduled_target_value_valuation_and_sizing_use_same_intraday_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()); 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), 11.0 ); assert_eq!( broker.value_sell_sizing_price(date, &data, "000001.SZ", snapshot), 11.0 ); } #[test] fn next_open_target_value_valuation_uses_execution_open() { 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_matching_type(MatchingType::NextBarOpen); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.prev_close = 10.0; snapshot.open = 11.0; snapshot.close = 20.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 snapshot = data.market(date, "000001.SZ").expect("market snapshot"); assert_eq!( broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot), 11.0 ); } #[test] fn next_open_target_value_recomputes_quantity_from_execution_open() { 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_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.prev_close = 10.0; snapshot.open = 11.0; snapshot.close = 20.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(20_000.0); portfolio.position_mut("000001.SZ").buy( date.pred_opt().expect("previous date"), 1_000, 10.0, ); portfolio.apply_cash_delta(-10_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker .process_target_value( date, &mut portfolio, &data, "000001.SZ", 5_500.0, "next_open_target_value", &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("target value execution"); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].price, 11.0); assert_eq!(report.fill_events[0].quantity, 500); assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 500); } #[test] fn target_value_reduction_from_full_to_twenty_percent_sells_eighty_percent() { 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_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.prev_close = 10.0; snapshot.open = 10.0; snapshot.close = 10.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(100_000.0); portfolio.position_mut("000001.SZ").buy( date.pred_opt().expect("previous date"), 10_000, 10.0, ); portfolio.apply_cash_delta(-100_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker .process_target_value( date, &mut portfolio, &data, "000001.SZ", 20_000.0, "target_value_20_percent", &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("target value reduction"); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].side, OrderSide::Sell); assert_eq!(report.fill_events[0].quantity, 8_000); assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 2_000); } #[test] fn unchanged_targets_do_not_create_zero_quantity_orders() { 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_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.prev_close = 10.0; snapshot.open = 10.0; snapshot.close = 10.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(20_000.0); portfolio.position_mut("000001.SZ").buy( date.pred_opt().expect("previous date"), 1_000, 10.0, ); portfolio.apply_cash_delta(-10_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker .process_target_value( date, &mut portfolio, &data, "000001.SZ", 10_000.0, "unchanged_target_value", &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("unchanged target value"); broker .process_timed_target_value( date, &mut portfolio, &data, "000001.SZ", 10_000.0, AlgoOrderStyle::Twap, None, None, "unchanged timed target value", &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("unchanged timed target value"); broker .process_target_shares( date, &mut portfolio, &data, "000001.SZ", 1_000, "unchanged target shares", &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("unchanged target shares"); assert!(report.order_events.is_empty()); assert!(report.fill_events.is_empty()); assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 1_000); } #[test] fn target_value_delta_below_order_step_is_audited_without_creating_zero_quantity_order() { 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_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.prev_close = 10.0; snapshot.open = 10.0; snapshot.close = 10.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.0); portfolio.position_mut("000001.SZ").buy( date.pred_opt().expect("previous date"), 1_000, 10.0, ); portfolio.apply_cash_delta(-10_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker .process_target_value( date, &mut portfolio, &data, "000001.SZ", 9_995.0, "sub_lot_target_adjust", &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("target value execution"); assert!(report.order_events.is_empty()); assert!(report.fill_events.is_empty()); assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 1_000); assert!(report.diagnostics.iter().any(|diagnostic| { diagnostic.contains("value_order_skipped symbol=000001.SZ side=sell") && diagnostic.contains("reason=below_executable_quantity") })); let mut buy_portfolio = PortfolioState::new(10_000.0); let mut buy_report = BrokerExecutionReport::default(); broker .process_target_value( date, &mut buy_portfolio, &data, "000001.SZ", 995.0, "sub_lot_target_open", &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut buy_report, ) .expect("target value execution"); assert!(buy_report.order_events.is_empty()); assert!(buy_report.fill_events.is_empty()); assert!(buy_portfolio.position("000001.SZ").is_none()); assert!(buy_report.diagnostics.iter().any(|diagnostic| { diagnostic.contains("value_order_skipped symbol=000001.SZ side=buy") && diagnostic.contains("reason=below_executable_quantity") })); } #[test] fn target_percent_reduction_from_full_to_twenty_percent_sells_eighty_percent() { 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_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.prev_close = 10.0; snapshot.open = 10.0; snapshot.close = 10.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(100_000.0); portfolio.position_mut("000001.SZ").buy( date.pred_opt().expect("previous date"), 10_000, 10.0, ); portfolio.apply_cash_delta(-100_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker .process_target_percent( date, &mut portfolio, &data, "000001.SZ", 0.2, "target_percent_20_percent", &mut BTreeMap::new(), &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, ) .expect("target percent reduction"); assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].side, OrderSide::Sell); assert_eq!(report.fill_events[0].quantity, 8_000); assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 2_000); } #[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 IntradayExecutionLedger::default(), &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_weight_buy_quantity_respects_per_symbol_budget_after_slippage_and_fees() { 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_matching_type(MatchingType::NextBarOpen) .with_slippage_model(SlippageModel::PriceRatio(0.002)) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut snapshot = limit_test_snapshot(); snapshot.date = date; snapshot.open = 10.0; snapshot.close = 10.0; snapshot.last_price = 10.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 portfolio = PortfolioState::new(100_000.0); let target_weights = BTreeMap::from([("000001.SZ".to_string(), 0.5)]); let (targets, _) = broker .target_quantities(date, &portfolio, &data, &target_weights) .expect("target quantities"); let quantity = targets["000001.SZ"]; let execution_price = 10.0 * 1.002; let allocated_amount = 50_000.0; assert_eq!(quantity, 4_900); assert!(broker.estimated_buy_cash_out(date, execution_price, quantity) <= allocated_amount); assert!( broker.estimated_buy_cash_out(date, execution_price, quantity + 100) > allocated_amount ); } #[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 IntradayExecutionLedger::default(), &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 target_portfolio_smart_defers_buy_risk_until_order_validation() { 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 broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_volume_limit(false) .with_liquidity_limit(false); let (targets, _) = broker .target_quantities(date, &portfolio, &data, &target_weights) .expect("target quantities"); assert_eq!(targets.get("000001.SZ").copied(), Some(49_900)); } #[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_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 IntradayExecutionLedger::default(), &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_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 = IntradayExecutionLedger::default(); 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_budgets_each_buy_before_cash_optimization() { 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.is_empty(), "{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 IntradayExecutionLedger::default(), &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 IntradayExecutionLedger::default(), &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_value_batch_executes_reductions_before_increases_when_sell_cash_is_reusable() { 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 decision = StrategyDecision { order_intents: vec![ OrderIntent::TargetValue { symbol: "000002.SZ".to_string(), target_value: 9_000.0, reason: "buy_submitted_first".to_string(), }, OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), target_value: 0.0, reason: "sell_submitted_second".to_string(), }, ], ..StrategyDecision::default() }; for mode in [ RebalanceCashMode::SamePointNet, RebalanceCashMode::SellThenBuy, ] { let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_rebalance_cash_mode(mode) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut portfolio = PortfolioState::new(0.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); let report = broker .execute(date, &mut portfolio, &data, &decision) .expect("target-value batch execution"); assert!(portfolio.position("000001.SZ").is_none(), "{mode:?}"); assert!( portfolio .position("000002.SZ") .is_some_and(|position| position.quantity > 0), "mode={mode:?} fills={:?}", report.fill_events ); assert_eq!( report.fill_events.first().map(|fill| fill.side), Some(OrderSide::Sell), "mode={mode:?} fills={:?}", report.fill_events ); } } #[test] fn failed_target_exit_blocks_replacement_entry_when_no_position_slot_is_released() { 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 broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_rebalance_cash_mode(RebalanceCashMode::SellThenBuy) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_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 decision = StrategyDecision { order_intents: vec![ OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), target_value: 0.0, reason: "replace_exit".to_string(), }, OrderIntent::TargetValue { symbol: "000003.SZ".to_string(), target_value: 9_000.0, reason: "replace_entry".to_string(), }, ], ..StrategyDecision::default() }; let report = broker .execute( date, &mut portfolio, &target_position_slot_test_data(true), &decision, ) .expect("failed-exit target batch execution"); assert_eq!( BrokerSimulator::::positive_position_count( &portfolio ), 2 ); assert!(portfolio.position("000001.SZ").is_some()); assert!(portfolio.position("000003.SZ").is_none()); assert!(report.order_events.iter().any(|event| { event.symbol == "000001.SZ" && event.side == OrderSide::Sell && event.status == OrderStatus::Canceled })); assert!(report.order_events.iter().any(|event| { event.symbol == "000003.SZ" && event.side == OrderSide::Buy && event .reason .contains("target position slot unavailable after failed exit") })); } #[test] fn successful_target_exit_releases_position_slot_for_replacement_entry() { 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 broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_rebalance_cash_mode(RebalanceCashMode::SellThenBuy) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_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 decision = StrategyDecision { order_intents: vec![ OrderIntent::TargetValue { symbol: "000003.SZ".to_string(), target_value: 9_000.0, reason: "replace_entry".to_string(), }, OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), target_value: 0.0, reason: "replace_exit".to_string(), }, ], ..StrategyDecision::default() }; let report = broker .execute( date, &mut portfolio, &target_position_slot_test_data(false), &decision, ) .expect("successful-exit target batch execution"); assert_eq!( BrokerSimulator::::positive_position_count( &portfolio ), 2 ); assert!(portfolio.position("000001.SZ").is_none()); assert!( portfolio .position("000003.SZ") .is_some_and(|position| position.quantity > 0) ); assert!(report.order_events.iter().all(|event| { !event .reason .contains("target position slot unavailable after failed exit") })); } #[test] fn independent_target_entry_is_not_subject_to_replacement_slot_limit() { 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 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 portfolio = PortfolioState::new(20_000.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); let decision = StrategyDecision { order_intents: vec![OrderIntent::TargetValue { symbol: "000003.SZ".to_string(), target_value: 9_000.0, reason: "independent_entry".to_string(), }], ..StrategyDecision::default() }; broker .execute( date, &mut portfolio, &target_position_slot_test_data(false), &decision, ) .expect("independent target entry"); assert_eq!( BrokerSimulator::::positive_position_count( &portfolio ), 2 ); assert!( portfolio .position("000003.SZ") .is_some_and(|position| position.quantity > 0) ); } #[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 IntradayExecutionLedger::default(), &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 IntradayExecutionLedger::default(), &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 IntradayExecutionLedger::default(), &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.iter().any(|event| { event.symbol == symbol && event.side == OrderSide::Sell && event.status == OrderStatus::Rejected && event.reason.contains("missing") })); } #[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).unwrap(); 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 IntradayExecutionLedger::default(), &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::default() .with_commission_rate(0.0003) .with_stamp_tax_rates(0.0005, 0.0005) .with_minimum_commission(5.0), 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_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 IntradayExecutionLedger::default(), &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.legs[0].execution_timestamp, Some(quote_timestamp)); assert!(fill.legs[0].execution_timestamp.unwrap() <= decision_time); 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 IntradayExecutionLedger::default(), &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 IntradayExecutionLedger::default(), &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 IntradayExecutionLedger::default(), &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); let cash_out = FixedMoney::checked_sum_f64([ fill.gross_amount, fill.commission, fill.stamp_tax, fill.transfer_fee, ]) .unwrap() .to_f64(); assert!( BrokerSimulator::::fixed_cash_fits( cash_out, value_budget ) ); 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, ); 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_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 IntradayExecutionLedger::default(), &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_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 IntradayExecutionLedger::default(), &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 ); } }