From 895aee1388f37d33eddb3d50e71cb5e356b14f24 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 21:51:45 -0700 Subject: [PATCH] Add futures depth matching and mod loader --- crates/fidc-core/src/data.rs | 136 +++++++++++++- crates/fidc-core/src/engine.rs | 84 ++++++++- crates/fidc-core/src/event_bus.rs | 71 ++++++++ crates/fidc-core/src/lib.rs | 6 +- crates/fidc-core/src/strategy_ai.rs | 7 +- crates/fidc-core/tests/engine_hooks.rs | 238 ++++++++++++++++++++++++- docs/rqalpha-gap-roadmap.md | 15 +- 7 files changed, 537 insertions(+), 20 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index f0bdcaf..061fe65 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -261,6 +261,43 @@ pub struct IntradayExecutionQuote { pub trading_phase: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntradayOrderBookDepthLevel { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + #[serde(with = "datetime_format")] + pub timestamp: NaiveDateTime, + pub level: u8, + pub bid_price: f64, + pub bid_volume: u64, + pub ask_price: f64, + pub ask_volume: u64, +} + +impl IntradayOrderBookDepthLevel { + pub fn executable_price(&self, side: crate::events::OrderSide) -> Option { + match side { + crate::events::OrderSide::Buy if self.ask_price.is_finite() && self.ask_price > 0.0 => { + Some(self.ask_price) + } + crate::events::OrderSide::Sell + if self.bid_price.is_finite() && self.bid_price > 0.0 => + { + Some(self.bid_price) + } + _ => None, + } + } + + pub fn executable_volume(&self, side: crate::events::OrderSide) -> u64 { + match side { + crate::events::OrderSide::Buy => self.ask_volume, + crate::events::OrderSide::Sell => self.bid_volume, + } + } +} + impl IntradayExecutionQuote { pub fn buy_price(&self) -> Option { if self.ask1.is_finite() && self.ask1 > 0.0 { @@ -661,6 +698,7 @@ pub struct DataSet { candidate_index: HashMap<(NaiveDate, String), CandidateEligibility>, corporate_actions_by_date: BTreeMap>, execution_quotes_index: HashMap<(NaiveDate, String), Vec>, + order_book_depth_index: HashMap<(NaiveDate, String), Vec>, benchmark_by_date: BTreeMap, market_series_by_symbol: HashMap, benchmark_series_cache: BenchmarkPriceSeries, @@ -694,7 +732,13 @@ impl DataSet { } else { Vec::new() }; - Self::from_components_with_actions_quotes_and_futures( + let order_book_depth_path = path.join("order_book_depth.csv"); + let order_book_depth = if order_book_depth_path.exists() { + read_order_book_depth(&order_book_depth_path)? + } else { + Vec::new() + }; + Self::from_components_with_actions_quotes_futures_and_depth( instruments, market, factors, @@ -703,6 +747,7 @@ impl DataSet { corporate_actions, execution_quotes, futures_params, + order_book_depth, ) } @@ -730,7 +775,13 @@ impl DataSet { } else { Vec::new() }; - Self::from_components_with_actions_quotes_and_futures( + let order_book_depth_dir = path.join("order_book_depth"); + let order_book_depth = if order_book_depth_dir.exists() { + read_partitioned_dir(&order_book_depth_dir, read_order_book_depth)? + } else { + Vec::new() + }; + Self::from_components_with_actions_quotes_futures_and_depth( instruments, market, factors, @@ -739,6 +790,7 @@ impl DataSet { corporate_actions, execution_quotes, futures_params, + order_book_depth, ) } @@ -809,6 +861,30 @@ impl DataSet { corporate_actions: Vec, execution_quotes: Vec, futures_params: Vec, + ) -> Result { + Self::from_components_with_actions_quotes_futures_and_depth( + instruments, + market, + factors, + candidates, + benchmarks, + corporate_actions, + execution_quotes, + futures_params, + Vec::new(), + ) + } + + pub fn from_components_with_actions_quotes_futures_and_depth( + instruments: Vec, + market: Vec, + factors: Vec, + candidates: Vec, + benchmarks: Vec, + corporate_actions: Vec, + execution_quotes: Vec, + futures_params: Vec, + order_book_depth: Vec, ) -> Result { let benchmark_code = collect_benchmark_code(&benchmarks)?; let calendar = TradingCalendar::new(benchmarks.iter().map(|item| item.date).collect()); @@ -837,6 +913,7 @@ impl DataSet { .collect::>(); let corporate_actions_by_date = group_by_date(corporate_actions, |item| item.date); let execution_quotes_index = build_execution_quote_index(execution_quotes); + let order_book_depth_index = build_order_book_depth_index(order_book_depth); let benchmark_by_date = benchmarks .into_iter() @@ -860,6 +937,7 @@ impl DataSet { candidate_index, corporate_actions_by_date, execution_quotes_index, + order_book_depth_index, benchmark_by_date, market_series_by_symbol, benchmark_series_cache, @@ -936,6 +1014,17 @@ impl DataSet { .unwrap_or(&[]) } + pub fn order_book_depth_on( + &self, + date: NaiveDate, + symbol: &str, + ) -> &[IntradayOrderBookDepthLevel] { + self.order_book_depth_index + .get(&(date, symbol.to_string())) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + pub fn execution_quotes_on_date(&self, date: NaiveDate) -> Vec { let mut quotes = self .execution_quotes_index @@ -1978,6 +2067,27 @@ fn read_execution_quotes(path: &Path) -> Result, Dat Ok(quotes) } +fn read_order_book_depth(path: &Path) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut levels = Vec::new(); + for row in rows { + levels.push(IntradayOrderBookDepthLevel { + date: row.parse_date(0)?, + symbol: row.get(1)?.to_string(), + timestamp: row.parse_datetime(2)?, + level: row + .parse_optional_u32(3) + .unwrap_or(1) + .clamp(1, u8::MAX as u32) as u8, + bid_price: row.parse_optional_f64(4).unwrap_or_default(), + bid_volume: row.parse_optional_u64(5).unwrap_or_default(), + ask_price: row.parse_optional_f64(6).unwrap_or_default(), + ask_volume: row.parse_optional_u64(7).unwrap_or_default(), + }); + } + Ok(levels) +} + fn read_futures_trading_parameters( path: &Path, ) -> Result, DataSetError> { @@ -2329,6 +2439,28 @@ fn build_execution_quote_index( grouped } +fn build_order_book_depth_index( + order_book_depth: Vec, +) -> HashMap<(NaiveDate, String), Vec> { + let mut grouped = HashMap::<(NaiveDate, String), Vec>::new(); + for level in order_book_depth { + grouped + .entry((level.date, level.symbol.clone())) + .or_default() + .push(level); + } + + for levels in grouped.values_mut() { + levels.sort_by(|left, right| { + left.timestamp + .cmp(&right.timestamp) + .then(left.level.cmp(&right.level)) + }); + } + + grouped +} + fn build_eligible_universe( factor_by_date: &BTreeMap>, candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>, diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 8848b94..f1137aa 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -7,7 +7,7 @@ use thiserror::Error; use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; -use crate::event_bus::{BacktestProcessMod, ProcessEventBus}; +use crate::event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, @@ -401,6 +401,22 @@ impl BacktestEngine { { self.process_event_bus.install_mod(module); } + + pub fn install_process_mod_loader( + &mut self, + loader: &mut BacktestProcessModLoader, + ) -> Vec { + self.process_event_bus.install_mod_loader(loader) + } + + pub fn install_enabled_process_mods( + &mut self, + loader: &mut BacktestProcessModLoader, + enabled_names: &[String], + ) -> Vec { + self.process_event_bus + .install_enabled_mods(loader, enabled_names) + } } impl BacktestEngine @@ -1119,6 +1135,15 @@ where intent: &FuturesOrderIntent, ) -> Option<(f64, u32)> { let snapshot = self.data.market(date, &intent.symbol); + if matches!( + self.broker.matching_type(), + MatchingType::NextTickBestCounterparty | MatchingType::CounterpartyOffer + ) { + let depth = self.data.order_book_depth_on(date, &intent.symbol); + if !depth.is_empty() { + return self.resolve_futures_depth_fill(date, intent, snapshot); + } + } let quotes = self.data.execution_quotes_on(date, &intent.symbol); for quote in quotes { let price = match self.broker.matching_type() { @@ -1172,6 +1197,63 @@ where None } + fn resolve_futures_depth_fill( + &self, + date: NaiveDate, + intent: &FuturesOrderIntent, + snapshot: Option<&crate::data::DailyMarketSnapshot>, + ) -> Option<(f64, u32)> { + let depth = self.data.order_book_depth_on(date, &intent.symbol); + let mut cursor = 0usize; + while cursor < depth.len() { + let timestamp = depth[cursor].timestamp; + let start = cursor; + while cursor < depth.len() && depth[cursor].timestamp == timestamp { + cursor += 1; + } + let mut levels = depth[start..cursor].iter().collect::>(); + levels.sort_by(|left, right| left.level.cmp(&right.level)); + + let mut filled_quantity = 0_u32; + let mut gross_amount = 0.0_f64; + for level in levels { + let Some(price) = level.executable_price(intent.side()) else { + continue; + }; + let can_trade = if let Some(snapshot) = snapshot { + self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price) + } else { + futures_limit_satisfied(intent.side(), price, intent.limit_price) + }; + if !can_trade { + if intent.limit_price.is_some() { + break; + } + continue; + } + let available_quantity = + level.executable_volume(intent.side()).min(u32::MAX as u64) as u32; + if available_quantity == 0 { + continue; + } + let remaining = intent.quantity.saturating_sub(filled_quantity); + if remaining == 0 { + break; + } + let take_quantity = remaining.min(available_quantity); + gross_amount += price * take_quantity as f64; + filled_quantity += take_quantity; + if filled_quantity >= intent.quantity { + break; + } + } + if filled_quantity > 0 { + return Some((gross_amount / filled_quantity as f64, filled_quantity)); + } + } + None + } + fn futures_price_can_trade( &self, snapshot: &crate::data::DailyMarketSnapshot, diff --git a/crates/fidc-core/src/event_bus.rs b/crates/fidc-core/src/event_bus.rs index 13ddadb..3a880fa 100644 --- a/crates/fidc-core/src/event_bus.rs +++ b/crates/fidc-core/src/event_bus.rs @@ -9,6 +9,65 @@ pub trait BacktestProcessMod { fn install(&mut self, bus: &mut ProcessEventBus); } +#[derive(Default)] +pub struct BacktestProcessModLoader { + modules: Vec>, +} + +impl BacktestProcessModLoader { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, module: M) + where + M: BacktestProcessMod + 'static, + { + self.modules.push(Box::new(module)); + } + + pub fn module_names(&self) -> Vec { + self.modules + .iter() + .map(|module| module.name().to_string()) + .collect() + } + + pub fn install_all(&mut self, bus: &mut ProcessEventBus) -> Vec { + self.modules + .iter_mut() + .map(|module| { + let name = module.name().to_string(); + module.install(bus); + name + }) + .collect() + } + + pub fn install_enabled( + &mut self, + bus: &mut ProcessEventBus, + enabled_names: &[String], + ) -> Vec { + if enabled_names.is_empty() { + return self.install_all(bus); + } + self.modules + .iter_mut() + .filter(|module| { + enabled_names + .iter() + .any(|name| name.eq_ignore_ascii_case(module.name())) + }) + .map(|module| { + let name = module.name().to_string(); + module.install(bus); + name + }) + .collect() + } +} + #[derive(Default)] pub struct ProcessEventBus { listeners: BTreeMap>, @@ -54,6 +113,18 @@ impl ProcessEventBus { module.install(self); } + pub fn install_mod_loader(&mut self, loader: &mut BacktestProcessModLoader) -> Vec { + loader.install_all(self) + } + + pub fn install_enabled_mods( + &mut self, + loader: &mut BacktestProcessModLoader, + enabled_names: &[String], + ) -> Vec { + loader.install_enabled(self, enabled_names) + } + pub fn publish(&mut self, event: &ProcessEvent) { if let Some(listeners) = self.listeners.get_mut(&event.kind) { for listener in listeners { diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index e302e81..c9821af 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -22,15 +22,15 @@ pub use cost::{ChinaAShareCostModel, CostModel, TradingCost}; pub use data::{ BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot, DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord, - EligibleUniverseSnapshot, FactorValue, IntradayExecutionQuote, PriceBar, PriceField, - SecuritiesMarginRecord, SplitRecord, YieldCurvePoint, + EligibleUniverseSnapshot, FactorValue, IntradayExecutionQuote, IntradayOrderBookDepthLevel, + PriceBar, PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint, }; pub use engine::{ AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult, DailyEquityPoint, }; -pub use event_bus::{BacktestProcessMod, ProcessEventBus}; +pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 7ed7d58..f7d1f21 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "execution.matching_type / execution.slippage".to_string(), - detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"next_tick_best_own\" | \"next_tick_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。其中 next_tick_last 使用 tick 的 last_price;next_tick_best_own / next_tick_best_counterparty 会按 L1 买一卖一近似 rqalpha 的 tick 最优价语义,counterparty_offer 当前也按 L1 对手方报价近似实现;vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 rqalpha 的最坏成交价。".to_string(), + detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"next_tick_best_own\" | \"next_tick_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。其中 next_tick_last 使用 tick 的 last_price;next_tick_best_own / next_tick_best_counterparty 会按 L1 买一卖一近似 rqalpha 的 tick 最优价语义;counterparty_offer 在存在 order_book_depth 多档盘口数据时会按真实档位逐档扫单并计算加权成交价,不存在 depth 时回退 L1 对手方报价;vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 rqalpha 的最坏成交价。".to_string(), }, ManualSection { title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(), @@ -238,6 +238,11 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { detail: "股票指标因子原表,可映射进 factors[...]。".to_string(), fields: vec![], }, + ManualFactorSource { + table: "order_book_depth.csv / order_book_depth/".to_string(), + detail: "可选多档盘口数据源,字段为 date,symbol,timestamp,level,bid_price,bid_volume,ask_price,ask_volume。存在该数据时,期货 counterparty_offer / next_tick_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(), + fields: vec![], + }, ], examples: vec![ ManualExample { diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 3f165a6..7b0c241 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -4,13 +4,14 @@ use std::rc::Rc; use chrono::{NaiveDate, NaiveDateTime}; use fidc_core::{ - BacktestConfig, BacktestEngine, BacktestProcessMod, BenchmarkSnapshot, BrokerSimulator, - CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, - DailyMarketSnapshot, DataSet, FuturesAccountState, FuturesCommissionType, FuturesContractSpec, - FuturesDirection, FuturesOrderIntent, FuturesTradingParameter, Instrument, - IntradayExecutionQuote, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, - PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, - ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, + BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader, + BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, + ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesAccountState, + FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, + FuturesTradingParameter, Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel, + MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField, + ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, + Strategy, StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -407,6 +408,37 @@ impl Strategy for FuturesLimitOrderStrategy { } } +struct FuturesDepthLimitOrderStrategy; + +impl Strategy for FuturesDepthLimitOrderStrategy { + fn name(&self) -> &str { + "futures-depth-limit-order" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date != d(2025, 1, 2) { + return Ok(StrategyDecision::default()); + } + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Futures { + intent: FuturesOrderIntent::limit_open( + "IF2501", + FuturesDirection::Long, + FuturesContractSpec::new(1.0, 0.0, 0.0), + 3, + 3990.0, + 0.0, + "sweep depth until limit", + ), + }], + ..StrategyDecision::default() + }) + } +} + struct AdvancedDataApiProbeStrategy { observed: Rc>>, } @@ -1399,6 +1431,127 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() { assert!((position.contract_multiplier - 300.0).abs() < 1e-6); } +#[test] +fn engine_sweeps_futures_order_book_depth_when_available() { + let date = d(2025, 1, 2); + let data = DataSet::from_components_with_actions_quotes_futures_and_depth( + 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(), + }, + Instrument { + symbol: "IF2501".to_string(), + name: "IF".to_string(), + board: "FUTURE".to_string(), + round_lot: 1, + listed_at: Some(d(2024, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + market_row(date, "000001.SZ", 10.0, 10.0), + market_row(date, "IF2501", 4000.0, 4000.0), + ], + vec![factor_row(date, "000001.SZ", BTreeMap::new())], + vec![candidate_row(date, "000001.SZ")], + vec![benchmark_row(date)], + Vec::new(), + Vec::new(), + vec![FuturesTradingParameter { + symbol: "IF2501".to_string(), + effective_date: Some(date), + contract_multiplier: 300.0, + long_margin_rate: 0.12, + short_margin_rate: 0.14, + commission_type: FuturesCommissionType::ByVolume, + open_commission_ratio: 2.5, + close_commission_ratio: 2.0, + close_today_commission_ratio: 3.0, + price_tick: 0.2, + }], + vec![ + IntradayOrderBookDepthLevel { + date, + symbol: "IF2501".to_string(), + timestamp: date.and_hms_opt(10, 18, 0).unwrap(), + level: 1, + bid_price: 3987.8, + bid_volume: 1, + ask_price: 3988.0, + ask_volume: 1, + }, + IntradayOrderBookDepthLevel { + date, + symbol: "IF2501".to_string(), + timestamp: date.and_hms_opt(10, 18, 0).unwrap(), + level: 2, + bid_price: 3987.6, + bid_volume: 1, + ask_price: 3990.0, + ask_volume: 1, + }, + IntradayOrderBookDepthLevel { + date, + symbol: "IF2501".to_string(), + timestamp: date.and_hms_opt(10, 18, 0).unwrap(), + level: 3, + bid_price: 3987.4, + bid_volume: 10, + ask_price: 3994.0, + ask_volume: 10, + }, + ], + ) + .expect("depth dataset"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ) + .with_matching_type(MatchingType::CounterpartyOffer); + let mut engine = BacktestEngine::new( + data, + FuturesDepthLimitOrderStrategy, + 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::Last, + }, + ) + .with_futures_initial_cash(1_000_000.0); + + let result = engine.run().expect("backtest succeeds"); + + let fill = result + .fills + .iter() + .find(|fill| fill.symbol == "IF2501") + .expect("depth futures fill"); + assert_eq!(fill.quantity, 2); + assert!((fill.price - 3989.0).abs() < 1e-6); + assert!(result.order_events.iter().any(|event| { + event.symbol == "IF2501" + && event.status == OrderStatus::PartiallyFilled + && event.filled_quantity == 2 + })); + assert!(result.order_events.iter().any(|event| { + event.symbol == "IF2501" + && event.status == OrderStatus::Pending + && event.requested_quantity == 3 + })); +} + #[test] fn strategy_context_exposes_advanced_rqdata_helpers() { let observed = Rc::new(RefCell::new(Vec::new())); @@ -2837,6 +2990,25 @@ impl BacktestProcessMod for AnyEventCountingMod { } } +struct NamedEventCountingMod { + name: &'static str, + sink: Rc>>, +} + +impl BacktestProcessMod for NamedEventCountingMod { + fn name(&self) -> &str { + self.name + } + + fn install(&mut self, bus: &mut ProcessEventBus) { + let sink = self.sink.clone(); + bus.add_any_listener(move |event: &ProcessEvent| { + sink.borrow_mut() + .push(format!("{:?}:{}", event.kind, event.detail)); + }); + } +} + #[test] fn engine_installs_process_mods_on_event_bus() { let date = d(2025, 1, 2); @@ -2874,6 +3046,58 @@ fn engine_installs_process_mods_on_event_bus() { ); } +#[test] +fn engine_installs_enabled_process_mods_from_loader() { + let date = d(2025, 1, 2); + let data = single_day_anchor_data(date); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::DayOpen, + ); + let mut engine = BacktestEngine::new( + data, + HookProbeStrategy { + log: Rc::new(RefCell::new(Vec::new())), + }, + 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::DayOpen, + }, + ); + let enabled_sink = Rc::new(RefCell::new(Vec::new())); + let disabled_sink = Rc::new(RefCell::new(Vec::new())); + let mut loader = BacktestProcessModLoader::new(); + loader.register(NamedEventCountingMod { + name: "enabled-counter", + sink: enabled_sink.clone(), + }); + loader.register(NamedEventCountingMod { + name: "disabled-counter", + sink: disabled_sink.clone(), + }); + + assert_eq!( + loader.module_names(), + vec![ + "enabled-counter".to_string(), + "disabled-counter".to_string() + ] + ); + let installed = + engine.install_enabled_process_mods(&mut loader, &["enabled-counter".to_string()]); + engine.run().expect("backtest run"); + + assert_eq!(installed, vec!["enabled-counter".to_string()]); + assert!(!enabled_sink.borrow().is_empty()); + assert!(disabled_sink.borrow().is_empty()); +} + #[test] fn engine_applies_dynamic_universe_and_subscription_directives() { let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)]; diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index 7efc147..22c7641 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -44,7 +44,7 @@ Parity gaps found by this pass and current closure state: | Priority | Gap | RQAlpha capability | Current engine state | Next implementation | | --- | --- | --- | --- | --- | -| P0 | Futures intraday matching | Bar/tick matchers support futures orders through the same broker lifecycle, matching type, slippage, price limit, liquidity limit, volume limit, partial fill, and market-close rejection semantics. | Closed for daily/open/close and tick-price futures fills, including limit checks and partial quantity handling. Full multi-level order-book sweeping remains data-dependent and intentionally not faked from L1 data. | Add true depth sweeping only when production futures tick depth exists. | +| P0 | Futures intraday matching | Bar/tick matchers support futures orders through the same broker lifecycle, matching type, slippage, price limit, liquidity limit, volume limit, partial fill, and market-close rejection semantics. | Closed for daily/open/close, tick-price futures fills, and true multi-level order-book sweeping when optional `order_book_depth` data exists. L1-only data still uses the existing L1 matcher and is not inflated into fake depth. | Extend depth fields only if production vendors expose more levels or exchange-specific fields. | | P0 | Futures open-order lifecycle | `SimulationBroker` keeps pending orders, supports `get_open_orders`, cancellation, before-trading activation, tick/bar rematching, and after-trading rejection. | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires RQAlpha's exact intermediate event names. | | P0 | Combined multi-account NAV | RQAlpha portfolio aggregates account values across stock/future accounts. | Closed. `DailyEquityPoint`, progress events, and metrics now use aggregate stock + futures initial cash and total equity. | None. | | P1 | Futures trading parameter data source | RQAlpha loads contract multiplier, margin ratios, commission type, open/close/close-today commission ratios, settlement/prev-settlement, tick size, listed/de-listed dates, and dominant contracts from data proxy. | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. | @@ -53,7 +53,7 @@ Parity gaps found by this pass and current closure state: | P1 | Frontend risk validators for futures | RQAlpha applies cash/margin, position closable, price-limit, trading-status, and self-trade validators before order submission. | Closed for zero quantity, invalid limit price, self-trade crossing risk, paused/no executable price, price-limit, margin, and close-position rejection diagnostics. | Add exchange-specific validators only as needed. | | P2 | RQData helper APIs | RQAlpha exposes `get_dividend`, `get_split`, `get_yield_curve`, `get_factor`, `get_margin_stocks`, `get_securities_margin`, `get_dominant_future`, and dominant futures price APIs. | Closed. These APIs are available through `DataSet` and `StrategyContext`; platform expressions also expose focused helpers such as `dividend_cash`, `factor_value`, `yield_curve`, `is_margin_stock`, `dominant_future`, and `dominant_future_price`. | Add more DSL aliases only when users need specific names. | | P2 | Analyzer/report parity | RQAlpha analyser can export richer trades, positions, benchmark, monthly returns, risk, and summary artifacts. | Closed for normalized trades, positions, monthly returns, risk summary, equity curve, benchmark series, metrics, and JSON report bundle via `BacktestResult::analyzer_report(_json)`. | UI/service download endpoints can serialize this report directly. | -| P3 | Mod/config/plugin architecture | RQAlpha has pluggable mods, event bus extension points, and many config toggles. | Partially closed with a lightweight `BacktestProcessMod` interface on top of `ProcessEventBus`; this supports event-driven extensions without recreating RQAlpha's global mod loader. | Add concrete production mods/toggles as requirements appear. | +| P3 | Mod/config/plugin architecture | RQAlpha has pluggable mods, event bus extension points, and many config toggles. | Closed for a lightweight engine-native model: `BacktestProcessMod`, `BacktestProcessModLoader`, enabled-name installation, and event-bus lifecycle hooks. It intentionally avoids RQAlpha's Python global mod loader. | Add concrete production mods/toggles as requirements appear. | ## Remaining Gaps @@ -149,6 +149,7 @@ Parity gaps found by this pass and current closure state: - [x] futures trading-parameter data source and automatic cost/margin resolver - [x] futures settlement/prev-settlement data integration and settlement mode - [x] futures-aware submission validators and self-trade checks +- [x] optional true multi-level futures order-book depth data and sweep matching ### Phase 10: Advanced data API parity @@ -173,7 +174,9 @@ Parity gaps found by this pass and current closure state: - [x] event-bus process listeners - [x] installable `BacktestProcessMod` extension hook -- [ ] full RQAlpha-style global mod loader and plugin lifecycle +- [x] `BacktestProcessModLoader` with enabled-name installation +- [ ] Python RQAlpha-style global mod loader, intentionally out of scope unless + we need Python plugin compatibility ## Execution Order @@ -199,7 +202,7 @@ Parity gaps found by this pass and current closure state: ## Current Step Active implementation target: P0-P2 parity items are implemented in the engine -core, and P3 now has a lightweight event-driven extension hook. Remaining +core, and P3 now has a lightweight event-driven extension loader. Remaining future work should be driven by concrete production strategy or UI requirements, -especially for data-dependent futures depth matching and exchange-specific -validators. +especially exchange-specific validators and optional vendor-specific depth +fields.