diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index d4011d4..414cb1e 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1310,6 +1310,12 @@ where .rules .can_buy(date, snapshot, candidate, self.execution_price_field); if !rule.allowed { + let status = match rule.reason.as_deref() { + Some("paused") + | Some("buy disabled by eligibility flags") + | Some("open at or above upper limit") => OrderStatus::Canceled, + _ => OrderStatus::Rejected, + }; report.order_events.push(OrderEvent { date, order_id: Some(order_id), @@ -1317,7 +1323,7 @@ where side: OrderSide::Buy, requested_quantity: requested_qty, filled_quantity: 0, - status: OrderStatus::Rejected, + status, reason: format!("{reason}: {}", rule.reason.unwrap_or_default()), }); return Ok(()); diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 0ec5481..5787e42 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -311,6 +311,113 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() { assert_eq!(report.fill_events[0].price, 9.8); } +#[test] +fn broker_cancels_buy_when_open_hits_upper_limit() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000002.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 09:30:00".to_string()), + day_open: 11.0, + open: 11.0, + high: 11.0, + low: 10.8, + close: 10.9, + last_price: 11.0, + bid1: 10.99, + ask1: 11.0, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 50_000, + ask1_volume: 50_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000002.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 100_000.0, + reason: "upper_limit_buy".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert!(report.fill_events.is_empty()); + assert_eq!(report.order_events.len(), 1); + assert_eq!( + report.order_events[0].status, + fidc_core::OrderStatus::Canceled + ); + assert!( + report.order_events[0] + .reason + .contains("open at or above upper limit") + ); +} + #[test] fn broker_applies_price_ratio_slippage_on_snapshot_fills() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();