From a47c7c3e49bc3143a60a440a673774c1d72f81b2 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 7 May 2026 17:12:49 -0700 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20fidc-backtest-eng?= =?UTF-8?q?ine=20-=202026-05-07?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 155 +++++++++++++++++- 1 file changed, 146 insertions(+), 9 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 3c653b2..167f8af 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -508,16 +508,24 @@ impl PlatformExprStrategy { 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)) - }); + // 命中分支:先借用 cache 拿到 AST,做完 eval 再 drop borrow,避免与 + // cache_hits 的 borrow_mut 冲突(虽然是不同 RefCell,但显式作用域更清晰)。 + { + let cache = self.compiled_cache.borrow(); + if let Some(ast) = cache.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)) @@ -7509,4 +7517,133 @@ mod tests { let decision = strategy.on_day(&ctx).expect("platform decision"); assert_eq!(decision.order_intents.len(), 1); } + + #[test] + fn ast_cache_reuses_compiled_ast_across_invocations() { + let date = d(2025, 2, 3); + let data = DataSet::from_components( + 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(), + }], + 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, + }], + 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::new(), + }], + 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, + }], + ) + .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: "cash * 0.1".to_string(), + limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, + when_expr: Some("allow_buy".to_string()), + reason: "ast_cache_reuse".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(cfg); + + // 第一次调用:所有表达式 cache miss。 + let _ = strategy.on_day(&ctx).expect("first decision"); + let misses_after_first = strategy.ast_cache_misses(); + let hits_after_first = strategy.ast_cache_hits(); + assert!( + misses_after_first > 0, + "first run should populate cache, misses={}", + misses_after_first + ); + + // 第二次调用:相同表达式,cache hit 数应当 > 第一次。 + let _ = strategy.on_day(&ctx).expect("second decision"); + let misses_after_second = strategy.ast_cache_misses(); + let hits_after_second = strategy.ast_cache_hits(); + assert!( + hits_after_second > hits_after_first, + "second run should reuse cached AST, hits {} -> {}", + hits_after_first, + hits_after_second + ); + // 缓存条目数不应该再增长(相同 script):misses 不再增加。 + assert_eq!( + misses_after_second, misses_after_first, + "second run should not introduce new misses for same scripts" + ); + } }