复用选股日快照视图

This commit is contained in:
boris
2026-08-31 05:03:20 +08:00
parent b634540047
commit cb18a3f279
3 changed files with 280 additions and 4 deletions
+223
View File
@@ -1335,6 +1335,55 @@ pub struct DataSet {
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
}
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<f64>; 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<NaiveDate, Vec<T>>,
symbol_ids_by_date: &'a BTreeMap<NaiveDate, Vec<u32>>,
row_positions_by_date: &'a Option<DenseRowPositionIndex>,
) -> 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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]
@@ -8886,8 +8886,10 @@ impl PlatformExprStrategy {
) -> (Vec<EligibleUniverseSnapshot>, Vec<FidcRiskDecisionAudit>) {
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 {
@@ -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.