From 1623994287b8ec73dca0379d9c20be90cf76803a Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 29 Jun 2026 16:22:50 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=BA=E8=A1=8C=E6=83=85=E8=B0=83=E4=BB=93?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E6=94=B9=E4=B8=BA=E6=8B=92=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 158 ++++++++++++++++++++++++++++++--- 1 file changed, 144 insertions(+), 14 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index a6085aa..556c89b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1633,6 +1633,44 @@ where }); } + 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, + 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 creation_reject_kind(emit_creation_events: bool) -> ProcessEventKind { if emit_creation_events { ProcessEventKind::OrderCreationReject @@ -2948,13 +2986,6 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let snapshot = data - .market(date, symbol) - .ok_or_else(|| BacktestError::MissingPrice { - date, - symbol: symbol.to_string(), - field: price_field_name(self.execution_price_field), - })?; let current_qty = portfolio .position(symbol) .map(|pos| pos.quantity) @@ -2974,6 +3005,17 @@ where }); return Ok(()); } + if data.market(date, symbol).is_none() { + self.reject_missing_market_snapshot_order( + report, + date, + symbol, + OrderSide::Sell, + current_qty, + reason, + ); + return Ok(()); + } self.process_sell( date, portfolio, @@ -2995,6 +3037,22 @@ where return Ok(()); } + let Some(snapshot) = data.market(date, symbol) else { + self.reject_missing_market_snapshot_order( + report, + date, + symbol, + if current_qty > 0 { + OrderSide::Sell + } else { + OrderSide::Buy + }, + 0, + reason, + ); + return Ok(()); + }; + let current_value = if self.aiquant_execution_rules { let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot); valuation_price * current_qty as f64 @@ -3486,13 +3544,29 @@ where if value.abs() <= f64::EPSILON { return Ok(()); } - let snapshot = data - .market(date, symbol) - .ok_or_else(|| BacktestError::MissingPrice { + 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_snapshot_order( + report, date, - symbol: symbol.to_string(), - field: price_field_name(self.execution_price_field), - })?; + 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); @@ -5400,7 +5474,7 @@ mod tests { BenchmarkSnapshot, CandidateEligibility, DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceField, }; - use crate::events::OrderSide; + use crate::events::{OrderSide, OrderStatus}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::rules::ChinaEquityRuleHooks; @@ -5604,6 +5678,62 @@ mod tests { ); } + #[test] + fn target_value_zero_rejects_sell_when_market_snapshot_missing() { + let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let missing_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); + let symbol = "000001.SZ"; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_volume_limit(false) + .with_liquidity_limit(false); + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![limit_test_snapshot()], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(100_000.0); + portfolio.position_mut(symbol).buy(trade_date, 1_000, 10.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_target_value( + missing_date, + &mut portfolio, + &data, + symbol, + 0.0, + "target_rebalance_exit", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("missing market snapshot should reject order, not abort backtest"); + + assert_eq!( + portfolio.position(symbol).map(|pos| pos.quantity), + Some(1_000) + ); + assert_eq!(report.fill_events.len(), 0); + assert_eq!(report.order_events.len(), 1); + let order = &report.order_events[0]; + assert_eq!(order.side, OrderSide::Sell); + assert_eq!(order.status, OrderStatus::Rejected); + assert_eq!(order.requested_quantity, 1_000); + assert_eq!(order.filled_quantity, 0); + assert!(order.reason.contains("market snapshot is missing")); + } + #[test] fn timed_target_value_zero_sells_full_position_at_requested_time_quote() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");