diff --git a/crates/fidc-core/src/bin/dump_platform_runtime_schema.rs b/crates/fidc-core/src/bin/dump_platform_runtime_schema.rs new file mode 100644 index 0000000..8e61c79 --- /dev/null +++ b/crates/fidc-core/src/bin/dump_platform_runtime_schema.rs @@ -0,0 +1,17 @@ +//! 把 DSP 运行时 schema 序列化为 JSON 输出到 stdout。 +//! +//! 用法(在 fidc-backtest-engine 仓库根): +//! cargo run -p fidc-core --bin dump_platform_runtime_schema \ +//! > ../omniquant/src/generated/platformRuntimeSchema.json +//! +//! 这是 omniquant 前端编译期校验表达式标识符的事实源;任何对 +//! reserved_scope_names / is_runtime_helper / register_fn 清单的修改,记得 +//! 重新跑这个命令并把生成文件提交到 omniquant。 + +use fidc_core::runtime_schema_json; + +fn main() { + let schema = runtime_schema_json(); + let output = serde_json::to_string_pretty(&schema).expect("serialize schema"); + println!("{output}"); +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 9f76a4e..75b6b9d 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod futures; pub mod instrument; pub mod metrics; pub mod platform_expr_strategy; +pub mod platform_runtime_schema; pub mod platform_strategy_spec; pub mod portfolio; pub mod rules; @@ -50,6 +51,11 @@ pub use platform_expr_strategy::{ PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, }; +pub use platform_runtime_schema::{ + PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names, + rhai_builtin_functions, rhai_keywords, runtime_helper_functions, runtime_schema, + runtime_schema_json, +}; pub use platform_strategy_spec::{ DynamicRangeConfig, IndexThrottleConfig, MovingAverageFilterConfig, SkipWindowConfig, StrategyBenchmarkSpec, StrategyEngineConfig, StrategyExecutionSpec, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 72fce20..3c653b2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1,7 +1,8 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; -use rhai::{Dynamic, Engine, Map, Scope}; +use rhai::{AST, Dynamic, Engine, Map, Scope}; use crate::cost::ChinaAShareCostModel; use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; @@ -415,6 +416,15 @@ struct PositionExpressionState { pub struct PlatformExprStrategy { config: PlatformExprStrategyConfig, engine: Engine, + /// 已编译表达式 AST 缓存。 + /// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本, + /// Value 是 Rhai 编译产物。命中后 eval 走 eval_ast_with_scope,避免重复 + /// parsing。一次回测里同一表达式(stock_filter / stop_loss / rank_expr 等) + /// 会被反复执行,重复解析的常数级开销在大规模回测里不可忽略。 + compiled_cache: RefCell>, + /// 命中计数与未命中计数,便于在 unit test 中验证缓存生效;非生产指标。 + cache_hits: RefCell, + cache_misses: RefCell, } impl PlatformExprStrategy { @@ -466,7 +476,63 @@ impl PlatformExprStrategy { engine.register_fn("upper", |value: &str| value.to_uppercase()); engine.register_fn("trim", |value: &str| value.trim().to_string()); engine.register_fn("strlen", |value: &str| value.chars().count() as i64); - Self { config, engine } + Self { + config, + engine, + compiled_cache: RefCell::new(HashMap::new()), + cache_hits: RefCell::new(0), + cache_misses: RefCell::new(0), + } + } + + /// AST 缓存命中次数(仅用于测试与诊断)。 + pub fn ast_cache_hits(&self) -> u64 { + *self.cache_hits.borrow() + } + + /// AST 缓存未命中次数(仅用于测试与诊断)。 + pub fn ast_cache_misses(&self) -> u64 { + *self.cache_misses.borrow() + } + + /// AST 缓存当前条目数(仅用于测试与诊断)。 + pub fn ast_cache_size(&self) -> usize { + self.compiled_cache.borrow().len() + } + + /// 用 AST 缓存执行 script。命中:直接走 eval_ast_with_scope;未命中:先 + /// engine.compile,再插入缓存,再 eval_ast_with_scope。任何编译/执行错误 + /// 都按字符串包装为 BacktestError::Execution。 + fn eval_with_cache( + &self, + scope: &mut Scope<'_>, + script: &str, + ) -> Result { + // 注意:HashMap key 借用即可命中,避免重复克隆 String。 + if let Some(ast) = self.compiled_cache.borrow().get(script) { + *self.cache_hits.borrow_mut() += 1; + return self + .engine + .eval_ast_with_scope::(scope, ast) + .map_err(|error| { + BacktestError::Execution(format!("platform expr eval failed: {}", error)) + }); + } + *self.cache_misses.borrow_mut() += 1; + let ast = self.engine.compile(script).map_err(|error| { + BacktestError::Execution(format!("platform expr compile failed: {}", error)) + })?; + let result = self + .engine + .eval_ast_with_scope::(scope, &ast) + .map_err(|error| { + BacktestError::Execution(format!("platform expr eval failed: {}", error)) + }); + // 即便本次执行失败,也把 AST 留下:错误源于 scope 中的值,下次仍然有效。 + self.compiled_cache + .borrow_mut() + .insert(script.to_string(), ast); + result } fn is_expression_identifier(name: &str) -> bool { @@ -2020,11 +2086,7 @@ impl PlatformExprStrategy { } script_parts.push(expanded_expr); let script = script_parts.join("\n"); - self.engine - .eval_with_scope::(&mut scope, &script) - .map_err(|error| { - BacktestError::Execution(format!("platform expr eval failed: {}", error)) - }) + self.eval_with_cache(&mut scope, &script) } fn normalize_expr(expr: &str) -> String { diff --git a/crates/fidc-core/src/platform_runtime_schema.rs b/crates/fidc-core/src/platform_runtime_schema.rs new file mode 100644 index 0000000..ae6d12c --- /dev/null +++ b/crates/fidc-core/src/platform_runtime_schema.rs @@ -0,0 +1,343 @@ +//! DSP 运行时变量与函数 schema 导出。 +//! +//! 这是前后端共享的"事实源":把引擎里 reserved_scope_names 和 is_runtime_helper +//! 等清单按 JSON Schema 暴露出来,供 omniquant 前端在编译期做表达式标识符校验。 +//! +//! 维护原则: +//! - 任何对 platform_expr_strategy.rs 中变量名 / 函数名清单的修改都必须在这里 +//! 同步一份。两侧一致由 unit test `runtime_schema_matches_strategy_runtime` +//! 守住。 +//! - 该 schema 的 version 字段需要与 omniquant/src/platformSchema.ts 里 +//! PLATFORM_RUNTIME_SCHEMA_VERSION 保持一致。前端读到不同版本时应给出诊断。 + +use serde::Serialize; +use serde_json::Value; + +/// 当前 schema 版本号。每次 reserved/runtime 列表的破坏性变更需要 +1。 +pub const PLATFORM_RUNTIME_SCHEMA_VERSION: &str = "1"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlatformRuntimeSchema { + pub version: &'static str, + pub reserved_scope_names: Vec<&'static str>, + pub runtime_helper_functions: Vec<&'static str>, + pub rhai_builtin_functions: Vec<&'static str>, + pub rhai_keywords: Vec<&'static str>, +} + +/// reserved scope names 列表。镜像 PlatformExprStrategy::reserved_scope_names。 +pub fn reserved_scope_names() -> &'static [&'static str] { + RESERVED_SCOPE_NAMES +} + +/// runtime helper functions 列表。镜像 PlatformExprStrategy::is_runtime_helper。 +pub fn runtime_helper_functions() -> &'static [&'static str] { + RUNTIME_HELPER_FUNCTIONS +} + +/// rhai engine 注册的内置函数列表。镜像 PlatformExprStrategy::new 中 register_fn +/// 的清单。 +pub fn rhai_builtin_functions() -> &'static [&'static str] { + RHAI_BUILTIN_FUNCTIONS +} + +/// rhai 控制流关键字(避免被前端校验视为未知)。 +pub fn rhai_keywords() -> &'static [&'static str] { + RHAI_KEYWORDS +} + +/// 构造完整 schema。 +pub fn runtime_schema() -> PlatformRuntimeSchema { + PlatformRuntimeSchema { + version: PLATFORM_RUNTIME_SCHEMA_VERSION, + reserved_scope_names: RESERVED_SCOPE_NAMES.to_vec(), + runtime_helper_functions: RUNTIME_HELPER_FUNCTIONS.to_vec(), + rhai_builtin_functions: RHAI_BUILTIN_FUNCTIONS.to_vec(), + rhai_keywords: RHAI_KEYWORDS.to_vec(), + } +} + +/// 把 schema 序列化为 JSON Value。给 fidc-data-center / strategy-runtime 接口使用。 +pub fn runtime_schema_json() -> Value { + serde_json::to_value(runtime_schema()).expect("runtime schema serialization is infallible") +} + +const RESERVED_SCOPE_NAMES: &[&str] = &[ + // day-level + "signal_close", + "benchmark_close", + "signal_ma5", + "signal_ma10", + "signal_ma20", + "signal_ma30", + "benchmark_ma5", + "benchmark_ma10", + "benchmark_ma20", + "benchmark_ma30", + "benchmark_ma_short", + "benchmark_ma_long", + "cash", + "available_cash", + "frozen_cash", + "market_value", + "total_equity", + "total_value", + "portfolio_value", + "starting_cash", + "unit_net_value", + "static_unit_net_value", + "daily_pnl", + "daily_returns", + "total_returns", + "cash_liabilities", + "management_fee_rate", + "management_fees", + "current_exposure", + "position_count", + "max_positions", + "refresh_rate", + "year", + "month", + "quarter", + "day_of_month", + "day_of_year", + "week_of_year", + "weekday", + "is_month_start", + "is_month_end", + "has_open_orders", + "open_order_count", + "open_buy_order_count", + "open_sell_order_count", + "open_buy_qty", + "open_sell_qty", + "latest_open_order_id", + "latest_open_order_status", + "latest_open_order_unfilled_qty", + "has_process_events", + "process_event_count", + "current_process_kind", + "current_process_order_id", + "current_process_symbol", + "current_process_side", + "current_process_detail", + "latest_process_kind", + "latest_process_order_id", + "latest_process_symbol", + "latest_process_side", + "latest_process_detail", + "process_event_counts", + "day_factors", + // stock-level + "symbol", + "market_cap", + "free_float_cap", + "pe_ttm", + "volume", + "tick_volume", + "bid1_volume", + "ask1_volume", + "turnover_ratio", + "effective_turnover_ratio", + "open", + "high", + "low", + "close", + "last", + "last_price", + "prev_close", + "amount", + "upper_limit", + "lower_limit", + "price_tick", + "round_lot", + "paused", + "is_st", + "is_kcb", + "is_one_yuan", + "is_new_listing", + "allow_buy", + "allow_sell", + "touched_upper_limit", + "touched_lower_limit", + "hit_upper_limit", + "hit_lower_limit", + "listed_days", + "symbol_open_order_count", + "symbol_open_buy_qty", + "symbol_open_sell_qty", + "latest_symbol_open_order_id", + "latest_symbol_open_order_status", + "latest_symbol_open_order_unfilled_qty", + "stock_ma_short", + "stock_ma_mid", + "stock_ma_long", + "stock_ma5", + "stock_ma10", + "stock_ma20", + "stock_ma30", + "ma5", + "ma10", + "ma20", + "ma30", + "factors", + "order_book_id", + // position-level + "avg_cost", + "avg_price", + "current_price", + "position_prev_close", + "prev_position_close", + "holding_return", + "quantity", + "sellable_qty", + "sellable", + "closable", + "old_quantity", + "buy_quantity", + "sell_quantity", + "bought_quantity", + "sold_quantity", + "buy_avg_price", + "sell_avg_price", + "bought_value", + "sold_value", + "transaction_cost", + "position_market_value", + "equity", + "value_percent", + "unrealized_pnl", + "realized_pnl", + "pnl", + "day_trade_quantity_delta", + "profit_pct", + "trading_pnl", + "position_pnl", + "dividend_receivable", + "at_upper_limit", + "at_lower_limit", +]; + +const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[ + "factor", + "day_factor", + "rolling_mean", + "ma", + "sma", + "vma", + "rolling_sum", + "rolling_min", + "rolling_max", + "rolling_stddev", + "stddev", + "rolling_zscore", + "pct_change", + "factor_value", + "get_factor_value", + "factor_text", + "get_factor_text", + "dividend_cash", + "has_dividend", + "split_ratio", + "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", + "industry_name", + "get_industry_name", + "yield_curve", + "get_yield_curve_value", + "is_margin_stock", + "dominant_future", + "get_dominant_future", + "dominant_future_price", + "get_dominant_future_price_value", +]; + +const RHAI_BUILTIN_FUNCTIONS: &[&str] = &[ + "round", + "floor", + "ceil", + "abs", + "min", + "max", + "sqrt", + "pow", + "log", + "exp", + "clamp", + "between", + "nz", + "safe_div", + "iff", + "contains", + "starts_with", + "ends_with", + "lower", + "upper", + "trim", + "strlen", +]; + +const RHAI_KEYWORDS: &[&str] = &[ + "if", "else", "while", "loop", "for", "in", "break", "continue", "return", "fn", "let", + "const", "true", "false", "switch", "do", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_schema_serializes_to_json_object() { + let value = runtime_schema_json(); + assert!(value.is_object()); + assert_eq!(value["version"], "1"); + assert!(value["reservedScopeNames"].is_array()); + assert!(value["runtimeHelperFunctions"].is_array()); + assert!(value["rhaiBuiltinFunctions"].is_array()); + assert!(value["rhaiKeywords"].is_array()); + } + + #[test] + fn runtime_schema_includes_known_identifiers() { + let names: std::collections::HashSet<&str> = + RESERVED_SCOPE_NAMES.iter().copied().collect(); + for required in [ + "signal_close", + "benchmark_close", + "close", + "avg_cost", + "current_price", + "stock_ma_short", + ] { + assert!(names.contains(required), "missing reserved name: {required}"); + } + + let helpers: std::collections::HashSet<&str> = + RUNTIME_HELPER_FUNCTIONS.iter().copied().collect(); + for required in ["rolling_mean", "factor", "pct_change"] { + assert!( + helpers.contains(required), + "missing helper function: {required}" + ); + } + } +}