diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 7959c08..4fe287e 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3946,7 +3946,7 @@ mod tests { BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, PriceField, }; - use crate::events::OrderStatus; + use crate::events::{OrderSide, OrderStatus}; use crate::instrument::Instrument; use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; @@ -4023,6 +4023,60 @@ mod tests { } } + #[derive(Debug)] + struct ScheduledRoundTripStrategy { + rule: ScheduleRule, + buy_decision_date: NaiveDate, + sell_decision_date: NaiveDate, + } + + impl Strategy for ScheduledRoundTripStrategy { + fn name(&self) -> &str { + "scheduled_round_trip" + } + + fn schedule_rules(&self) -> Vec { + vec![self.rule.clone()] + } + + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + rule: &ScheduleRule, + ) -> Result { + assert_eq!(rule.name, self.rule.name); + assert_eq!( + ctx.current_datetime().map(|value| value.date()), + Some(ctx.decision_date) + ); + if ctx.decision_date == self.buy_decision_date + && ctx.portfolio.position(SYMBOL).is_none() + { + return Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.to_string(), + quantity: 100, + reason: "round_trip_buy".to_string(), + }], + ..StrategyDecision::default() + }); + } + if ctx.decision_date == self.sell_decision_date { + if let Some(position) = ctx.portfolio.position(SYMBOL) { + return Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.to_string(), + quantity: -(position.quantity as i32), + reason: "round_trip_sell".to_string(), + }], + ..StrategyDecision::default() + }); + } + } + Ok(StrategyDecision::default()) + } + } + fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } @@ -4069,6 +4123,18 @@ mod tests { } } + fn market_with_volume( + date: NaiveDate, + open: f64, + close: f64, + volume: u64, + ) -> DailyMarketSnapshot { + DailyMarketSnapshot { + volume, + ..market(date, open, close) + } + } + fn factor(date: NaiveDate) -> DailyFactorSnapshot { DailyFactorSnapshot { date, @@ -4110,6 +4176,18 @@ mod tests { } } + fn candidate_with_sell_state( + date: NaiveDate, + is_paused: bool, + allow_sell: bool, + ) -> CandidateEligibility { + CandidateEligibility { + is_paused, + allow_sell, + ..candidate(date) + } + } + fn st_candidate(date: NaiveDate) -> CandidateEligibility { CandidateEligibility { is_st: true, @@ -4192,6 +4270,28 @@ mod tests { .expect("dataset") } + fn dataset_from_market_and_candidates( + markets: Vec, + candidates: Vec, + ) -> DataSet { + let factors = markets + .iter() + .map(|market| factor(market.date)) + .collect::>(); + let benchmarks = markets + .iter() + .map(|market| benchmark(market.date)) + .collect::>(); + DataSet::from_components( + vec![default_instrument()], + markets, + factors, + candidates, + benchmarks, + ) + .expect("dataset") + } + fn run_with_matching( matching_type: MatchingType, execution_price_field: PriceField, @@ -4277,6 +4377,35 @@ mod tests { .expect("backtest run") } + fn run_scheduled_round_trip_next_open_with_dataset_and_broker( + dataset: DataSet, + broker: BrokerSimulator, + ) -> super::BacktestResult { + let first = d(2025, 1, 2); + let third = d(2025, 1, 6); + let config = BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000852.SH".to_string(), + start_date: Some(first), + end_date: Some(d(2025, 1, 7)), + decision_lag_trading_days: 1, + execution_price_field: PriceField::Open, + }; + + BacktestEngine::new( + dataset, + ScheduledRoundTripStrategy { + rule: ScheduleRule::daily("daily_round_trip", ScheduleStage::OnDay), + buy_decision_date: first, + sell_decision_date: third, + }, + broker, + config, + ) + .run() + .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()); @@ -4287,6 +4416,18 @@ mod tests { })); } + fn assert_round_trip_sell_canceled_with_reason(result: &super::BacktestResult, reason: &str) { + let execution_date = d(2025, 1, 7); + assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Buy)); + assert!(result.fills.iter().all(|fill| fill.side != OrderSide::Sell)); + assert!(result.order_events.iter().any(|event| { + event.date == execution_date + && event.side == OrderSide::Sell + && 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); @@ -4440,4 +4581,127 @@ mod tests { assert_next_open_canceled_with_reason(&result, "blacklisted"); } + + #[test] + fn next_bar_open_sell_risk_ignores_decision_day_paused_and_lower_limit_state() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let third = d(2025, 1, 6); + let fourth = d(2025, 1, 7); + let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( + dataset_from_market_and_candidates( + vec![ + market(first, 10.0, 10.5), + market(second, 11.0, 11.5), + market_with_state(third, 9.0, 9.0, true, 20.0, 9.0), + market(fourth, 12.0, 12.2), + ], + vec![ + candidate(first), + candidate(second), + candidate_with_sell_state(third, true, false), + candidate(fourth), + ], + ), + scheduled_next_open_broker(FidcRiskControlConfig::default()), + ); + + let sell_fill = result + .fills + .iter() + .find(|fill| fill.side == OrderSide::Sell) + .expect("sell should execute on actual execution date"); + assert_eq!(sell_fill.date, fourth); + assert_eq!(sell_fill.price, 12.0); + } + + #[test] + fn next_bar_open_sell_risk_rejects_execution_day_lower_limit_state() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let third = d(2025, 1, 6); + let fourth = d(2025, 1, 7); + let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( + dataset_from_market_and_candidates( + vec![ + market(first, 10.0, 10.5), + market(second, 11.0, 11.5), + market(third, 12.0, 12.5), + market_with_state(fourth, 9.0, 9.0, false, 20.0, 9.0), + ], + vec![ + candidate(first), + candidate(second), + candidate(third), + candidate(fourth), + ], + ), + scheduled_next_open_broker(FidcRiskControlConfig::default()), + ); + + assert_round_trip_sell_canceled_with_reason(&result, "open at or below lower limit"); + } + + #[test] + fn next_bar_open_sell_volume_limit_ignores_decision_day_zero_volume() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let third = d(2025, 1, 6); + let fourth = d(2025, 1, 7); + let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) + .with_volume_limit(true) + .with_volume_percent(0.25); + let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( + dataset_from_market_and_candidates( + vec![ + market(first, 10.0, 10.5), + market(second, 11.0, 11.5), + market_with_volume(third, 12.0, 12.5, 0), + market(fourth, 12.0, 12.2), + ], + vec![ + candidate(first), + candidate(second), + candidate(third), + candidate(fourth), + ], + ), + broker, + ); + + assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Buy)); + assert!(result.fills.iter().any(|fill| { + fill.side == OrderSide::Sell && fill.date == fourth && (fill.price - 12.0).abs() < 1e-9 + })); + } + + #[test] + fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let third = d(2025, 1, 6); + let fourth = d(2025, 1, 7); + let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) + .with_volume_limit(true) + .with_volume_percent(0.25); + let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( + dataset_from_market_and_candidates( + vec![ + market(first, 10.0, 10.5), + market(second, 11.0, 11.5), + market(third, 12.0, 12.5), + market_with_volume(fourth, 12.0, 12.2, 0), + ], + vec![ + candidate(first), + candidate(second), + candidate(third), + candidate(fourth), + ], + ), + broker, + ); + + assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit"); + } }