From 339f85c27bb9c0abb325642c480a0f6b80b1096d Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 4 Jul 2026 21:45:23 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90next-open=E5=8D=96=E5=87=BA?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=97=A5=E9=A3=8E=E6=8E=A7=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 125 +++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index fcd2f26..cd07a0b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -5814,6 +5814,17 @@ mod tests { } } + 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 minute_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) @@ -5953,6 +5964,120 @@ mod tests { ); } + #[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 current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() { let mut snapshot = limit_test_snapshot();