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>,
}
#[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 {
pub fn buy_price(&self) -> Option<f64> {
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<NaiveDate, Vec<CorporateAction>>,
execution_quotes_index: HashMap<(NaiveDate, String), Vec<IntradayExecutionQuote>>,
order_book_depth_index: HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>,
benchmark_by_date: BTreeMap<NaiveDate, BenchmarkSnapshot>,
market_series_by_symbol: HashMap<String, SymbolPriceSeries>,
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<CorporateAction>,
execution_quotes: Vec<IntradayExecutionQuote>,
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> {
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::<HashMap<_, _>>();
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<IntradayExecutionQuote> {
let mut quotes = self
.execution_quotes_index
@@ -1978,6 +2067,27 @@ fn read_execution_quotes(path: &Path) -> Result<Vec<IntradayExecutionQuote>, Dat
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(
path: &Path,
) -> Result<Vec<FuturesTradingParameter>, DataSetError> {
@@ -2329,6 +2439,28 @@ fn build_execution_quote_index(
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(
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>,

View File

@@ -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<S, C, R> BacktestEngine<S, C, R> {
{
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>
@@ -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::<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(
&self,
snapshot: &crate::data::DailyMarketSnapshot,

View File

@@ -9,6 +9,65 @@ pub trait BacktestProcessMod {
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)]
pub struct ProcessEventBus {
listeners: BTreeMap<ProcessEventKind, Vec<ProcessEventListener>>,
@@ -54,6 +113,18 @@ impl ProcessEventBus {
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) {
if let Some(listeners) = self.listeners.get_mut(&event.kind) {
for listener in listeners {

View File

@@ -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,

View File

@@ -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_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 {
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 {

View File

@@ -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<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 {
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);
}
#[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<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]
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)];