From 9e6eac557f6cd1a78e7168c6b04d4d87aa6ddf22 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 29 Jun 2026 16:35:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DSmart=E8=B0=83=E4=BB=93?= =?UTF-8?q?=E7=BC=BA=E8=A1=8C=E6=83=85=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 190 ++++++++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 12 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 556c89b..50d5e5f 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1671,6 +1671,52 @@ where ); } + 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, + 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 @@ -2469,11 +2515,40 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let snapshot = data.require_market(date, symbol)?; - let candidate = data.require_candidate(date, symbol)?; let Some(position) = portfolio.position(symbol) else { return Ok(()); }; + let Some(snapshot) = data.market(date, symbol) else { + Self::reject_unavailable_order( + report, + date, + order_id, + symbol, + OrderSide::Sell, + requested_qty, + reason, + format!( + "market snapshot is missing for price field {}", + price_field_name(self.execution_price_field) + ), + 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( @@ -4083,8 +4158,37 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let snapshot = data.require_market(date, symbol)?; - let candidate = data.require_candidate(date, symbol)?; + let Some(snapshot) = data.market(date, symbol) else { + Self::reject_unavailable_order( + report, + date, + order_id, + symbol, + OrderSide::Buy, + requested_qty, + reason, + format!( + "market snapshot is missing for price field {}", + price_field_name(self.execution_price_field) + ), + 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( @@ -4679,14 +4783,16 @@ where field: "custom valuation", }); } - let snapshot = data - .market(date, symbol) - .ok_or_else(|| BacktestError::MissingPrice { - date, - symbol: symbol.to_string(), - field: self.rebalance_valuation_price_field_name(), - })?; - self.rebalance_valuation_price_for_snapshot(snapshot) + 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(), @@ -5734,6 +5840,66 @@ mod tests { assert!(order.reason.contains("market snapshot is missing")); } + #[test] + fn target_portfolio_smart_keeps_position_when_sell_snapshot_missing() { + let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let missing_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); + let symbol = "000001.SZ"; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_volume_limit(false) + .with_liquidity_limit(false); + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![limit_test_snapshot()], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(100_000.0); + portfolio.position_mut(symbol).buy(trade_date, 1_000, 10.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_target_portfolio_smart( + missing_date, + &mut portfolio, + &data, + &BTreeMap::new(), + None, + None, + "target_portfolio_rebalance", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("missing market snapshot should keep position, not abort rebalance"); + + assert_eq!( + portfolio.position(symbol).map(|pos| pos.quantity), + Some(1_000) + ); + assert!(report.fill_events.is_empty()); + assert!(report.order_events.is_empty()); + assert!( + report + .diagnostics + .iter() + .any(|item| item.contains("rebalance_target_clipped") + && item.contains("000001.SZ")), + "{:?}", + report.diagnostics + ); + } + #[test] fn timed_target_value_zero_sells_full_position_at_requested_time_quote() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");