From 2f61bd8e57b61031ca5db71c53271c64e800ca2c Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 5 Jul 2026 10:41:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dnext-open=E6=B6=A8=E8=B7=8C?= =?UTF-8?q?=E5=81=9C=E9=A3=8E=E6=8E=A7=E4=BB=B7=E6=A0=BC=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 248 +++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index b48f0bd..4ba0b48 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -297,6 +297,7 @@ impl BrokerSimulator { pub fn with_matching_type(mut self, matching_type: MatchingType) -> Self { self.matching_type = matching_type; + self.execution_price_field = execution_price_field_from_matching_type(matching_type); self } @@ -5801,6 +5802,19 @@ fn matching_type_from_price_field(field: PriceField) -> MatchingType { } } +fn execution_price_field_from_matching_type(matching_type: MatchingType) -> PriceField { + match matching_type { + MatchingType::OpenAuction => PriceField::DayOpen, + MatchingType::CurrentBarClose => PriceField::Close, + MatchingType::NextBarOpen => PriceField::Open, + MatchingType::MinuteLast + | MatchingType::MinuteBestOwn + | MatchingType::MinuteBestCounterparty + | MatchingType::Vwap + | MatchingType::Twap => PriceField::Last, + } +} + fn merge_partial_fill_reason(current: Option, next: Option<&str>) -> Option { match (current, next) { (Some(existing), Some(next_reason)) if !existing.contains(next_reason) => { @@ -6153,6 +6167,119 @@ mod tests { ); } + #[test] + fn next_open_buy_limit_risk_uses_open_not_close() { + let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut execution_snapshot = dated_limit_test_snapshot(execution_date); + execution_snapshot.open = 10.0; + execution_snapshot.day_open = 10.0; + execution_snapshot.close = execution_snapshot.upper_limit; + execution_snapshot.last_price = execution_snapshot.upper_limit; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![execution_snapshot], + Vec::new(), + vec![dated_limit_test_candidate( + execution_date, + false, + false, + true, + true, + )], + vec![dated_limit_test_benchmark(execution_date)], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(20_000.0); + + let report = broker + .execute( + execution_date, + &mut portfolio, + &data, + &next_open_buy_decision(), + ) + .expect("execute next open buy"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].price, 10.0); + assert_eq!( + portfolio + .position("000001.SZ") + .map(|position| position.quantity), + Some(100) + ); + } + + #[test] + fn next_open_buy_limit_risk_rejects_open_upper_limit_even_when_close_is_clean() { + let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut execution_snapshot = dated_limit_test_snapshot(execution_date); + execution_snapshot.open = execution_snapshot.upper_limit; + execution_snapshot.day_open = execution_snapshot.upper_limit; + execution_snapshot.close = 10.0; + execution_snapshot.last_price = 10.0; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![execution_snapshot], + Vec::new(), + vec![dated_limit_test_candidate( + execution_date, + false, + false, + true, + true, + )], + vec![dated_limit_test_benchmark(execution_date)], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(20_000.0); + + let report = broker + .execute( + execution_date, + &mut portfolio, + &data, + &next_open_buy_decision(), + ) + .expect("execute next open buy"); + + assert!(report.fill_events.is_empty()); + assert!(portfolio.position("000001.SZ").is_none()); + let rejected_order = report + .order_events + .iter() + .find(|event| event.side == OrderSide::Buy) + .expect("buy order event"); + assert_eq!(rejected_order.status, OrderStatus::Canceled); + assert!( + rejected_order + .reason + .contains("open at or above upper limit"), + "{}", + rejected_order.reason + ); + } + #[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"); @@ -6267,6 +6394,127 @@ mod tests { ); } + #[test] + fn next_open_sell_limit_risk_uses_open_not_close() { + 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_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut execution_snapshot = dated_limit_test_snapshot(execution_date); + execution_snapshot.open = 10.0; + execution_snapshot.day_open = 10.0; + execution_snapshot.close = execution_snapshot.lower_limit; + execution_snapshot.last_price = execution_snapshot.lower_limit; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![execution_snapshot], + Vec::new(), + vec![dated_limit_test_candidate( + execution_date, + false, + false, + true, + true, + )], + vec![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].price, 10.0); + assert!(portfolio.position("000001.SZ").is_none()); + } + + #[test] + fn next_open_sell_limit_risk_rejects_open_lower_limit_even_when_close_is_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_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::NextBarOpen) + .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.close = 10.0; + execution_snapshot.last_price = 10.0; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![execution_snapshot], + Vec::new(), + vec![dated_limit_test_candidate( + execution_date, + false, + false, + true, + true, + )], + vec![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.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();