diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 4e908ee..68a4bdf 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use chrono::NaiveDate; use serde::Serialize; @@ -104,6 +104,7 @@ pub struct BacktestEngine { subscriptions: BTreeSet, futures_account: Option, next_futures_order_id: u64, + futures_expirations: BTreeMap>, } impl BacktestEngine { @@ -124,6 +125,7 @@ impl BacktestEngine { subscriptions: BTreeSet::new(), futures_account: None, next_futures_order_id: 1, + futures_expirations: BTreeMap::new(), } } @@ -149,6 +151,27 @@ impl BacktestEngine { self.futures_account.as_mut() } + pub fn with_futures_expiration( + mut self, + date: NaiveDate, + symbol: impl Into, + settlement_price: f64, + ) -> Self { + self.futures_expirations + .entry(date) + .or_default() + .insert(symbol.into(), settlement_price); + self + } + + pub fn with_futures_expirations( + mut self, + expirations: BTreeMap>, + ) -> Self { + self.futures_expirations = expirations; + self + } + pub fn process_event_bus_mut(&mut self) -> &mut ProcessEventBus { &mut self.process_event_bus } @@ -1412,6 +1435,8 @@ where &mut settlement_decision, &mut directive_report, )?; + let futures_expiration_report = self.settle_futures_expirations(execution_date); + merge_broker_report(&mut directive_report, futures_expiration_report); let dynamic_universe_snapshot = self.dynamic_universe.clone(); let subscriptions_snapshot = self.subscriptions.clone(); let management_fee_report = self.apply_management_fee( @@ -1841,6 +1866,26 @@ where report } + fn settle_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport { + let mut report = BrokerExecutionReport::default(); + let Some(expirations) = self.futures_expirations.remove(&date) else { + return report; + }; + let Some(account) = self.futures_account.as_mut() else { + report.diagnostics.push(format!( + "futures_expiration_skipped date={date} reason=no_future_account count={}", + expirations.len() + )); + return report; + }; + for (symbol, settlement_price) in expirations { + let futures_report = + account.expire_contract(date, &symbol, settlement_price, "data_driven_expiration"); + merge_futures_report(&mut report, futures_report); + } + report + } + fn apply_management_fee( &mut self, execution_date: NaiveDate, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index d36d860..91d687a 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -30,6 +30,73 @@ fn bool_flags(values: Vec) -> String { .join(",") } +fn single_day_anchor_data(date: NaiveDate) -> DataSet { + DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Anchor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-02 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 1_000_000, + bid1_volume: 1_000_000, + ask1_volume: 1_000_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.89, + lower_limit: 8.91, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 100.0, + free_float_cap_bn: 80.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset") +} + struct HookProbeStrategy { log: Rc>>, } @@ -983,6 +1050,54 @@ fn engine_executes_futures_order_intents_against_future_account() { assert!((futures_account.cash() - 355_988.0).abs() < 1e-6); } +#[test] +fn engine_settles_configured_futures_expiration_at_settlement() { + let date = d(2025, 1, 2); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + single_day_anchor_data(date), + FuturesOrderStrategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_futures_initial_cash(500_000.0) + .with_futures_expiration(date, "IF2501", 4010.0); + + let result = engine.run().expect("backtest succeeds"); + + assert_eq!( + result + .order_events + .iter() + .filter(|event| event.symbol == "IF2501" && event.status == OrderStatus::Filled) + .count(), + 2 + ); + let futures_account = engine.futures_account().expect("future account"); + assert!( + futures_account + .position("IF2501", FuturesDirection::Long) + .is_none() + ); + assert!((futures_account.total_cash() - 502_988.0).abs() < 1e-6); + assert!(result.process_events.iter().any(|event| { + event.symbol.as_deref() == Some("IF2501") + && event.kind == ProcessEventKind::Trade + && event.detail.contains("4010") + })); +} + #[test] fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() { let date = d(2025, 1, 2); diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index 2c1fac4..f2f92e5 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -96,7 +96,9 @@ current alignment pass. loop for account-level open/close execution - [x] standalone futures expiration settlement closes all long/short contract positions at settlement price -- [ ] futures intraday matching integration and data-driven expiration schedule +- [x] data-driven futures expiration schedule in `BacktestEngine` settlement + phase +- [ ] futures intraday matching integration ## Execution Order @@ -117,4 +119,4 @@ account runtime view, core Portfolio fields, deposit/withdraw, financing liability APIs, management-fee callbacks, stock account accessors, and the standalone futures account/order execution model plus generic engine runtime account visibility and account-level futures order intents; next gap is adding -futures intraday matching and a data-driven expiration schedule. +futures intraday matching semantics.