Add futures depth matching and mod loader

This commit is contained in:
boris
2026-04-23 21:51:45 -07:00
parent ed8ac385e4
commit 895aee1388
7 changed files with 537 additions and 20 deletions

View File

@@ -261,6 +261,43 @@ pub struct IntradayExecutionQuote {
pub trading_phase: Option<String>, pub trading_phase: Option<String>,
} }
#[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<f64> {
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 { impl IntradayExecutionQuote {
pub fn buy_price(&self) -> Option<f64> { pub fn buy_price(&self) -> Option<f64> {
if self.ask1.is_finite() && self.ask1 > 0.0 { if self.ask1.is_finite() && self.ask1 > 0.0 {
@@ -661,6 +698,7 @@ pub struct DataSet {
candidate_index: HashMap<(NaiveDate, String), CandidateEligibility>, candidate_index: HashMap<(NaiveDate, String), CandidateEligibility>,
corporate_actions_by_date: BTreeMap<NaiveDate, Vec<CorporateAction>>, corporate_actions_by_date: BTreeMap<NaiveDate, Vec<CorporateAction>>,
execution_quotes_index: HashMap<(NaiveDate, String), Vec<IntradayExecutionQuote>>, execution_quotes_index: HashMap<(NaiveDate, String), Vec<IntradayExecutionQuote>>,
order_book_depth_index: HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>,
benchmark_by_date: BTreeMap<NaiveDate, BenchmarkSnapshot>, benchmark_by_date: BTreeMap<NaiveDate, BenchmarkSnapshot>,
market_series_by_symbol: HashMap<String, SymbolPriceSeries>, market_series_by_symbol: HashMap<String, SymbolPriceSeries>,
benchmark_series_cache: BenchmarkPriceSeries, benchmark_series_cache: BenchmarkPriceSeries,
@@ -694,7 +732,13 @@ impl DataSet {
} else { } else {
Vec::new() 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, instruments,
market, market,
factors, factors,
@@ -703,6 +747,7 @@ impl DataSet {
corporate_actions, corporate_actions,
execution_quotes, execution_quotes,
futures_params, futures_params,
order_book_depth,
) )
} }
@@ -730,7 +775,13 @@ impl DataSet {
} else { } else {
Vec::new() 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, instruments,
market, market,
factors, factors,
@@ -739,6 +790,7 @@ impl DataSet {
corporate_actions, corporate_actions,
execution_quotes, execution_quotes,
futures_params, futures_params,
order_book_depth,
) )
} }
@@ -809,6 +861,30 @@ impl DataSet {
corporate_actions: Vec<CorporateAction>, corporate_actions: Vec<CorporateAction>,
execution_quotes: Vec<IntradayExecutionQuote>, execution_quotes: Vec<IntradayExecutionQuote>,
futures_params: Vec<FuturesTradingParameter>, futures_params: Vec<FuturesTradingParameter>,
) -> Result<Self, DataSetError> {
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<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
corporate_actions: Vec<CorporateAction>,
execution_quotes: Vec<IntradayExecutionQuote>,
futures_params: Vec<FuturesTradingParameter>,
order_book_depth: Vec<IntradayOrderBookDepthLevel>,
) -> Result<Self, DataSetError> { ) -> Result<Self, DataSetError> {
let benchmark_code = collect_benchmark_code(&benchmarks)?; let benchmark_code = collect_benchmark_code(&benchmarks)?;
let calendar = TradingCalendar::new(benchmarks.iter().map(|item| item.date).collect()); let calendar = TradingCalendar::new(benchmarks.iter().map(|item| item.date).collect());
@@ -837,6 +913,7 @@ impl DataSet {
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let corporate_actions_by_date = group_by_date(corporate_actions, |item| item.date); let corporate_actions_by_date = group_by_date(corporate_actions, |item| item.date);
let execution_quotes_index = build_execution_quote_index(execution_quotes); 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 let benchmark_by_date = benchmarks
.into_iter() .into_iter()
@@ -860,6 +937,7 @@ impl DataSet {
candidate_index, candidate_index,
corporate_actions_by_date, corporate_actions_by_date,
execution_quotes_index, execution_quotes_index,
order_book_depth_index,
benchmark_by_date, benchmark_by_date,
market_series_by_symbol, market_series_by_symbol,
benchmark_series_cache, benchmark_series_cache,
@@ -936,6 +1014,17 @@ impl DataSet {
.unwrap_or(&[]) .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<IntradayExecutionQuote> { pub fn execution_quotes_on_date(&self, date: NaiveDate) -> Vec<IntradayExecutionQuote> {
let mut quotes = self let mut quotes = self
.execution_quotes_index .execution_quotes_index
@@ -1978,6 +2067,27 @@ fn read_execution_quotes(path: &Path) -> Result<Vec<IntradayExecutionQuote>, Dat
Ok(quotes) Ok(quotes)
} }
fn read_order_book_depth(path: &Path) -> Result<Vec<IntradayOrderBookDepthLevel>, 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( fn read_futures_trading_parameters(
path: &Path, path: &Path,
) -> Result<Vec<FuturesTradingParameter>, DataSetError> { ) -> Result<Vec<FuturesTradingParameter>, DataSetError> {
@@ -2329,6 +2439,28 @@ fn build_execution_quote_index(
grouped grouped
} }
fn build_order_book_depth_index(
order_book_depth: Vec<IntradayOrderBookDepthLevel>,
) -> HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>> {
let mut grouped = HashMap::<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>::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( fn build_eligible_universe(
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>, factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>, candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>,

View File

@@ -7,7 +7,7 @@ use thiserror::Error;
use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType}; use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType};
use crate::cost::CostModel; use crate::cost::CostModel;
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
use crate::event_bus::{BacktestProcessMod, ProcessEventBus}; use crate::event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
use crate::events::{ use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind, ProcessEventKind,
@@ -401,6 +401,22 @@ impl<S, C, R> BacktestEngine<S, C, R> {
{ {
self.process_event_bus.install_mod(module); self.process_event_bus.install_mod(module);
} }
pub fn install_process_mod_loader(
&mut self,
loader: &mut BacktestProcessModLoader,
) -> Vec<String> {
self.process_event_bus.install_mod_loader(loader)
}
pub fn install_enabled_process_mods(
&mut self,
loader: &mut BacktestProcessModLoader,
enabled_names: &[String],
) -> Vec<String> {
self.process_event_bus
.install_enabled_mods(loader, enabled_names)
}
} }
impl<S, C, R> BacktestEngine<S, C, R> impl<S, C, R> BacktestEngine<S, C, R>
@@ -1119,6 +1135,15 @@ where
intent: &FuturesOrderIntent, intent: &FuturesOrderIntent,
) -> Option<(f64, u32)> { ) -> Option<(f64, u32)> {
let snapshot = self.data.market(date, &intent.symbol); 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); let quotes = self.data.execution_quotes_on(date, &intent.symbol);
for quote in quotes { for quote in quotes {
let price = match self.broker.matching_type() { let price = match self.broker.matching_type() {
@@ -1172,6 +1197,63 @@ where
None 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::<Vec<_>>();
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( fn futures_price_can_trade(
&self, &self,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,

View File

@@ -9,6 +9,65 @@ pub trait BacktestProcessMod {
fn install(&mut self, bus: &mut ProcessEventBus); fn install(&mut self, bus: &mut ProcessEventBus);
} }
#[derive(Default)]
pub struct BacktestProcessModLoader {
modules: Vec<Box<dyn BacktestProcessMod>>,
}
impl BacktestProcessModLoader {
pub fn new() -> Self {
Self::default()
}
pub fn register<M>(&mut self, module: M)
where
M: BacktestProcessMod + 'static,
{
self.modules.push(Box::new(module));
}
pub fn module_names(&self) -> Vec<String> {
self.modules
.iter()
.map(|module| module.name().to_string())
.collect()
}
pub fn install_all(&mut self, bus: &mut ProcessEventBus) -> Vec<String> {
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<String> {
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)] #[derive(Default)]
pub struct ProcessEventBus { pub struct ProcessEventBus {
listeners: BTreeMap<ProcessEventKind, Vec<ProcessEventListener>>, listeners: BTreeMap<ProcessEventKind, Vec<ProcessEventListener>>,
@@ -54,6 +113,18 @@ impl ProcessEventBus {
module.install(self); module.install(self);
} }
pub fn install_mod_loader(&mut self, loader: &mut BacktestProcessModLoader) -> Vec<String> {
loader.install_all(self)
}
pub fn install_enabled_mods(
&mut self,
loader: &mut BacktestProcessModLoader,
enabled_names: &[String],
) -> Vec<String> {
loader.install_enabled(self, enabled_names)
}
pub fn publish(&mut self, event: &ProcessEvent) { pub fn publish(&mut self, event: &ProcessEvent) {
if let Some(listeners) = self.listeners.get_mut(&event.kind) { if let Some(listeners) = self.listeners.get_mut(&event.kind) {
for listener in listeners { for listener in listeners {

View File

@@ -22,15 +22,15 @@ pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
pub use data::{ pub use data::{
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot, BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord, DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
EligibleUniverseSnapshot, FactorValue, IntradayExecutionQuote, PriceBar, PriceField, EligibleUniverseSnapshot, FactorValue, IntradayExecutionQuote, IntradayOrderBookDepthLevel,
SecuritiesMarginRecord, SplitRecord, YieldCurvePoint, PriceBar, PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
}; };
pub use engine::{ pub use engine::{
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary, AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, DailyEquityPoint, BacktestResult, DailyEquityPoint,
}; };
pub use event_bus::{BacktestProcessMod, ProcessEventBus}; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{ pub use events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind, ProcessEventKind,

View File

@@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
}, },
ManualSection { ManualSection {
title: "execution.matching_type / execution.slippage".to_string(), 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_pricenext_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_pricenext_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 { ManualSection {
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(), 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(), detail: "股票指标因子原表,可映射进 factors[...]。".to_string(),
fields: vec![], 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![ examples: vec![
ManualExample { ManualExample {

View File

@@ -4,13 +4,14 @@ use std::rc::Rc;
use chrono::{NaiveDate, NaiveDateTime}; use chrono::{NaiveDate, NaiveDateTime};
use fidc_core::{ use fidc_core::{
BacktestConfig, BacktestEngine, BacktestProcessMod, BenchmarkSnapshot, BrokerSimulator, BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader,
CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
DailyMarketSnapshot, DataSet, FuturesAccountState, FuturesCommissionType, FuturesContractSpec, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesAccountState,
FuturesDirection, FuturesOrderIntent, FuturesTradingParameter, Instrument, FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent,
IntradayExecutionQuote, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, FuturesTradingParameter, Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel,
PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField,
ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule,
Strategy, StrategyContext, StrategyDecision,
}; };
fn d(year: i32, month: u32, day: u32) -> NaiveDate { 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<StrategyDecision, fidc_core::BacktestError> {
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 { struct AdvancedDataApiProbeStrategy {
observed: Rc<RefCell<Vec<String>>>, observed: Rc<RefCell<Vec<String>>>,
} }
@@ -1399,6 +1431,127 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() {
assert!((position.contract_multiplier - 300.0).abs() < 1e-6); 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] #[test]
fn strategy_context_exposes_advanced_rqdata_helpers() { fn strategy_context_exposes_advanced_rqdata_helpers() {
let observed = Rc::new(RefCell::new(Vec::new())); let observed = Rc::new(RefCell::new(Vec::new()));
@@ -2837,6 +2990,25 @@ impl BacktestProcessMod for AnyEventCountingMod {
} }
} }
struct NamedEventCountingMod {
name: &'static str,
sink: Rc<RefCell<Vec<String>>>,
}
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] #[test]
fn engine_installs_process_mods_on_event_bus() { fn engine_installs_process_mods_on_event_bus() {
let date = d(2025, 1, 2); 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] #[test]
fn engine_applies_dynamic_universe_and_subscription_directives() { fn engine_applies_dynamic_universe_and_subscription_directives() {
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)]; let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];

View File

@@ -44,7 +44,7 @@ Parity gaps found by this pass and current closure state:
| Priority | Gap | RQAlpha capability | Current engine state | Next implementation | | 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 | 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. | | 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. | | 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. | | 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 | 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. | | 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 ## 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 trading-parameter data source and automatic cost/margin resolver
- [x] futures settlement/prev-settlement data integration and settlement mode - [x] futures settlement/prev-settlement data integration and settlement mode
- [x] futures-aware submission validators and self-trade checks - [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 ### 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] event-bus process listeners
- [x] installable `BacktestProcessMod` extension hook - [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 ## Execution Order
@@ -199,7 +202,7 @@ Parity gaps found by this pass and current closure state:
## Current Step ## Current Step
Active implementation target: P0-P2 parity items are implemented in the engine 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, future work should be driven by concrete production strategy or UI requirements,
especially for data-dependent futures depth matching and exchange-specific especially exchange-specific validators and optional vendor-specific depth
validators. fields.