diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 4fe287e..0f223cc 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -4077,6 +4077,63 @@ mod tests { } } + #[derive(Debug)] + struct ScheduledSameDayRebuyStrategy { + rule: ScheduleRule, + buy_decision_date: NaiveDate, + rebuy_decision_date: NaiveDate, + } + + impl Strategy for ScheduledSameDayRebuyStrategy { + fn name(&self) -> &str { + "scheduled_same_day_rebuy" + } + + 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); + 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: "same_day_rebuy_setup_buy".to_string(), + }], + ..StrategyDecision::default() + }); + } + if ctx.decision_date == self.rebuy_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: "same_day_rebuy_sell".to_string(), + }, + OrderIntent::Shares { + symbol: SYMBOL.to_string(), + quantity: 100, + reason: "same_day_rebuy_buy".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") } @@ -4406,6 +4463,35 @@ mod tests { .expect("backtest run") } + fn run_scheduled_same_day_rebuy_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, + ScheduledSameDayRebuyStrategy { + rule: ScheduleRule::daily("daily_same_day_rebuy", ScheduleStage::OnDay), + buy_decision_date: first, + rebuy_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()); @@ -4704,4 +4790,49 @@ mod tests { assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit"); } + + #[test] + fn next_bar_open_same_day_rebuy_uses_actual_execution_date() { + 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_same_day_rebuy_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(fourth, 13.0, 13.5), + ], + vec![ + candidate(first), + candidate(second), + candidate(third), + candidate(fourth), + ], + ), + scheduled_next_open_broker(FidcRiskControlConfig::default()), + ); + + assert!(result.fills.iter().any(|fill| { + fill.side == OrderSide::Buy + && fill.date == second + && fill.reason == "same_day_rebuy_setup_buy" + })); + assert!(result.fills.iter().any(|fill| { + fill.side == OrderSide::Sell + && fill.date == fourth + && fill.reason == "same_day_rebuy_sell" + })); + assert!(result.order_events.iter().any(|event| { + event.date == fourth + && event.side == OrderSide::Buy + && matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected) + && event.reason.contains("same_day_rebuy_forbidden") + })); + assert!(!result.order_events.iter().any(|event| { + event.date == third && event.reason.contains("same_day_rebuy_forbidden") + })); + } }