From f45a5fd0a71208f4133cc2095669f004a78bce8f Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 3 Jul 2026 21:30:06 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90next-open=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E9=A3=8E=E6=8E=A7=E5=9B=9E=E5=BD=92=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/engine.rs | 175 ++++++++++++++++++++++++++++----- 1 file changed, 151 insertions(+), 24 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index a304e08..7959c08 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3948,6 +3948,7 @@ mod tests { }; use crate::events::OrderStatus; use crate::instrument::Instrument; + use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; use crate::scheduler::{ScheduleRule, ScheduleStage}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; @@ -4109,6 +4110,20 @@ mod tests { } } + fn st_candidate(date: NaiveDate) -> CandidateEligibility { + CandidateEligibility { + is_st: true, + ..candidate(date) + } + } + + fn one_yuan_candidate(date: NaiveDate) -> CandidateEligibility { + CandidateEligibility { + is_one_yuan: true, + ..candidate(date) + } + } + fn benchmark(date: NaiveDate) -> BenchmarkSnapshot { BenchmarkSnapshot { date, @@ -4131,24 +4146,44 @@ mod tests { ) } + fn default_instrument() -> Instrument { + Instrument { + symbol: SYMBOL.to_string(), + name: "Test Stock".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + } + } + fn dataset_with( first_market: DailyMarketSnapshot, second_market: DailyMarketSnapshot, first_candidate: CandidateEligibility, second_candidate: CandidateEligibility, + ) -> DataSet { + dataset_with_instrument( + default_instrument(), + first_market, + second_market, + first_candidate, + second_candidate, + ) + } + + fn dataset_with_instrument( + instrument: Instrument, + first_market: DailyMarketSnapshot, + second_market: DailyMarketSnapshot, + first_candidate: CandidateEligibility, + second_candidate: CandidateEligibility, ) -> DataSet { let first = first_market.date; let second = second_market.date; DataSet::from_components( - vec![Instrument { - symbol: SYMBOL.to_string(), - name: "Test Stock".to_string(), - board: "SZ".to_string(), - round_lot: 100, - listed_at: Some(d(2020, 1, 1)), - delisted_at: None, - status: "active".to_string(), - }], + vec![instrument], vec![first_market, second_market], vec![factor(first), factor(second)], vec![first_candidate, second_candidate], @@ -4194,16 +4229,32 @@ mod tests { } fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult { - let first = d(2025, 1, 2); - let broker = BrokerSimulator::new_with_execution_price( + run_scheduled_next_open_with_dataset_and_broker( + dataset, + scheduled_next_open_broker(FidcRiskControlConfig::default()), + ) + } + + fn scheduled_next_open_broker( + risk_config: FidcRiskControlConfig, + ) -> BrokerSimulator { + BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_matching_type(MatchingType::NextBarOpen) + .with_risk_config(risk_config) .with_volume_limit(false) .with_liquidity_limit(false) - .with_inactive_limit(false); + .with_inactive_limit(false) + } + + fn run_scheduled_next_open_with_dataset_and_broker( + dataset: DataSet, + broker: BrokerSimulator, + ) -> super::BacktestResult { + let first = d(2025, 1, 2); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), @@ -4226,6 +4277,16 @@ mod tests { .expect("backtest run") } + fn assert_next_open_canceled_with_reason(result: &super::BacktestResult, reason: &str) { + let execution_date = d(2025, 1, 3); + assert!(result.fills.is_empty()); + assert!(result.order_events.iter().any(|event| { + event.date == execution_date + && matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected) + && event.reason.contains(reason) + })); + } + #[test] fn current_bar_close_uses_decision_day_close_for_fill() { let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0); @@ -4276,6 +4337,22 @@ mod tests { assert_eq!(result.fills[0].price, 12.0); } + #[test] + fn next_bar_open_execution_risk_ignores_decision_day_st_state() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let result = run_scheduled_next_open_with_dataset(dataset_with( + market(first, 10.0, 11.5), + market(second, 12.0, 99.0), + st_candidate(first), + candidate(second), + )); + + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].date, second); + assert_eq!(result.fills[0].price, 12.0); + } + #[test] fn next_bar_open_execution_risk_rejects_execution_day_paused_state() { let first = d(2025, 1, 2); @@ -4287,12 +4364,7 @@ mod tests { candidate_with_state(second, true, true), )); - assert!(result.fills.is_empty()); - assert!(result.order_events.iter().any(|event| { - event.date == second - && event.status == OrderStatus::Canceled - && event.reason.contains("paused") - })); + assert_next_open_canceled_with_reason(&result, "paused"); } #[test] @@ -4306,11 +4378,66 @@ mod tests { candidate(second), )); - assert!(result.fills.is_empty()); - assert!(result.order_events.iter().any(|event| { - event.date == second - && event.status == OrderStatus::Canceled - && event.reason.contains("open at or above upper limit") - })); + assert_next_open_canceled_with_reason(&result, "open at or above upper limit"); + } + + #[test] + fn next_bar_open_execution_risk_rejects_execution_day_st_state() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let result = run_scheduled_next_open_with_dataset(dataset_with( + market(first, 10.0, 11.5), + market(second, 12.0, 99.0), + candidate(first), + st_candidate(second), + )); + + assert_next_open_canceled_with_reason(&result, "st"); + } + + #[test] + fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let result = run_scheduled_next_open_with_dataset(dataset_with( + market(first, 10.0, 11.5), + market(second, 12.0, 99.0), + candidate(first), + one_yuan_candidate(second), + )); + + assert_next_open_canceled_with_reason(&result, "one_yuan"); + } + + #[test] + fn next_bar_open_execution_risk_rejects_execution_day_delisted_state() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let mut instrument = default_instrument(); + instrument.delisted_at = Some(second); + let result = run_scheduled_next_open_with_dataset(dataset_with_instrument( + instrument, + market(first, 10.0, 11.5), + market(second, 12.0, 99.0), + candidate(first), + candidate(second), + )); + + assert_next_open_canceled_with_reason(&result, "inactive_or_delisted"); + } + + #[test] + fn next_bar_open_execution_risk_rejects_blacklisted_buy_on_execution_day() { + let mut risk_config = FidcRiskControlConfig::default(); + risk_config + .static_rules + .blacklisted_symbols + .insert(SYMBOL.to_string()); + let result = run_scheduled_next_open_with_dataset_and_broker( + dataset(), + scheduled_next_open_broker(risk_config), + ); + + assert_next_open_canceled_with_reason(&result, "blacklisted"); } }