Add remaining RQAlpha extension helpers

This commit is contained in:
boris
2026-04-23 21:44:42 -07:00
parent beb9c7a7ae
commit ed8ac385e4
7 changed files with 689 additions and 21 deletions

View File

@@ -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<AnalyzerTradeRow>,
pub positions: Vec<AnalyzerPositionRow>,
pub monthly_returns: Vec<AnalyzerMonthlyReturnRow>,
pub risk_summary: AnalyzerRiskSummary,
pub equity_curve: Vec<DailyEquityPoint>,
pub benchmark_series: Vec<BenchmarkSnapshot>,
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<String, serde_json::Error> {
serde_json::to_string_pretty(&self.analyzer_report())
}
pub fn analyzer_monthly_returns(&self) -> Vec<AnalyzerMonthlyReturnRow> {
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<S, C, R> BacktestEngine<S, C, R> {
{
self.process_event_bus.add_any_listener(listener);
}
pub fn install_process_mod<M>(&mut self, module: &mut M)
where
M: BacktestProcessMod,
{
self.process_event_bus.install_mod(module);
}
}
impl<S, C, R> BacktestEngine<S, C, R>
@@ -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<f64>) -> bool {
let Some(limit_price) = limit_price else {
return price.is_finite() && price > 0.0;

View File

@@ -4,6 +4,11 @@ use crate::events::{ProcessEvent, ProcessEventKind};
type ProcessEventListener = Box<dyn FnMut(&ProcessEvent)>;
pub trait BacktestProcessMod {
fn name(&self) -> &str;
fn install(&mut self, bus: &mut ProcessEventBus);
}
#[derive(Default)]
pub struct ProcessEventBus {
listeners: BTreeMap<ProcessEventKind, Vec<ProcessEventListener>>,
@@ -42,6 +47,13 @@ impl ProcessEventBus {
self.any_listeners.push(Box::new(listener));
}
pub fn install_mod<M>(&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 {

View File

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

View File

@@ -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<String, BacktestError> {
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::<f64>();
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::<f64>();
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<usize, BacktestError> {
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);

View File

@@ -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() },

View File

@@ -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<RefCell<Vec<String>>>,
}
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)];

View File

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