From 882053e12b8d6a1ab7bab3fed9d34a7f0724c16e Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 22:04:55 -0700 Subject: [PATCH] Add RQData factor helper APIs --- crates/fidc-core/src/data.rs | 353 ++++++++++++++++++ .../fidc-core/src/platform_expr_strategy.rs | 210 +++++++++++ crates/fidc-core/src/strategy.rs | 84 +++++ crates/fidc-core/src/strategy_ai.rs | 8 +- crates/fidc-core/tests/engine_hooks.rs | 47 ++- docs/rqalpha-gap-roadmap.md | 7 +- 6 files changed, 704 insertions(+), 5 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 061fe65..8e4d3a8 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -1373,6 +1373,188 @@ impl DataSet { .collect() } + pub fn get_shares( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + share_type: &str, + ) -> Vec { + self.get_first_available_factor_series( + symbol, + start, + end, + &shares_factor_aliases(share_type), + &format!("shares_{}", normalize_field(share_type)), + ) + } + + pub fn get_turnover_rate( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.get_first_available_factor_series( + symbol, + start, + end, + &turnover_rate_factor_aliases(field), + &format!("turnover_rate_{}", normalize_field(field)), + ) + } + + pub fn get_price_change_rate( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + ) -> Vec { + if start > end { + return Vec::new(); + } + let mut rows = self + .market_by_date + .range(start..=end) + .flat_map(|(_, snapshots)| snapshots.iter()) + .filter(|snapshot| snapshot.symbol == symbol) + .filter_map(|snapshot| { + if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 { + Some(FactorValue { + date: snapshot.date, + symbol: snapshot.symbol.clone(), + field: "price_change_rate".to_string(), + value: snapshot.close / snapshot.prev_close - 1.0, + }) + } else { + None + } + }) + .collect::>(); + if rows.is_empty() { + rows = self.get_first_available_factor_series( + symbol, + start, + end, + &[ + "price_change_rate".to_string(), + "change_rate".to_string(), + "pct_change".to_string(), + ], + "price_change_rate", + ); + } + rows.sort_by_key(|row| row.date); + rows + } + + pub fn get_stock_connect( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.get_first_available_factor_series( + symbol, + start, + end, + &stock_connect_factor_aliases(field), + &format!("stock_connect_{}", normalize_field(field)), + ) + } + + pub fn current_performance( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.get_first_available_factor_series( + symbol, + start, + end, + &prefixed_factor_aliases("current_performance", field), + field, + ) + } + + pub fn get_fundamentals( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.get_first_available_factor_series( + symbol, + start, + end, + &prefixed_factor_aliases("fundamental", field), + field, + ) + } + + pub fn get_financials( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.get_first_available_factor_series( + symbol, + start, + end, + &prefixed_factor_aliases("financial", field), + field, + ) + } + + pub fn get_pit_financials( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.get_first_available_factor_series( + symbol, + start, + end, + &prefixed_factor_aliases("pit_financial", field), + field, + ) + } + + pub fn get_industry( + &self, + symbol: &str, + date: NaiveDate, + source: &str, + level: usize, + ) -> Option { + let fields = industry_factor_aliases(source, level); + for (factor_date, snapshots) in self.factor_by_date.range(..=date).rev() { + let Some(snapshot) = snapshots.iter().find(|row| row.symbol == symbol) else { + continue; + }; + for field in &fields { + if let Some(value) = factor_numeric_value(snapshot, field) { + return Some(FactorValue { + date: *factor_date, + symbol: snapshot.symbol.clone(), + field: field.clone(), + value, + }); + } + } + } + None + } + pub fn get_dominant_future(&self, underlying_symbol: &str, date: NaiveDate) -> Option { let underlying = normalize_field(underlying_symbol); let mut candidates = self @@ -1614,6 +1796,39 @@ impl DataSet { .and_then(|snapshot| factor_numeric_value(snapshot, field)) } + fn get_first_available_factor_series( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + fields: &[String], + output_field: &str, + ) -> Vec { + if start > end { + return Vec::new(); + } + let output_field = normalize_field(output_field); + let mut rows = Vec::new(); + for (_, snapshots) in self.factor_by_date.range(start..=end) { + let Some(snapshot) = snapshots.iter().find(|row| row.symbol == symbol) else { + continue; + }; + for field in fields { + if let Some(value) = factor_numeric_value(snapshot, field) { + rows.push(FactorValue { + date: snapshot.date, + symbol: snapshot.symbol.clone(), + field: output_field.clone(), + value, + }); + break; + } + } + } + rows.sort_by_key(|row| row.date); + rows + } + pub fn factor_moving_average( &self, date: NaiveDate, @@ -1838,6 +2053,144 @@ fn read_factors(path: &Path) -> Result, DataSetError> { Ok(snapshots) } +fn normalized_aliases(values: &[String]) -> Vec { + let mut aliases = Vec::new(); + for value in values { + let normalized = normalize_field(value); + if !aliases.contains(&normalized) { + aliases.push(normalized); + } + } + aliases +} + +fn shares_factor_aliases(share_type: &str) -> Vec { + let field = normalize_field(share_type); + let values = match field.as_str() { + "" | "all" | "total" => vec![ + "total_shares", + "shares_total", + "total_share", + "total_share_capital", + "capitalization", + "shares", + ], + "float" | "free_float" | "circulating" | "circulation" => vec![ + "free_float_shares", + "float_shares", + "circulating_shares", + "circulation_shares", + "float_a_shares", + ], + "a" | "a_share" | "a_shares" => vec!["a_shares", "shares_a", "a_share_capital"], + other => { + return normalized_aliases(&[ + other.to_string(), + format!("shares_{other}"), + format!("{other}_shares"), + ]); + } + }; + normalized_aliases( + &values + .iter() + .map(|value| value.to_string()) + .collect::>(), + ) +} + +fn turnover_rate_factor_aliases(field: &str) -> Vec { + let field = normalize_field(field); + let values = match field.as_str() { + "" | "all" | "rate" | "turnover" | "turnover_rate" | "turnover_ratio" => { + vec!["turnover_rate", "turnover_ratio"] + } + "effective" | "effective_turnover" | "effective_turnover_rate" => { + vec!["effective_turnover_rate", "effective_turnover_ratio"] + } + other => { + return normalized_aliases(&[ + other.to_string(), + format!("turnover_rate_{other}"), + format!("{other}_turnover_rate"), + format!("turnover_ratio_{other}"), + format!("{other}_turnover_ratio"), + ]); + } + }; + normalized_aliases( + &values + .iter() + .map(|value| value.to_string()) + .collect::>(), + ) +} + +fn stock_connect_factor_aliases(field: &str) -> Vec { + let field = normalize_field(field); + let values = match field.as_str() { + "" | "all" | "connect" | "stock_connect" => { + vec![ + "stock_connect", + "stock_connect_all", + "connect_all", + "north_bound", + ] + } + "north" | "north_bound" | "northbound" => vec![ + "stock_connect_north_bound", + "stock_connect_northbound", + "connect_north_bound", + "north_bound", + "northbound", + ], + "south" | "south_bound" | "southbound" => vec![ + "stock_connect_south_bound", + "stock_connect_southbound", + "connect_south_bound", + "south_bound", + "southbound", + ], + other => { + return normalized_aliases(&[ + other.to_string(), + format!("stock_connect_{other}"), + format!("connect_{other}"), + ]); + } + }; + normalized_aliases( + &values + .iter() + .map(|value| value.to_string()) + .collect::>(), + ) +} + +fn prefixed_factor_aliases(prefix: &str, field: &str) -> Vec { + let prefix = normalize_field(prefix); + let field = normalize_field(field); + let plural_prefix = format!("{prefix}s"); + normalized_aliases(&[ + format!("{prefix}_{field}"), + format!("{plural_prefix}_{field}"), + field.clone(), + ]) +} + +fn industry_factor_aliases(source: &str, level: usize) -> Vec { + let source = normalize_field(source); + normalized_aliases(&[ + format!("industry_{source}_l{level}"), + format!("industry_{source}_{level}"), + format!("{source}_industry_l{level}"), + format!("{source}_industry_{level}"), + format!("industry_l{level}"), + format!("industry_{level}"), + "industry_code".to_string(), + ]) +} + fn factor_numeric_value(snapshot: &DailyFactorSnapshot, field: &str) -> Option { match field { "market_cap" | "market_cap_bn" => Some(snapshot.market_cap_bn), diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 4b9b27a..feaee71 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -642,6 +642,23 @@ impl PlatformExprStrategy { | "has_split" | "securities_margin" | "get_securities_margin_value" + | "shares" + | "get_shares_value" + | "turnover_rate" + | "get_turnover_rate_value" + | "price_change_rate" + | "get_price_change_rate_value" + | "stock_connect" + | "get_stock_connect_value" + | "current_performance" + | "fundamental" + | "get_fundamentals_value" + | "financial" + | "get_financials_value" + | "pit_financial" + | "get_pit_financials_value" + | "industry_code" + | "get_industry_code" | "yield_curve" | "get_yield_curve_value" | "is_margin_stock" @@ -2143,6 +2160,143 @@ impl PlatformExprStrategy { .unwrap_or(0.0); Ok(Self::format_rhai_float(value)) } + "shares" | "get_shares_value" => { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let (field, lookback) = + Self::parse_field_lookback_helper_args(helper, &args, "total")?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_shares(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "turnover_rate" | "get_turnover_rate_value" => { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let (field, lookback) = + Self::parse_field_lookback_helper_args(helper, &args, "turnover_rate")?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_turnover_rate(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "price_change_rate" | "get_price_change_rate_value" => { + if args.len() > 1 { + return Err(BacktestError::Execution(format!( + "{helper} expects optional lookback" + ))); + } + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let lookback = Self::parse_optional_positive_usize(args.first(), 1)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_price_change_rate(&stock.symbol, start, day.date) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "stock_connect" | "get_stock_connect_value" => { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let (field, lookback) = + Self::parse_field_lookback_helper_args(helper, &args, "all")?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_stock_connect(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "current_performance" => { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let (field, lookback) = + Self::parse_required_field_lookback_helper_args(helper, &args)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .current_performance(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "fundamental" | "get_fundamentals_value" => { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let (field, lookback) = + Self::parse_required_field_lookback_helper_args(helper, &args)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_fundamentals(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "financial" | "get_financials_value" => { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let (field, lookback) = + Self::parse_required_field_lookback_helper_args(helper, &args)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_financials(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "pit_financial" | "get_pit_financials_value" => { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let (field, lookback) = + Self::parse_required_field_lookback_helper_args(helper, &args)?; + let start = self.helper_start_date(ctx, day.date, lookback); + let value = ctx + .get_pit_financials(&stock.symbol, start, day.date, &field) + .last() + .map(|row| row.value) + .unwrap_or(0.0); + Ok(Self::format_rhai_float(value)) + } + "industry_code" | "get_industry_code" => { + if args.len() > 2 { + return Err(BacktestError::Execution(format!( + "{helper} expects optional source and optional level" + ))); + } + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!("{helper} requires stock context")) + })?; + let source = args + .first() + .map(|arg| Self::parse_string_or_identifier(arg)) + .transpose()? + .unwrap_or_else(|| "citics".to_string()); + let level = Self::parse_optional_positive_usize(args.get(1), 1)?; + let value = ctx + .get_industry(&stock.symbol, &source, level) + .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!( @@ -2264,6 +2418,46 @@ impl PlatformExprStrategy { )) } + fn parse_field_lookback_helper_args( + helper: &str, + args: &[String], + default_field: &str, + ) -> Result<(String, usize), BacktestError> { + if args.len() > 2 { + return Err(BacktestError::Execution(format!( + "{helper} expects optional field and optional lookback" + ))); + } + if args.is_empty() { + return Ok((default_field.to_string(), 1)); + } + if args.len() == 1 { + if let Ok(lookback) = Self::parse_positive_usize(&args[0]) { + return Ok((default_field.to_string(), lookback)); + } + return Ok((Self::parse_string_or_identifier(&args[0])?, 1)); + } + Ok(( + Self::parse_string_or_identifier(&args[0])?, + Self::parse_positive_usize(&args[1])?, + )) + } + + fn parse_required_field_lookback_helper_args( + helper: &str, + args: &[String], + ) -> Result<(String, usize), BacktestError> { + if args.is_empty() || args.len() > 2 { + return Err(BacktestError::Execution(format!( + "{helper} expects field and optional lookback" + ))); + } + Ok(( + Self::parse_string_or_identifier(&args[0])?, + Self::parse_optional_positive_usize(args.get(1), 1)?, + )) + } + fn parse_optional_positive_usize( raw: Option<&String>, fallback: usize, @@ -4517,6 +4711,13 @@ mod tests { ("custom_alpha".to_string(), 7.0), ("margin_all".to_string(), 1.0), ("yield_curve_1y".to_string(), 0.02), + ("total_shares".to_string(), 123.0), + ("stock_connect_north_bound".to_string(), 1.0), + ("industry_citics_l1".to_string(), 10.0), + ("fundamental_net_profit".to_string(), 99.0), + ("financial_revenue".to_string(), 188.0), + ("pit_financial_eps".to_string(), 0.88), + ("current_performance_roe".to_string(), 12.0), ]), }], vec![CandidateEligibility { @@ -4604,6 +4805,15 @@ mod tests { " && factor_value(\"custom_alpha\") == 7.0", " && securities_margin(\"margin_all\") == 1.0", " && is_margin_stock(\"all\")", + " && shares(\"total\") == 123.0", + " && turnover_rate(\"effective\") == 18.0", + " && price_change_rate() > 0.015", + " && stock_connect(\"north_bound\") == 1.0", + " && industry_code(\"citics\", 1) == 10.0", + " && fundamental(\"net_profit\") == 99.0", + " && financial(\"revenue\") == 188.0", + " && pit_financial(\"eps\") > 0.87", + " && current_performance(\"roe\") == 12.0", " && yield_curve(\"1y\") > 0.019", " && dominant_future(\"IF\") == \"IF2501\"", " && dominant_future_price(\"IF\", \"close\") == 4000.0", diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index dce5023..70e91f7 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -654,6 +654,90 @@ impl StrategyContext<'_> { self.data.get_securities_margin(symbol, start, end, field) } + pub fn get_shares( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + share_type: &str, + ) -> Vec { + self.data.get_shares(symbol, start, end, share_type) + } + + pub fn get_turnover_rate( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.data.get_turnover_rate(symbol, start, end, field) + } + + pub fn get_price_change_rate( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + ) -> Vec { + self.data.get_price_change_rate(symbol, start, end) + } + + pub fn get_stock_connect( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.data.get_stock_connect(symbol, start, end, field) + } + + pub fn current_performance( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.data.current_performance(symbol, start, end, field) + } + + pub fn get_fundamentals( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.data.get_fundamentals(symbol, start, end, field) + } + + pub fn get_financials( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.data.get_financials(symbol, start, end, field) + } + + pub fn get_pit_financials( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.data.get_pit_financials(symbol, start, end, field) + } + + pub fn get_industry(&self, symbol: &str, source: &str, level: usize) -> Option { + self.data + .get_industry(symbol, self.execution_date, source, level) + } + pub fn get_dominant_future(&self, underlying_symbol: &str) -> Option { self.data .get_dominant_future(underlying_symbol, self.execution_date) diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 341837d..c99c3d4 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -216,6 +216,12 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { 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_shares / shares".to_string(), signature: "shares(\"total\" | \"free_float\", lookback=1)".to_string(), detail: "股本 API。shares(\"total\") 会依次读取 total_shares/shares_total/total_share_capital 等字段;shares(\"free_float\") 会读取 free_float_shares/float_shares/circulating_shares 等字段;Rust Context 可用 ctx.get_shares(symbol, start, end, share_type)。".to_string() }, + ManualFunction { name: "get_turnover_rate / turnover_rate".to_string(), signature: "turnover_rate(\"turnover\" | \"effective\", lookback=1)".to_string(), detail: "换手率 API。turnover_rate(\"turnover\") 读取 turnover_rate/turnover_ratio;turnover_rate(\"effective\") 读取 effective_turnover_rate/effective_turnover_ratio;也可传任意字段名映射数据库因子。".to_string() }, + ManualFunction { name: "get_price_change_rate / price_change_rate".to_string(), signature: "price_change_rate(lookback=1)".to_string(), detail: "涨跌幅 API,默认按日行情 close / prev_close - 1 计算,缺少行情时回退 factors 中的 price_change_rate/change_rate/pct_change。返回小数,例如 0.1 表示上涨 10%。".to_string() }, + ManualFunction { name: "get_stock_connect / stock_connect".to_string(), signature: "stock_connect(\"north_bound\" | \"south_bound\" | \"all\", lookback=1)".to_string(), detail: "陆股通/互联互通标记 API,从 stock_connect_north_bound、north_bound、stock_connect_south_bound 等因子读取,返回数值标记。".to_string() }, + ManualFunction { name: "current_performance / fundamental / financial / pit_financial".to_string(), signature: "fundamental(\"net_profit\", lookback=1)".to_string(), detail: "财务与基本面 API。它们都是对 factors 的通用映射:fundamental(field) 会依次读取 fundamental_field / fundamentals_field / field,financial(field) 读取 financial_field / financials_field / field,pit_financial(field) 读取 pit_financial_field / pit_financials_field / field,current_performance(field) 读取 current_performance_field / current_performances_field / field。".to_string() }, + ManualFunction { name: "get_industry / industry_code".to_string(), signature: "industry_code(\"citics\", 1)".to_string(), detail: "行业 API。当前 core 的 factors 仅承载数值字段,因此行业先支持数值 code:按 industry_citics_l1、industry_citics_1、citics_industry_l1、industry_code 等字段读取最近可用值;字符串行业名称需要数据链路扩展字符串型因子后再暴露。".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() }, @@ -239,7 +245,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualFactorSource { table: "fi_data_center.stock_indicator_factors_v1".to_string(), - detail: "股票指标因子原表,可映射进 factors[...]。".to_string(), + detail: "股票指标因子原表,可映射进 factors[...]。股本、换手率、财务、陆股通、行业 code 等 RQData 风格 API 均优先从这里或 bt_daily_features_v1 的 extra_factors 中读取。".to_string(), fields: vec![], }, ManualFactorSource { diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 0ae142c..b22b1f6 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -205,6 +205,10 @@ fn two_day_futures_data() -> DataSet { ("custom_alpha".to_string(), 7.0), ("margin_all".to_string(), 1.0), ("yield_curve_1y".to_string(), 0.02), + ("total_shares".to_string(), 123.0), + ("stock_connect_north_bound".to_string(), 1.0), + ("industry_citics_l1".to_string(), 10.0), + ("fundamental_net_profit".to_string(), 99.0), ]), ), factor_row( @@ -214,6 +218,10 @@ fn two_day_futures_data() -> DataSet { ("custom_alpha".to_string(), 8.0), ("margin_all".to_string(), 1.0), ("yield_curve_1y".to_string(), 0.021), + ("total_shares".to_string(), 124.0), + ("stock_connect_north_bound".to_string(), 1.0), + ("industry_citics_l1".to_string(), 10.0), + ("fundamental_net_profit".to_string(), 101.0), ]), ), ], @@ -556,13 +564,44 @@ impl Strategy for AdvancedDataApiProbeStrategy { let dominant = ctx.get_dominant_future("IF").unwrap_or_default(); let dominant_prices = ctx.get_dominant_future_price("IF", ctx.execution_date, ctx.execution_date, "1d"); + let shares = ctx.get_shares("000001.SZ", ctx.execution_date, ctx.execution_date, "total"); + let turnover = ctx.get_turnover_rate( + "000001.SZ", + ctx.execution_date, + ctx.execution_date, + "turnover", + ); + let price_change = + ctx.get_price_change_rate("000001.SZ", ctx.execution_date, ctx.execution_date); + let stock_connect = ctx.get_stock_connect( + "000001.SZ", + ctx.execution_date, + ctx.execution_date, + "north_bound", + ); + let industry = ctx + .get_industry("000001.SZ", "citics", 1) + .map(|row| row.value) + .unwrap_or_default(); + let fundamentals = ctx.get_fundamentals( + "000001.SZ", + ctx.execution_date, + ctx.execution_date, + "net_profit", + ); self.observed.borrow_mut().push(format!( - "factor={:.0};margin={};yield={:.3};dominant={};prices={}", + "factor={:.0};margin={};yield={:.3};dominant={};prices={};shares={:.0};turnover={:.1};change={:.3};connect={:.0};industry={:.0};profit={:.0}", factors.first().map(|row| row.value).unwrap_or_default(), margin_stocks.join(","), yield_curve.first().map(|row| row.value).unwrap_or_default(), dominant, - dominant_prices.len() + dominant_prices.len(), + shares.first().map(|row| row.value).unwrap_or_default(), + turnover.first().map(|row| row.value).unwrap_or_default(), + price_change.first().map(|row| row.value).unwrap_or_default(), + stock_connect.first().map(|row| row.value).unwrap_or_default(), + industry, + fundamentals.first().map(|row| row.value).unwrap_or_default() )); Ok(StrategyDecision::default()) } @@ -1857,7 +1896,9 @@ fn strategy_context_exposes_advanced_rqdata_helpers() { assert_eq!( observed.borrow().as_slice(), - &["factor=7;margin=000001.SZ;yield=0.020;dominant=IF2501;prices=1"] + &[ + "factor=7;margin=000001.SZ;yield=0.020;dominant=IF2501;prices=1;shares=123;turnover=1.0;change=0.000;connect=1;industry=10;profit=99" + ] ); assert!(result.analyzer_report().positions.is_empty()); } diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index 28efedf..0b449be 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -51,7 +51,7 @@ Parity gaps found by this pass and current closure state: | 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, active-contract, trading-phase, tick-aligned limit price, price-limit, self-trade crossing risk, paused/no executable price, margin, and close-position rejection diagnostics. These submission validators are controlled by `FuturesValidationConfig` so service-level callers can relax individual checks for compatibility tests or vendor-specific rules. | Add more exchange metadata columns only when source data exposes 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 | RQData helper APIs | RQAlpha exposes `get_dividend`, `get_split`, `get_yield_curve`, `get_factor`, `get_margin_stocks`, `get_securities_margin`, `get_shares`, `get_turnover_rate`, `get_price_change_rate`, industry, stock-connect, fundamentals/financials/PIT-financials, `get_dominant_future`, and dominant futures price APIs. | Closed for the engine-native data model. These APIs are available through `DataSet` and `StrategyContext`; platform expressions expose focused helpers such as `dividend_cash`, `factor_value`, `yield_curve`, `is_margin_stock`, `shares`, `turnover_rate`, `price_change_rate`, `stock_connect`, `industry_code`, `fundamental`, `financial`, `pit_financial`, `current_performance`, `dominant_future`, and `dominant_future_price`. String-valued industry names remain a data-model extension because current factors are numeric. | Add string factor support only if source data exposes non-numeric categories. | | 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. | Closed for a lightweight engine-native model: `BacktestProcessMod`, `BacktestProcessModLoader`, enabled-name installation, and event-bus lifecycle hooks. It intentionally avoids RQAlpha's Python global mod loader. | Add concrete production mods/toggles as requirements appear. | @@ -161,6 +161,11 @@ Parity gaps found by this pass and current closure state: - [x] `get_factor` - [x] `get_margin_stocks` - [x] `get_securities_margin` +- [x] `get_shares` +- [x] `get_turnover_rate` +- [x] `get_price_change_rate` +- [x] stock-connect, industry-code, fundamentals, financials, PIT-financials, + and current-performance factor wrappers - [x] `get_dominant_future` - [x] futures dominant price helpers - [x] platform DSL helper aliases for advanced RQData-style APIs