From a838732e5a1e7a39fedc039bd6ffbeb01bcff9a1 Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 6 Sep 2026 18:26:55 +0800 Subject: [PATCH] fix: reject missing daily execution prices per order --- crates/fidc-core/src/broker.rs | 283 +++++++++++++++++++++++++-- crates/fidc-core/src/risk_control.rs | 5 +- 2 files changed, 274 insertions(+), 14 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index d2d828b..d955513 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1039,16 +1039,23 @@ where .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)) + let execution_price = 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)); + if execution_price.is_finite() && execution_price > 0.0 { + return execution_price; + } + self.target_value_valuation_price(date, data, symbol, snapshot) } fn snapshot_execution_price( @@ -1080,6 +1087,31 @@ where } } + fn missing_daily_execution_price_reason( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + algo_request: Option<&AlgoExecutionRequest>, + ) -> Option<&'static str> { + if algo_request.is_some() { + return None; + } + let (price, reason) = match self.matching_type { + MatchingType::OpenAuction => { + (snapshot.day_open, "missing_execution_price field=day_open") + } + MatchingType::CurrentBarClose => { + (snapshot.close, "missing_execution_price field=close") + } + MatchingType::NextBarOpen => (snapshot.open, "missing_execution_price field=open"), + MatchingType::MinuteLast + | MatchingType::MinuteBestOwn + | MatchingType::MinuteBestCounterparty + | MatchingType::Vwap + | MatchingType::Twap => return None, + }; + (!price.is_finite() || price <= 0.0).then_some(reason) + } + fn snapshot_mark_price( &self, snapshot: &crate::data::DailyMarketSnapshot, @@ -3153,6 +3185,47 @@ where ); } + fn reject_missing_execution_price_order( + report: &mut BrokerExecutionReport, + date: NaiveDate, + order_id: u64, + symbol: &str, + side: OrderSide, + requested_quantity: u32, + reason: &str, + missing_reason: &'static str, + emit_creation_events: bool, + ) { + 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}: {missing_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={missing_reason} historical_price_fallback=false" + ), + ); + report.diagnostics.push(format!( + "order_execution_price_unavailable symbol={symbol} side={} requested={requested_quantity} reason={missing_reason} historical_price_fallback=false", + side.as_str() + )); + } + fn creation_reject_kind(emit_creation_events: bool) -> ProcessEventKind { if emit_creation_events { ProcessEventKind::OrderCreationReject @@ -4263,6 +4336,24 @@ where return Ok(()); } + if let Some(missing_reason) = + self.missing_daily_execution_price_reason(snapshot, algo_request) + { + Self::reject_missing_execution_price_order( + report, + date, + order_id, + symbol, + OrderSide::Sell, + requested_qty, + reason, + missing_reason, + emit_creation_events, + ); + self.clear_open_order(order_id); + return Ok(()); + } + let size_check_price = limit_price.unwrap_or_else(|| { self.execution_order_limit_check_price( date, @@ -6042,6 +6133,24 @@ where return Ok(()); } + if let Some(missing_reason) = + self.missing_daily_execution_price_reason(snapshot, algo_request) + { + Self::reject_missing_execution_price_order( + report, + date, + order_id, + symbol, + OrderSide::Buy, + requested_qty, + reason, + missing_reason, + emit_creation_events, + ); + self.clear_open_order(order_id); + return Ok(()); + } + let current_position_quantity = portfolio .position(symbol) .map(|position| position.quantity) @@ -6683,10 +6792,15 @@ where snapshot.price(self.execution_price_field) }; if price.is_finite() && price > 0.0 { - Some(price) - } else { - None + return Some(price); } + if self.matching_type == MatchingType::NextBarOpen + && snapshot.close.is_finite() + && snapshot.close > 0.0 + { + return Some(snapshot.close); + } + None } fn rebalance_valuation_price_with_overrides( @@ -7943,6 +8057,34 @@ mod tests { } } + fn unpriced_next_open_test_data(paused: bool) -> DataSet { + let mut snapshot = limit_test_snapshot(); + snapshot.day_open = 0.0; + snapshot.open = 0.0; + snapshot.close = 10.0; + snapshot.last_price = 10.0; + snapshot.paused = paused; + if paused { + snapshot.high = 0.0; + snapshot.low = 0.0; + snapshot.volume = 0; + snapshot.upper_limit = 0.0; + snapshot.lower_limit = 0.0; + } + let mut candidate = limit_test_candidate(!paused, !paused); + candidate.is_paused = paused; + DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![snapshot], + Vec::new(), + vec![candidate], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("unpriced next-open dataset") + } + fn target_position_slot_test_data(block_exit: bool) -> DataSet { let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; let instruments = symbols @@ -10357,6 +10499,121 @@ mod tests { ); } + #[test] + fn next_open_unpriced_active_snapshot_rejects_without_historical_fill() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let data = unpriced_next_open_test_data(false); + 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); + let decision = StrategyDecision { + order_intents: vec![OrderIntent::TargetValue { + symbol: "000001.SZ".to_string(), + target_value: 10_000.0, + reason: "unpriced_next_open".to_string(), + }], + ..StrategyDecision::default() + }; + + let report = broker + .execute(date, &mut portfolio, &data, &decision) + .expect("missing execution price must reject only the affected order"); + + assert!(report.fill_events.is_empty()); + assert!(portfolio.position("000001.SZ").is_none()); + assert!(report.order_events.iter().any(|event| { + event.symbol == "000001.SZ" + && event.requested_quantity > 0 + && event.filled_quantity == 0 + && event.status == OrderStatus::Rejected + && event.reason.contains("missing_execution_price field=open") + })); + assert!(report.process_events.iter().any(|event| { + event.detail.contains("missing_execution_price field=open") + && event.detail.contains("historical_price_fallback=false") + })); + } + + #[test] + fn next_open_unpriced_paused_snapshot_prefers_execution_day_pause_risk() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let data = unpriced_next_open_test_data(true); + 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); + let decision = StrategyDecision { + order_intents: vec![OrderIntent::TargetValue { + symbol: "000001.SZ".to_string(), + target_value: 10_000.0, + reason: "unpriced_paused_next_open".to_string(), + }], + ..StrategyDecision::default() + }; + + let report = broker + .execute(date, &mut portfolio, &data, &decision) + .expect("execution-day pause must reject without aborting the run"); + + assert!(report.fill_events.is_empty()); + assert!(portfolio.position("000001.SZ").is_none()); + assert!(report.order_events.iter().any(|event| { + event.symbol == "000001.SZ" + && event.requested_quantity > 0 + && event.filled_quantity == 0 + && event.status == OrderStatus::Canceled + && event.reason.ends_with(": paused") + })); + } + + #[test] + fn next_open_target_portfolio_unpriced_symbol_does_not_abort_batch() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let data = unpriced_next_open_test_data(false); + 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); + let decision = StrategyDecision { + order_intents: vec![OrderIntent::TargetPortfolioSmart { + target_weights: BTreeMap::from([("000001.SZ".to_string(), 0.5)]), + order_prices: None, + valuation_prices: None, + reason: "unpriced_target_portfolio".to_string(), + }], + ..StrategyDecision::default() + }; + + let report = broker + .execute(date, &mut portfolio, &data, &decision) + .expect("one unpriced target must not abort the target portfolio batch"); + + assert!(report.fill_events.is_empty()); + assert!(portfolio.position("000001.SZ").is_none()); + assert!(report.order_events.iter().any(|event| { + event.symbol == "000001.SZ" + && event.requested_quantity > 0 + && event.status == OrderStatus::Rejected + && event.reason.contains("missing_execution_price field=open") + })); + } + #[test] fn target_portfolio_smart_uses_prior_close_only_for_missing_day_valuation() { let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 47f7f35..e701f96 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -413,7 +413,10 @@ impl ChinaAShareRiskControl { RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy, RiskCheckScope::Sell => false, }; - if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) { + if reject_one_yuan + && (candidate.is_one_yuan + || (market.day_open.is_finite() && market.day_open > 0.0 && market.day_open <= 1.0)) + { return Some("one_yuan"); } if Self::missing_risk_state_rejected(candidate, config, scope) {