From ed8ac385e4d9697ddd1d9b4cad0312484b2d1e6b Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 21:44:42 -0700 Subject: [PATCH] Add remaining RQAlpha extension helpers --- crates/fidc-core/src/engine.rs | 106 +++- crates/fidc-core/src/event_bus.rs | 12 + crates/fidc-core/src/lib.rs | 7 +- .../fidc-core/src/platform_expr_strategy.rs | 480 +++++++++++++++++- crates/fidc-core/src/strategy_ai.rs | 7 + crates/fidc-core/tests/engine_hooks.rs | 75 ++- docs/rqalpha-gap-roadmap.md | 23 +- 7 files changed, 689 insertions(+), 21 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 3ca4f40..8848b94 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1,13 +1,13 @@ use std::collections::{BTreeMap, BTreeSet}; -use chrono::NaiveDate; +use chrono::{Datelike, NaiveDate}; use serde::Serialize; use thiserror::Error; use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; -use crate::event_bus::ProcessEventBus; +use crate::event_bus::{BacktestProcessMod, ProcessEventBus}; use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, @@ -104,11 +104,41 @@ pub struct AnalyzerPositionRow { pub transaction_cost: f64, } +#[derive(Debug, Clone, Serialize)] +pub struct AnalyzerMonthlyReturnRow { + pub year: i32, + pub month: u32, + pub portfolio_return: f64, + pub benchmark_return: f64, + pub excess_return: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AnalyzerRiskSummary { + pub total_return: f64, + pub annual_return: f64, + pub benchmark_cumulative_return: f64, + pub excess_cumulative_return: f64, + pub alpha: f64, + pub beta: f64, + pub sharpe: f64, + pub sortino: f64, + pub information_ratio: f64, + pub tracking_error: f64, + pub volatility: f64, + pub max_drawdown: f64, + pub max_drawdown_duration_days: usize, + pub win_rate: f64, + pub excess_win_rate: f64, +} + #[derive(Debug, Clone, Serialize)] pub struct AnalyzerReport { pub strategy_name: String, pub trades: Vec, pub positions: Vec, + pub monthly_returns: Vec, + pub risk_summary: AnalyzerRiskSummary, pub equity_curve: Vec, pub benchmark_series: Vec, pub metrics: BacktestMetrics, @@ -149,6 +179,8 @@ impl BacktestResult { transaction_cost: holding.transaction_cost, }) .collect(), + monthly_returns: self.analyzer_monthly_returns(), + risk_summary: self.analyzer_risk_summary(), equity_curve: self.equity_curve.clone(), benchmark_series: self.benchmark_series.clone(), metrics: self.metrics.clone(), @@ -158,6 +190,61 @@ impl BacktestResult { pub fn analyzer_report_json(&self) -> Result { serde_json::to_string_pretty(&self.analyzer_report()) } + + pub fn analyzer_monthly_returns(&self) -> Vec { + let mut month_points = BTreeMap::<(i32, u32), (f64, f64, f64, f64)>::new(); + for point in &self.equity_curve { + let key = (point.date.year(), point.date.month()); + month_points + .entry(key) + .and_modify(|(_, _, end_equity, end_benchmark)| { + *end_equity = point.total_equity; + *end_benchmark = point.benchmark_close; + }) + .or_insert(( + point.total_equity, + point.benchmark_close, + point.total_equity, + point.benchmark_close, + )); + } + month_points + .into_iter() + .map( + |((year, month), (start_equity, start_benchmark, end_equity, end_benchmark))| { + let portfolio_return = analyzer_ratio_change(start_equity, end_equity); + let benchmark_return = analyzer_ratio_change(start_benchmark, end_benchmark); + AnalyzerMonthlyReturnRow { + year, + month, + portfolio_return, + benchmark_return, + excess_return: portfolio_return - benchmark_return, + } + }, + ) + .collect() + } + + pub fn analyzer_risk_summary(&self) -> AnalyzerRiskSummary { + AnalyzerRiskSummary { + total_return: self.metrics.total_return, + annual_return: self.metrics.annual_return, + benchmark_cumulative_return: self.metrics.benchmark_cumulative_return, + excess_cumulative_return: self.metrics.excess_cumulative_return, + alpha: self.metrics.alpha, + beta: self.metrics.beta, + sharpe: self.metrics.sharpe, + sortino: self.metrics.sortino, + information_ratio: self.metrics.information_ratio, + tracking_error: self.metrics.tracking_error, + volatility: self.metrics.volatility, + max_drawdown: self.metrics.max_drawdown, + max_drawdown_duration_days: self.metrics.max_drawdown_duration_days, + win_rate: self.metrics.win_rate, + excess_win_rate: self.metrics.excess_win_rate, + } + } } #[derive(Debug, Clone, Serialize)] @@ -307,6 +394,13 @@ impl BacktestEngine { { self.process_event_bus.add_any_listener(listener); } + + pub fn install_process_mod(&mut self, module: &mut M) + where + M: BacktestProcessMod, + { + self.process_event_bus.install_mod(module); + } } impl BacktestEngine @@ -3042,6 +3136,14 @@ fn merge_futures_execution_report( target.diagnostics.extend(incoming.diagnostics); } +fn analyzer_ratio_change(start: f64, end: f64) -> f64 { + if start.abs() <= f64::EPSILON { + 0.0 + } else { + end / start - 1.0 + } +} + fn futures_limit_satisfied(side: OrderSide, price: f64, limit_price: Option) -> bool { let Some(limit_price) = limit_price else { return price.is_finite() && price > 0.0; diff --git a/crates/fidc-core/src/event_bus.rs b/crates/fidc-core/src/event_bus.rs index 789a34b..13ddadb 100644 --- a/crates/fidc-core/src/event_bus.rs +++ b/crates/fidc-core/src/event_bus.rs @@ -4,6 +4,11 @@ use crate::events::{ProcessEvent, ProcessEventKind}; type ProcessEventListener = Box; +pub trait BacktestProcessMod { + fn name(&self) -> &str; + fn install(&mut self, bus: &mut ProcessEventBus); +} + #[derive(Default)] pub struct ProcessEventBus { listeners: BTreeMap>, @@ -42,6 +47,13 @@ impl ProcessEventBus { self.any_listeners.push(Box::new(listener)); } + pub fn install_mod(&mut self, module: &mut M) + where + M: BacktestProcessMod, + { + module.install(self); + } + 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 3b464ea..e302e81 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -26,10 +26,11 @@ pub use data::{ SecuritiesMarginRecord, SplitRecord, YieldCurvePoint, }; pub use engine::{ - AnalyzerPositionRow, AnalyzerReport, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, - BacktestEngine, BacktestError, BacktestResult, DailyEquityPoint, + AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary, + AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, + BacktestResult, DailyEquityPoint, }; -pub use event_bus::ProcessEventBus; +pub use event_bus::{BacktestProcessMod, ProcessEventBus}; pub use events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 8a168f3..4b9b27a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -627,6 +627,31 @@ impl PlatformExprStrategy { ]) } + fn is_runtime_helper(name: &str) -> bool { + matches!( + name, + "factor" + | "day_factor" + | "rolling_mean" + | "sma" + | "factor_value" + | "get_factor_value" + | "dividend_cash" + | "has_dividend" + | "split_ratio" + | "has_split" + | "securities_margin" + | "get_securities_margin_value" + | "yield_curve" + | "get_yield_curve_value" + | "is_margin_stock" + | "dominant_future" + | "get_dominant_future" + | "dominant_future_price" + | "get_dominant_future_price_value" + ) + } + fn price_is_at_limit(price: f64, limit: f64, tick: f64) -> bool { if !price.is_finite() || !limit.is_finite() { return false; @@ -1927,10 +1952,42 @@ impl PlatformExprStrategy { ) -> Result { let mut output = String::with_capacity(expr.len()); let mut cursor = 0usize; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; while cursor < expr.len() { let Some(ch) = expr[cursor..].chars().next() else { break; }; + if escaped { + output.push(ch); + escaped = false; + cursor += ch.len_utf8(); + continue; + } + if ch == '\\' && (in_single_quote || in_double_quote) { + output.push(ch); + escaped = true; + cursor += ch.len_utf8(); + continue; + } + if ch == '\'' && !in_double_quote { + output.push(ch); + in_single_quote = !in_single_quote; + cursor += ch.len_utf8(); + continue; + } + if ch == '"' && !in_single_quote { + output.push(ch); + in_double_quote = !in_double_quote; + cursor += ch.len_utf8(); + continue; + } + if in_single_quote || in_double_quote { + output.push(ch); + cursor += ch.len_utf8(); + continue; + } if !(ch == '_' || ch.is_ascii_alphabetic()) { output.push(ch); cursor += ch.len_utf8(); @@ -1964,7 +2021,7 @@ impl PlatformExprStrategy { output.push_str(&expr[ident_start..cursor]); break; }; - if next != '(' || !matches!(ident, "factor" | "day_factor" | "rolling_mean" | "sma") { + if next != '(' || !Self::is_runtime_helper(ident) { output.push_str(&expr[ident_start..cursor]); continue; } @@ -2016,12 +2073,230 @@ impl PlatformExprStrategy { let value = self.resolve_rolling_mean(ctx, day, stock, &field, lookback)?; Ok(format!("{value:.12}")) } + "factor_value" | "get_factor_value" => { + if args.is_empty() || args.len() > 2 { + return Err(BacktestError::Execution(format!( + "{helper} expects field and optional lookback" + ))); + } + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let field = Self::parse_string_or_identifier(&args[0])?; + let lookback = Self::parse_optional_positive_usize(args.get(1), 1)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_factor(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "dividend_cash" | "has_dividend" => { + let (symbol, lookback) = + self.parse_symbol_lookback_helper_args(helper, &args, stock, 1, 2)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let total = ctx + .data + .get_dividend(&symbol, start, day.date) + .iter() + .map(|row| row.dividend_cash_before_tax) + .sum::(); + if helper == "has_dividend" { + Ok((total.abs() > f64::EPSILON).to_string()) + } else { + Ok(Self::format_rhai_float(total)) + } + } + "split_ratio" | "has_split" => { + let (symbol, lookback) = + self.parse_symbol_lookback_helper_args(helper, &args, stock, 1, 2)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let splits = ctx.data.get_split(&symbol, start, day.date); + let ratio = splits.iter().map(|row| row.split_ratio).product::(); + if helper == "has_split" { + Ok((!splits.is_empty()).to_string()) + } else { + Ok(Self::format_rhai_float(if splits.is_empty() { + 1.0 + } else { + ratio + })) + } + } + "securities_margin" | "get_securities_margin_value" => { + if args.is_empty() || args.len() > 2 { + return Err(BacktestError::Execution(format!( + "{helper} expects field and optional lookback" + ))); + } + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let field = Self::parse_string_or_identifier(&args[0])?; + let lookback = Self::parse_optional_positive_usize(args.get(1), 1)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_securities_margin(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "yield_curve" | "get_yield_curve_value" => { + if args.is_empty() || args.len() > 2 { + return Err(BacktestError::Execution(format!( + "{helper} expects tenor and optional lookback" + ))); + } + let tenor = Self::parse_string_or_identifier(&args[0])?; + let lookback = Self::parse_optional_positive_usize(args.get(1), 1)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_yield_curve(start, day.date, Some(&tenor)) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "is_margin_stock" => { + if args.len() > 1 { + return Err(BacktestError::Execution( + "is_margin_stock expects optional margin type".to_string(), + )); + } + let stock = stock.ok_or_else(|| { + BacktestError::Execution("is_margin_stock requires stock context".to_string()) + })?; + let margin_type = args + .first() + .map(|arg| Self::parse_string_or_identifier(arg)) + .transpose()? + .unwrap_or_else(|| "all".to_string()); + let matched = ctx + .get_margin_stocks(&margin_type) + .iter() + .any(|symbol| symbol == &stock.symbol); + Ok(matched.to_string()) + } + "dominant_future" | "get_dominant_future" => { + if args.len() != 1 { + return Err(BacktestError::Execution(format!( + "{helper} expects underlying symbol" + ))); + } + let underlying = Self::parse_string_or_identifier(&args[0])?; + let symbol = ctx.get_dominant_future(&underlying).unwrap_or_default(); + Ok(Self::quote_rhai_string(&symbol)) + } + "dominant_future_price" | "get_dominant_future_price_value" => { + if args.is_empty() || args.len() > 3 { + return Err(BacktestError::Execution(format!( + "{helper} expects underlying, optional field, optional lookback" + ))); + } + let underlying = Self::parse_string_or_identifier(&args[0])?; + let field = args + .get(1) + .map(|arg| Self::parse_string_or_identifier(arg)) + .transpose()? + .unwrap_or_else(|| "close".to_string()); + let lookback = Self::parse_optional_positive_usize(args.get(2), 1)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_dominant_future_price(&underlying, start, day.date, "1d") + .last() + .map(|row| Self::price_bar_field(row, &field)) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } other => Err(BacktestError::Execution(format!( "unsupported platform helper: {other}" ))), } } + fn helper_start_date( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + lookback: usize, + ) -> NaiveDate { + ctx.data + .previous_trading_date(date, lookback.saturating_sub(1)) + .unwrap_or(date) + } + + fn parse_symbol_lookback_helper_args( + &self, + helper: &str, + args: &[String], + stock: Option<&StockExpressionState>, + default_lookback: usize, + max_args: usize, + ) -> Result<(String, usize), BacktestError> { + if args.len() > max_args { + return Err(BacktestError::Execution(format!( + "{helper} expects optional symbol and optional lookback" + ))); + } + if args.is_empty() { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + return Ok((stock.symbol.clone(), default_lookback)); + } + if args.len() == 1 { + if let Ok(lookback) = Self::parse_positive_usize(&args[0]) { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + return Ok((stock.symbol.clone(), lookback)); + } + return Ok(( + Self::parse_string_or_identifier(&args[0])?, + default_lookback, + )); + } + Ok(( + Self::parse_string_or_identifier(&args[0])?, + Self::parse_positive_usize(&args[1])?, + )) + } + + fn parse_optional_positive_usize( + raw: Option<&String>, + fallback: usize, + ) -> Result { + raw.map(|value| Self::parse_positive_usize(value)) + .transpose() + .map(|value| value.unwrap_or(fallback)) + } + + fn format_rhai_float(value: f64) -> String { + if value.is_finite() { + format!("{value:.12}") + } else { + "0.0".to_string() + } + } + + fn price_bar_field(row: &crate::data::PriceBar, field: &str) -> f64 { + match field.trim().to_ascii_lowercase().as_str() { + "open" => row.open, + "high" => row.high, + "low" => row.low, + "last" | "last_price" => row.last_price, + "volume" => row.volume as f64, + "amount" => row.amount, + "bid1" => row.bid1, + "ask1" => row.ask1, + "bid1_volume" => row.bid1_volume as f64, + "ask1_volume" => row.ask1_volume as f64, + _ => row.close, + } + } + fn resolve_rolling_mean( &self, ctx: &StrategyContext<'_>, @@ -3908,8 +4183,9 @@ mod tests { PlatformUniverseActionKind, }; use crate::{ - AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, - DailyMarketSnapshot, DataSet, Instrument, OpenOrderView, PortfolioState, ProcessEvent, + AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, + DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesCommissionType, + FuturesTradingParameter, Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, TargetPortfolioOrderPricing, TradingCalendar, default_stage_time, }; @@ -4157,6 +4433,204 @@ mod tests { ); } + #[test] + fn platform_strategy_exposes_advanced_data_runtime_helpers() { + let date = d(2025, 2, 3); + let data = DataSet::from_components_with_actions_quotes_and_futures( + vec![ + Instrument { + symbol: "000001.SZ".to_string(), + name: "Ping An Bank".to_string(), + board: "SZSE".to_string(), + round_lot: 100, + listed_at: Some(d(2010, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: "IF2501".to_string(), + name: "IF main".to_string(), + board: "FUTURE".to_string(), + round_lot: 1, + listed_at: Some(d(2024, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.9, + close: 10.1, + last_price: 10.05, + bid1: 10.04, + ask1: 10.05, + prev_close: 9.95, + volume: 1_000_000, + tick_volume: 5_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.94, + lower_limit: 8.96, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date, + symbol: "IF2501".to_string(), + timestamp: Some("10:18:00".to_string()), + day_open: 4000.0, + open: 4000.0, + high: 4010.0, + low: 3990.0, + close: 4000.0, + last_price: 4000.0, + bid1: 3999.8, + ask1: 4000.2, + prev_close: 3995.0, + volume: 100_000, + tick_volume: 1_000, + bid1_volume: 100, + ask1_volume: 100, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 4200.0, + lower_limit: 3800.0, + price_tick: 0.2, + }, + ], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(22.0), + effective_turnover_ratio: Some(18.0), + extra_factors: BTreeMap::from([ + ("custom_alpha".to_string(), 7.0), + ("margin_all".to_string(), 1.0), + ("yield_curve_1y".to_string(), 0.02), + ]), + }], + 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: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + vec![CorporateAction { + date, + symbol: "000001.SZ".to_string(), + payable_date: Some(date), + share_cash: 0.2, + share_bonus: 0.1, + share_gift: 0.0, + issue_quantity: 0.0, + issue_price: 0.0, + reform: false, + adjust_factor: None, + successor_symbol: None, + successor_ratio: None, + successor_cash: None, + }], + 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::ByMoney, + open_commission_ratio: 0.000023, + close_commission_ratio: 0.000023, + close_today_commission_ratio: 0.000345, + price_tick: 0.2, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::Value, + symbol: "000001.SZ".to_string(), + amount_expr: "1000".to_string(), + limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, + when_expr: Some(concat!( + "has_dividend(1) && dividend_cash(1) > 0.19", + " && has_split(1) && split_ratio(1) > 1.09", + " && factor_value(\"custom_alpha\") == 7.0", + " && securities_margin(\"margin_all\") == 1.0", + " && is_margin_stock(\"all\")", + " && yield_curve(\"1y\") > 0.019", + " && dominant_future(\"IF\") == \"IF2501\"", + " && dominant_future_price(\"IF\", \"close\") == 4000.0", + " && \"factor_value(\\\"custom_alpha\\\")\" == \"factor_value(\\\"custom_alpha\\\")\"" + ) + .to_string()), + reason: "advanced_data_helper_entry".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert_eq!(decision.order_intents.len(), 1); + match &decision.order_intents[0] { + crate::strategy::OrderIntent::Value { + symbol, + value, + reason, + } => { + assert_eq!(symbol, "000001.SZ"); + assert!((*value - 1000.0).abs() < 1e-6); + assert_eq!(reason, "advanced_data_helper_entry"); + } + other => panic!("unexpected advanced helper intent: {other:?}"), + } + } + #[test] fn platform_strategy_emits_target_shares_explicit_action() { let date = d(2025, 2, 3); diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 66f0976..7ed7d58 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -206,6 +206,13 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { ManualFunction { name: "get_trading_dates/get_previous_trading_date/get_next_trading_date".to_string(), signature: "ctx.get_previous_trading_date(date, n)".to_string(), detail: "交易日历 API。get_trading_dates 返回闭区间交易日;previous/next 返回相对某日向前或向后的第 n 个交易日,当前日自身不计入。".to_string() }, ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前交易日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应 RQAlpha 的 is_suspended/is_st_stock 数据源能力。".to_string() }, ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\" | \"tick\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟或 tick 返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() }, + ManualFunction { name: "get_dividend / dividend_cash / has_dividend".to_string(), signature: "dividend_cash(lookback) / has_dividend(lookback)".to_string(), detail: "RQData 风格分红 API。Rust Context 可用 ctx.get_dividend(symbol, start_date) 读取明细;平台表达式可用 dividend_cash(lookback) 汇总当前股票最近 N 个交易日现金分红,用 has_dividend(lookback) 判断是否发生分红,也支持 dividend_cash(\"600000.SH\", lookback)。".to_string() }, + ManualFunction { name: "get_split / split_ratio / has_split".to_string(), signature: "split_ratio(lookback) / has_split(lookback)".to_string(), detail: "RQData 风格拆分/送转 API。Rust Context 可用 ctx.get_split(symbol, start_date) 读取明细;平台表达式可用 split_ratio(lookback) 计算当前股票最近 N 个交易日累计拆分比例,has_split(lookback) 判断是否发生送转。".to_string() }, + ManualFunction { name: "get_factor / factor_value".to_string(), signature: "factor_value(\"field\", lookback=1)".to_string(), detail: "因子 API。factor(\"field\") 读取当前股票当日因子;factor_value(\"field\", lookback) 会在最近 N 个交易日内取该字段最新值,适合读取任意数据库指标或自定义因子。Rust Context 可用 ctx.get_factor(symbol, start, end, field) 读取完整序列。".to_string() }, + ManualFunction { name: "get_yield_curve / yield_curve".to_string(), signature: "yield_curve(\"1y\", lookback=1)".to_string(), detail: "收益率曲线 API。平台表达式从 factors 中的 yield_curve_1y / yc_1y 等字段读取最近值;Rust Context 可用 ctx.get_yield_curve(start, end, Some(\"1y\")) 读取序列。".to_string() }, + ManualFunction { name: "get_margin_stocks / is_margin_stock".to_string(), signature: "is_margin_stock(\"all\" | \"stock\" | \"cash\")".to_string(), detail: "融资融券标的 API。平台表达式用 is_margin_stock(...) 判断当前股票是否在 margin_all/margin_stock/margin_cash 标记中;Rust Context 可用 ctx.get_margin_stocks(type) 返回标的列表。".to_string() }, + ManualFunction { name: "get_securities_margin / securities_margin".to_string(), signature: "securities_margin(\"field\", lookback=1)".to_string(), detail: "融资融券明细 API。平台表达式读取当前股票最近 N 个交易日指定融资融券字段最新值;Rust Context 可用 ctx.get_securities_margin(symbol, start, end, field) 读取序列。".to_string() }, + ManualFunction { name: "get_dominant_future / dominant_future / dominant_future_price".to_string(), signature: "dominant_future(\"IF\") / dominant_future_price(\"IF\", \"close\", lookback=1)".to_string(), detail: "主力合约 API。dominant_future 返回当前日期匹配前缀的主力期货合约代码;dominant_future_price 读取该主力合约最近 N 个交易日指定字段的最新价格。Rust Context 可用 ctx.get_dominant_future(...) 和 ctx.get_dominant_future_price(...)。".to_string() }, ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 RQAlpha Order 的核心属性。".to_string() }, ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() }, ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 RQAlpha 管理费回调能力。".to_string() }, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 1da9940..3f165a6 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -4,12 +4,13 @@ use std::rc::Rc; use chrono::{NaiveDate, NaiveDateTime}; use fidc_core::{ - BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, - ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, - FuturesOrderIntent, FuturesTradingParameter, Instrument, IntradayExecutionQuote, OpenOrderView, - OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField, ProcessEventKind, - ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, + 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, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -1335,11 +1336,16 @@ fn engine_aggregates_futures_account_into_nav_and_metrics() { assert!((result.equity_curve[0].total_equity - 599_988.0).abs() < 1e-6); assert!((result.metrics.total_assets - 599_988.0).abs() < 1e-6); assert_eq!(result.analyzer_report().trades.len(), result.fills.len()); + assert_eq!(result.analyzer_report().monthly_returns.len(), 1); + assert_eq!( + result.analyzer_report().risk_summary.total_return, + result.metrics.total_return + ); assert!( result .analyzer_report_json() .expect("report json") - .contains("\"trades\"") + .contains("\"monthly_returns\"") ); } @@ -2813,6 +2819,61 @@ fn engine_dispatches_process_events_to_external_bus_listeners() { ); } +struct AnyEventCountingMod { + sink: Rc>>, +} + +impl BacktestProcessMod for AnyEventCountingMod { + fn name(&self) -> &str { + "any-event-counter" + } + + 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); + 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 sink = Rc::new(RefCell::new(Vec::new())); + let mut module = AnyEventCountingMod { sink: sink.clone() }; + + engine.install_process_mod(&mut module); + engine.run().expect("backtest run"); + + assert!( + sink.borrow() + .iter() + .any(|item| { item.starts_with("PreBeforeTrading:before_trading:pre") }) + ); +} + #[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 d6f7328..7efc147 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -44,16 +44,16 @@ 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 order-book-depth counterparty sweeping remains out of scope unless production strategies require it. | Keep extending matching detail only when real futures tick depth data is available. | +| 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 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. | | P1 | Futures transaction cost decider | RQAlpha supports by-money/by-volume futures commission with separate open, close, and close-today rates and a commission multiplier. | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. | | P1 | Futures settlement price mode | RQAlpha can settle futures by `settlement` or `close`, including previous-settlement fields. | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. | | 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`, using existing corporate-action, factor, market, and futures-parameter data. | Wire any missing frontend DSL aliases separately if the script layer needs them. | -| P2 | Analyzer/report parity | RQAlpha analyser can export richer trades, positions, benchmark, monthly returns, risk, and summary artifacts. | Closed for normalized trades, positions, 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. | Engine has explicit Rust config and event/process records, not a full mod framework. | Only implement toggles required by production strategies; avoid recreating the whole RQAlpha mod system unless 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. | ## Remaining Gaps @@ -160,6 +160,7 @@ Parity gaps found by this pass and current closure state: - [x] `get_securities_margin` - [x] `get_dominant_future` - [x] futures dominant price helpers +- [x] platform DSL helper aliases for advanced RQData-style APIs ### Phase 11: Analyzer / report parity @@ -168,6 +169,12 @@ Parity gaps found by this pass and current closure state: - [x] benchmark / monthly returns / risk summary artifacts - [x] downloadable analyser output bundle +### Phase 12: Lightweight mod / extension parity + +- [x] event-bus process listeners +- [x] installable `BacktestProcessMod` extension hook +- [ ] full RQAlpha-style global mod loader and plugin lifecycle + ## Execution Order 1. Close the explicit order API gap with target-shares / `order_to` parity. @@ -186,9 +193,13 @@ Parity gaps found by this pass and current closure state: settlement-price integration. 13. Add advanced RQData helper APIs where source data exists. 14. Add analyser/report artifact parity. +15. Add lightweight process-mod extension hooks; only add concrete mods when + production needs them. ## Current Step Active implementation target: P0-P2 parity items are implemented in the engine -core. Remaining future work should be driven by concrete production strategy or -UI requirements rather than recreating RQAlpha's full plugin/mod framework. +core, and P3 now has a lightweight event-driven extension hook. Remaining +future work should be driven by concrete production strategy or UI requirements, +especially for data-dependent futures depth matching and exchange-specific +validators.