Add remaining RQAlpha extension helpers
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::{Datelike, NaiveDate};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType};
|
use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType};
|
||||||
use crate::cost::CostModel;
|
use crate::cost::CostModel;
|
||||||
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
|
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
|
||||||
use crate::event_bus::ProcessEventBus;
|
use crate::event_bus::{BacktestProcessMod, ProcessEventBus};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
ProcessEventKind,
|
ProcessEventKind,
|
||||||
@@ -104,11 +104,41 @@ pub struct AnalyzerPositionRow {
|
|||||||
pub transaction_cost: f64,
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct AnalyzerReport {
|
pub struct AnalyzerReport {
|
||||||
pub strategy_name: String,
|
pub strategy_name: String,
|
||||||
pub trades: Vec<AnalyzerTradeRow>,
|
pub trades: Vec<AnalyzerTradeRow>,
|
||||||
pub positions: Vec<AnalyzerPositionRow>,
|
pub positions: Vec<AnalyzerPositionRow>,
|
||||||
|
pub monthly_returns: Vec<AnalyzerMonthlyReturnRow>,
|
||||||
|
pub risk_summary: AnalyzerRiskSummary,
|
||||||
pub equity_curve: Vec<DailyEquityPoint>,
|
pub equity_curve: Vec<DailyEquityPoint>,
|
||||||
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
||||||
pub metrics: BacktestMetrics,
|
pub metrics: BacktestMetrics,
|
||||||
@@ -149,6 +179,8 @@ impl BacktestResult {
|
|||||||
transaction_cost: holding.transaction_cost,
|
transaction_cost: holding.transaction_cost,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
monthly_returns: self.analyzer_monthly_returns(),
|
||||||
|
risk_summary: self.analyzer_risk_summary(),
|
||||||
equity_curve: self.equity_curve.clone(),
|
equity_curve: self.equity_curve.clone(),
|
||||||
benchmark_series: self.benchmark_series.clone(),
|
benchmark_series: self.benchmark_series.clone(),
|
||||||
metrics: self.metrics.clone(),
|
metrics: self.metrics.clone(),
|
||||||
@@ -158,6 +190,61 @@ impl BacktestResult {
|
|||||||
pub fn analyzer_report_json(&self) -> Result<String, serde_json::Error> {
|
pub fn analyzer_report_json(&self) -> Result<String, serde_json::Error> {
|
||||||
serde_json::to_string_pretty(&self.analyzer_report())
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -307,6 +394,13 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
{
|
{
|
||||||
self.process_event_bus.add_any_listener(listener);
|
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>
|
impl<S, C, R> BacktestEngine<S, C, R>
|
||||||
@@ -3042,6 +3136,14 @@ fn merge_futures_execution_report(
|
|||||||
target.diagnostics.extend(incoming.diagnostics);
|
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 {
|
fn futures_limit_satisfied(side: OrderSide, price: f64, limit_price: Option<f64>) -> bool {
|
||||||
let Some(limit_price) = limit_price else {
|
let Some(limit_price) = limit_price else {
|
||||||
return price.is_finite() && price > 0.0;
|
return price.is_finite() && price > 0.0;
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ use crate::events::{ProcessEvent, ProcessEventKind};
|
|||||||
|
|
||||||
type ProcessEventListener = Box<dyn FnMut(&ProcessEvent)>;
|
type ProcessEventListener = Box<dyn FnMut(&ProcessEvent)>;
|
||||||
|
|
||||||
|
pub trait BacktestProcessMod {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
fn install(&mut self, bus: &mut ProcessEventBus);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ProcessEventBus {
|
pub struct ProcessEventBus {
|
||||||
listeners: BTreeMap<ProcessEventKind, Vec<ProcessEventListener>>,
|
listeners: BTreeMap<ProcessEventKind, Vec<ProcessEventListener>>,
|
||||||
@@ -42,6 +47,13 @@ impl ProcessEventBus {
|
|||||||
self.any_listeners.push(Box::new(listener));
|
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) {
|
pub fn publish(&mut self, event: &ProcessEvent) {
|
||||||
if let Some(listeners) = self.listeners.get_mut(&event.kind) {
|
if let Some(listeners) = self.listeners.get_mut(&event.kind) {
|
||||||
for listener in listeners {
|
for listener in listeners {
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ pub use data::{
|
|||||||
SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
|
SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
|
||||||
};
|
};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
AnalyzerPositionRow, AnalyzerReport, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress,
|
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||||
BacktestEngine, BacktestError, BacktestResult, DailyEquityPoint,
|
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
||||||
|
BacktestResult, DailyEquityPoint,
|
||||||
};
|
};
|
||||||
pub use event_bus::ProcessEventBus;
|
pub use event_bus::{BacktestProcessMod, ProcessEventBus};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
ProcessEventKind,
|
ProcessEventKind,
|
||||||
|
|||||||
@@ -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 {
|
fn price_is_at_limit(price: f64, limit: f64, tick: f64) -> bool {
|
||||||
if !price.is_finite() || !limit.is_finite() {
|
if !price.is_finite() || !limit.is_finite() {
|
||||||
return false;
|
return false;
|
||||||
@@ -1927,10 +1952,42 @@ impl PlatformExprStrategy {
|
|||||||
) -> Result<String, BacktestError> {
|
) -> Result<String, BacktestError> {
|
||||||
let mut output = String::with_capacity(expr.len());
|
let mut output = String::with_capacity(expr.len());
|
||||||
let mut cursor = 0usize;
|
let mut cursor = 0usize;
|
||||||
|
let mut in_single_quote = false;
|
||||||
|
let mut in_double_quote = false;
|
||||||
|
let mut escaped = false;
|
||||||
while cursor < expr.len() {
|
while cursor < expr.len() {
|
||||||
let Some(ch) = expr[cursor..].chars().next() else {
|
let Some(ch) = expr[cursor..].chars().next() else {
|
||||||
break;
|
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()) {
|
if !(ch == '_' || ch.is_ascii_alphabetic()) {
|
||||||
output.push(ch);
|
output.push(ch);
|
||||||
cursor += ch.len_utf8();
|
cursor += ch.len_utf8();
|
||||||
@@ -1964,7 +2021,7 @@ impl PlatformExprStrategy {
|
|||||||
output.push_str(&expr[ident_start..cursor]);
|
output.push_str(&expr[ident_start..cursor]);
|
||||||
break;
|
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]);
|
output.push_str(&expr[ident_start..cursor]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2016,12 +2073,230 @@ impl PlatformExprStrategy {
|
|||||||
let value = self.resolve_rolling_mean(ctx, day, stock, &field, lookback)?;
|
let value = self.resolve_rolling_mean(ctx, day, stock, &field, lookback)?;
|
||||||
Ok(format!("{value:.12}"))
|
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!(
|
other => Err(BacktestError::Execution(format!(
|
||||||
"unsupported platform helper: {other}"
|
"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(
|
fn resolve_rolling_mean(
|
||||||
&self,
|
&self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
@@ -3908,8 +4183,9 @@ mod tests {
|
|||||||
PlatformUniverseActionKind,
|
PlatformUniverseActionKind,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot,
|
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
||||||
DailyMarketSnapshot, DataSet, Instrument, OpenOrderView, PortfolioState, ProcessEvent,
|
DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesCommissionType,
|
||||||
|
FuturesTradingParameter, Instrument, OpenOrderView, PortfolioState, ProcessEvent,
|
||||||
ProcessEventKind, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext,
|
ProcessEventKind, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext,
|
||||||
TargetPortfolioOrderPricing, TradingCalendar, default_stage_time,
|
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]
|
#[test]
|
||||||
fn platform_strategy_emits_target_shares_explicit_action() {
|
fn platform_strategy_emits_target_shares_explicit_action() {
|
||||||
let date = d(2025, 2, 3);
|
let date = d(2025, 2, 3);
|
||||||
|
|||||||
@@ -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: "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: "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_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: "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: "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() },
|
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() },
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use chrono::{NaiveDate, NaiveDateTime};
|
use chrono::{NaiveDate, NaiveDateTime};
|
||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
BacktestConfig, BacktestEngine, BacktestProcessMod, BenchmarkSnapshot, BrokerSimulator,
|
||||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot,
|
||||||
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
|
DailyMarketSnapshot, DataSet, FuturesAccountState, FuturesCommissionType, FuturesContractSpec,
|
||||||
FuturesOrderIntent, FuturesTradingParameter, Instrument, IntradayExecutionQuote, OpenOrderView,
|
FuturesDirection, FuturesOrderIntent, FuturesTradingParameter, Instrument,
|
||||||
OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField, ProcessEventKind,
|
IntradayExecutionQuote, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState,
|
||||||
ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision,
|
PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage,
|
||||||
|
ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
@@ -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.equity_curve[0].total_equity - 599_988.0).abs() < 1e-6);
|
||||||
assert!((result.metrics.total_assets - 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().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!(
|
assert!(
|
||||||
result
|
result
|
||||||
.analyzer_report_json()
|
.analyzer_report_json()
|
||||||
.expect("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]
|
#[test]
|
||||||
fn engine_applies_dynamic_universe_and_subscription_directives() {
|
fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||||
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];
|
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];
|
||||||
|
|||||||
@@ -44,16 +44,16 @@ Parity gaps found by this pass and current closure state:
|
|||||||
|
|
||||||
| Priority | Gap | RQAlpha capability | Current engine state | Next implementation |
|
| Priority | Gap | RQAlpha capability | Current engine state | Next implementation |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| P0 | Futures intraday matching | Bar/tick matchers support futures orders through the same broker lifecycle, matching type, slippage, price limit, liquidity limit, volume limit, partial fill, and market-close rejection semantics. | Closed for daily/open/close and tick-price futures fills, including limit checks and partial quantity handling. Full 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 | Futures open-order lifecycle | `SimulationBroker` keeps pending orders, supports `get_open_orders`, cancellation, before-trading activation, tick/bar rematching, and after-trading rejection. | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires RQAlpha's exact intermediate event names. |
|
||||||
| P0 | Combined multi-account NAV | RQAlpha portfolio aggregates account values across stock/future accounts. | Closed. `DailyEquityPoint`, progress events, and metrics now use aggregate stock + futures initial cash and total equity. | None. |
|
| P0 | Combined multi-account NAV | RQAlpha portfolio aggregates account values across stock/future accounts. | Closed. `DailyEquityPoint`, progress events, and metrics now use aggregate stock + futures initial cash and total equity. | None. |
|
||||||
| P1 | Futures trading parameter data source | RQAlpha loads contract multiplier, margin ratios, commission type, open/close/close-today commission ratios, settlement/prev-settlement, tick size, listed/de-listed dates, and dominant contracts from data proxy. | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. |
|
| P1 | Futures trading parameter data source | RQAlpha loads contract multiplier, margin ratios, commission type, open/close/close-today commission ratios, settlement/prev-settlement, tick size, listed/de-listed dates, and dominant contracts from data proxy. | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. |
|
||||||
| 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 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 | 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. |
|
| 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 | 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, equity curve, benchmark series, metrics, and JSON report bundle via `BacktestResult::analyzer_report(_json)`. | UI/service download endpoints can serialize this report directly. |
|
| P2 | Analyzer/report parity | RQAlpha analyser can export richer trades, positions, benchmark, monthly returns, risk, and summary artifacts. | Closed for normalized trades, positions, monthly returns, risk summary, equity curve, benchmark series, metrics, and JSON report bundle via `BacktestResult::analyzer_report(_json)`. | UI/service download endpoints can serialize this report directly. |
|
||||||
| P3 | Mod/config/plugin architecture | RQAlpha has pluggable mods, event bus extension points, and many config toggles. | 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. |
|
| 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
|
## Remaining Gaps
|
||||||
|
|
||||||
@@ -160,6 +160,7 @@ Parity gaps found by this pass and current closure state:
|
|||||||
- [x] `get_securities_margin`
|
- [x] `get_securities_margin`
|
||||||
- [x] `get_dominant_future`
|
- [x] `get_dominant_future`
|
||||||
- [x] futures dominant price helpers
|
- [x] futures dominant price helpers
|
||||||
|
- [x] platform DSL helper aliases for advanced RQData-style APIs
|
||||||
|
|
||||||
### Phase 11: Analyzer / report parity
|
### 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] benchmark / monthly returns / risk summary artifacts
|
||||||
- [x] downloadable analyser output bundle
|
- [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
|
## Execution Order
|
||||||
|
|
||||||
1. Close the explicit order API gap with target-shares / `order_to` parity.
|
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.
|
settlement-price integration.
|
||||||
13. Add advanced RQData helper APIs where source data exists.
|
13. Add advanced RQData helper APIs where source data exists.
|
||||||
14. Add analyser/report artifact parity.
|
14. Add analyser/report artifact parity.
|
||||||
|
15. Add lightweight process-mod extension hooks; only add concrete mods when
|
||||||
|
production needs them.
|
||||||
|
|
||||||
## Current Step
|
## Current Step
|
||||||
|
|
||||||
Active implementation target: P0-P2 parity items are implemented in the engine
|
Active implementation target: P0-P2 parity items are implemented in the engine
|
||||||
core. Remaining future work should be driven by concrete production strategy or
|
core, and P3 now has a lightweight event-driven extension hook. Remaining
|
||||||
UI requirements rather than recreating RQAlpha's full plugin/mod framework.
|
future work should be driven by concrete production strategy or UI requirements,
|
||||||
|
especially for data-dependent futures depth matching and exchange-specific
|
||||||
|
validators.
|
||||||
|
|||||||
Reference in New Issue
Block a user