diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index bba85e9..7a90fd8 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -1335,6 +1335,55 @@ pub struct DataSet { futures_params_by_symbol: Arc>>, } +struct DailySymbolRows<'a, T> { + rows: &'a [T], + symbol_ids: &'a [u32], + row_positions: Option<&'a [u32]>, +} + +impl<'a, T> DailySymbolRows<'a, T> { + fn get(&self, symbol_id: u32) -> Option<&'a T> { + if let Some(positions) = self.row_positions { + let position = positions.get(symbol_id as usize).copied()?; + if position == MISSING_ROW_POSITION { + return None; + } + return self.rows.get(position as usize); + } + find_by_symbol_id(self.rows, self.symbol_ids, symbol_id) + } +} + +/// Borrowed, immutable snapshots for one trading date. +/// +/// A strategy evaluates thousands of symbols for the same date. Resolving the +/// date in three BTreeMaps for every symbol is unnecessary; this view freezes +/// the already indexed slices once and keeps all lookups read-only. +pub(crate) struct DailySnapshotView<'a> { + market: DailySymbolRows<'a, DailyMarketSnapshot>, + factor_rows: &'a [DailyFactorSnapshot], + factor_symbol_ids: &'a [u32], + candidates: DailySymbolRows<'a, CandidateEligibility>, +} + +impl<'a> DailySnapshotView<'a> { + pub(crate) fn market(&self, symbol_id: u32) -> Option<&'a DailyMarketSnapshot> { + self.market.get(symbol_id) + } + + pub(crate) fn candidate(&self, symbol_id: u32) -> Option<&'a CandidateEligibility> { + self.candidates.get(symbol_id) + } + + pub(crate) fn factor_rows(&self) -> &'a [DailyFactorSnapshot] { + self.factor_rows + } + + pub(crate) fn factor_symbol_ids(&self) -> &'a [u32] { + self.factor_symbol_ids + } +} + #[derive(Debug, Clone, Copy)] pub(crate) struct StandardRollingMeans { pub close: [Option; 7], @@ -1868,6 +1917,52 @@ impl DataSet { ) } + pub(crate) fn daily_snapshot_view(&self, date: NaiveDate) -> DailySnapshotView<'_> { + fn rows_on<'a, T>( + date: NaiveDate, + rows_by_date: &'a BTreeMap>, + symbol_ids_by_date: &'a BTreeMap>, + row_positions_by_date: &'a Option, + ) -> DailySymbolRows<'a, T> { + DailySymbolRows { + rows: rows_by_date.get(&date).map(Vec::as_slice).unwrap_or(&[]), + symbol_ids: symbol_ids_by_date + .get(&date) + .map(Vec::as_slice) + .unwrap_or(&[]), + row_positions: row_positions_by_date + .as_ref() + .and_then(|positions| positions.get(&date)) + .map(Vec::as_slice), + } + } + + DailySnapshotView { + market: rows_on( + date, + &self.market_by_date, + &self.market_symbol_ids_by_date, + &self.market_row_positions_by_date, + ), + factor_rows: self + .factor_by_date + .get(&date) + .map(Vec::as_slice) + .unwrap_or(&[]), + factor_symbol_ids: self + .factor_symbol_ids_by_date + .get(&date) + .map(Vec::as_slice) + .unwrap_or(&[]), + candidates: rows_on( + date, + &self.candidate_by_date, + &self.candidate_symbol_ids_by_date, + &self.candidate_row_positions_by_date, + ), + } + } + fn market_series(&self, symbol: &str) -> Option<&SymbolPriceSeries> { self.market_series_by_symbol.get(symbol).map(Arc::as_ref) } @@ -4775,11 +4870,16 @@ mod tests { for symbol in ["000001.SZ", "600000.SH"] { let symbol_id = data.symbol_id(symbol).unwrap(); + let day = data.daily_snapshot_view(date); assert_eq!( data.market_by_symbol_id(date, symbol_id) .map(|row| row.symbol.as_str()), Some(symbol) ); + assert_eq!( + day.market(symbol_id).map(|row| row.symbol.as_str()), + Some(symbol) + ); assert_eq!( data.factor_by_symbol_id(date, symbol_id) .map(|row| row.symbol.as_str()), @@ -4790,15 +4890,138 @@ mod tests { .map(|row| row.symbol.as_str()), Some(symbol) ); + assert_eq!( + day.candidate(symbol_id).map(|row| row.symbol.as_str()), + Some(symbol) + ); } let signal_id = data.symbol_id("000300.SH").unwrap(); + let day = data.daily_snapshot_view(date); assert_eq!( data.market_by_symbol_id(date, signal_id).map(|row| row.symbol.as_str()), Some("000300.SH") ); assert!(data.factor_by_symbol_id(date, signal_id).is_none()); assert!(data.candidate_by_symbol_id(date, signal_id).is_none()); + assert!(day.candidate(signal_id).is_none()); + } + + #[test] + #[ignore = "manual component benchmark"] + fn benchmark_daily_snapshot_view_lookup() { + use std::hint::black_box; + use std::time::Instant; + + let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); + let symbol_count = 6_000usize; + let symbols = (0..symbol_count) + .map(|index| format!("{index:06}.SZ")) + .collect::>(); + let instruments = symbols + .iter() + .map(|symbol| Instrument { + symbol: symbol.clone(), + name: symbol.clone(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }) + .collect::>(); + let market = symbols + .iter() + .enumerate() + .map(|(index, symbol)| { + let mut row = market_row( + "2025-01-02", + 10.0 + index as f64 / 1000.0, + 1_000_000, + ); + row.symbol = symbol.clone(); + row + }) + .collect::>(); + let factors = symbols + .iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: symbol.clone(), + market_cap_bn: 10.0 + index as f64 / 1000.0, + free_float_cap_bn: 8.0, + pe_ttm: 10.0, + turnover_ratio: None, + effective_turnover_ratio: None, + extra_factors: NumericFactorMap::new(), + }) + .collect::>(); + let candidates = symbols + .iter() + .map(|symbol| CandidateEligibility { + date, + symbol: symbol.clone(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }) + .collect::>(); + let data = DataSet::from_components( + instruments, + market, + factors, + candidates, + vec![benchmark_row("2025-01-02", 20.0)], + ) + .unwrap(); + let symbol_ids = symbols + .iter() + .map(|symbol| data.symbol_id(symbol).unwrap()) + .collect::>(); + let rounds = 200usize; + + let started = Instant::now(); + let mut baseline_sum = 0.0; + for _ in 0..rounds { + for symbol_id in symbol_ids.iter().copied() { + baseline_sum += black_box( + data.market_by_symbol_id(date, symbol_id).unwrap().close + + data + .candidate_by_symbol_id(date, symbol_id) + .unwrap() + .allow_buy as u8 as f64, + ); + } + } + let baseline = started.elapsed(); + + let day = data.daily_snapshot_view(date); + let started = Instant::now(); + let mut view_sum = 0.0; + for _ in 0..rounds { + for symbol_id in symbol_ids.iter().copied() { + view_sum += black_box( + day.market(symbol_id).unwrap().close + + day.candidate(symbol_id).unwrap().allow_buy as u8 as f64, + ); + } + } + let view = started.elapsed(); + assert_eq!(baseline_sum, view_sum); + println!( + "daily_snapshot_view rows={} rounds={} baseline_seconds={:.6} view_seconds={:.6}", + symbol_count, + rounds, + baseline.as_secs_f64(), + view.as_secs_f64(), + ); } #[test] diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index d678d39..d6bcbfa 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -8886,8 +8886,10 @@ impl PlatformExprStrategy { ) -> (Vec, Vec) { let mut rows = Vec::new(); let mut decisions = Vec::new(); - let factor_rows = ctx.data.factor_snapshot_rows_on(factor_date); - let factor_symbol_ids = ctx.data.factor_symbol_ids_on(factor_date); + let execution_day = ctx.data.daily_snapshot_view(date); + let factor_day = ctx.data.daily_snapshot_view(factor_date); + let factor_rows = factor_day.factor_rows(); + let factor_symbol_ids = factor_day.factor_symbol_ids(); debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len()); for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) { if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) { @@ -8895,14 +8897,14 @@ impl PlatformExprStrategy { } let synthetic_candidate; let candidate = - if let Some(candidate) = ctx.data.candidate_by_symbol_id(date, symbol_id) { + if let Some(candidate) = execution_day.candidate(symbol_id) { candidate } else { synthetic_candidate = crate::data::missing_candidate_risk_state(date, &factor.symbol); &synthetic_candidate }; - let Some(market) = ctx.data.market_by_symbol_id(date, symbol_id) else { + let Some(market) = execution_day.market(symbol_id) else { continue; }; let (reject_from_universe, selection_decision) = if collect_risk_decisions { diff --git a/docs/market-day-view-benchmark-20260831.md b/docs/market-day-view-benchmark-20260831.md new file mode 100644 index 0000000..b0c9791 --- /dev/null +++ b/docs/market-day-view-benchmark-20260831.md @@ -0,0 +1,51 @@ +# Market Day View Component Benchmark + +Date: 2026-08-31 + +## Scope + +The platform-expression selection loop already iterates one factor slice for a +single trading date. The previous implementation still resolved the same date +in the market and candidate `BTreeMap` for every symbol. `DailySnapshotView` +borrows the existing immutable market/factor/candidate slices and dense row +position arrays once per date, then performs only `symbol_id -> row` lookups. + +The view does not copy snapshots, cache strategy results, share account state, +or change missing-row behavior. The optimization is independent of strategy +text, thresholds, rolling windows, execution mode and portfolio size. + +## Release Component A/B + +Contract: + +- 6,000 symbols; +- 200 complete lookup rounds; +- each lookup reads market close and candidate `allow_buy`; +- baseline and view checksums must be exactly equal; +- `cargo test --release`, system allocator, local macOS host. + +| Round | Baseline seconds | Day view seconds | +| ---: | ---: | ---: | +| 1 | 0.009000 | 0.002939 | +| 2 | 0.004370 | 0.001555 | +| 3 | 0.004274 | 0.001578 | + +Median component time changed from `0.004370s` to `0.001578s`, an observed +reduction of about `63.9%` (`2.77x`). This is a component result only and is +not a complete backtest SLA. + +## Correctness Gates + +- sparse market-only symbols remain absent from factor/candidate views; +- dense and binary-search fallback lookup semantics remain unchanged; +- full engine suite: 529 passed, 3 ignored manual benchmarks; +- next-open execution-day risk, minute matching, fees, slippage, volume limits, + corporate actions, delisting and futures tests all passed. + +## Deployment Status + +Not deployed. The 177 FIDC-managed Boris factor task is still active, so no +Source Lake, backtest service or engine restart is allowed. After the task +ends naturally, acceptance must use the same frozen bundle and compare daily +selection, orders, fills, holdings, NAV, risk facts and canonical digest for +multiple daily/minute and fixed/dynamic-universe strategies.