use std::borrow::Cow; use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; use std::sync::{Arc, OnceLock}; use ahash::AHashMap; use chrono::{NaiveDate, NaiveDateTime}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::calendar::TradingCalendar; use crate::futures::FuturesTradingParameter; use crate::instrument::Instrument; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig}; pub(crate) const BACKWARD_ADJUSTMENT_FACTOR_FIELD: &str = "adjustment_factor_backward1"; mod date_format { use chrono::NaiveDate; use serde::{self, Deserialize, Deserializer, Serializer}; const FORMAT: &str = "%Y-%m-%d"; pub fn serialize(date: &NaiveDate, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&date.format(FORMAT).to_string()) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let text = String::deserialize(deserializer)?; NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom) } } mod datetime_format { use chrono::NaiveDateTime; use serde::{self, Deserialize, Deserializer, Serializer}; const FORMAT: &str = "%Y-%m-%d %H:%M:%S"; pub fn serialize(date: &NaiveDateTime, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&date.format(FORMAT).to_string()) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let text = String::deserialize(deserializer)?; NaiveDateTime::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom) } } #[derive(Debug, Error)] pub enum DataSetError { #[error("benchmark file contains multiple benchmark codes")] MultipleBenchmarks, #[error("missing data for {kind} on {date} / {symbol}")] MissingSnapshot { kind: &'static str, date: NaiveDate, symbol: String, }, #[error("benchmark snapshot missing for {date}")] MissingBenchmark { date: NaiveDate }, #[error("duplicate daily snapshot bundle for {date}")] DuplicateDailyBundle { date: NaiveDate }, #[error( "{kind} snapshot date {row_date} does not match daily bundle {bundle_date} for {symbol}" )] InvalidDailyBundleComponentDate { kind: &'static str, bundle_date: NaiveDate, row_date: NaiveDate, symbol: String, }, #[error("duplicate intraday market overlay for {date} / {symbol}")] DuplicateIntradayMarketOverlay { date: NaiveDate, symbol: String }, #[error("cannot mutate shared {component} while finalizing a backtest dataset")] SharedComponentMutation { component: &'static str }, #[error( "{kind} snapshot rows and symbol ids are misaligned on {date}: rows={row_count}, ids={symbol_id_count}" )] SnapshotSymbolIndexAlignment { kind: &'static str, date: NaiveDate, row_count: usize, symbol_id_count: usize, }, #[error("factor field {field} must use its typed column on {date} / {symbol}")] ReservedTypedFactorInExtraMap { date: NaiveDate, symbol: String, field: &'static str, }, #[error("invalid backward adjustment factor {value} on {date} / {symbol}")] InvalidBackwardAdjustmentFactor { date: NaiveDate, symbol: String, value: f64, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PriceField { DayOpen, Open, Close, Last, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DailyMarketSnapshot { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub timestamp: Option, pub day_open: f64, pub open: f64, pub high: f64, pub low: f64, pub close: f64, pub last_price: f64, pub bid1: f64, pub ask1: f64, pub prev_close: f64, pub volume: u64, pub minute_volume: u64, pub bid1_volume: u64, pub ask1_volume: u64, pub trading_phase: Option, pub paused: bool, pub upper_limit: f64, pub lower_limit: f64, pub price_tick: f64, } impl DailyMarketSnapshot { pub fn price(&self, field: PriceField) -> f64 { match field { PriceField::DayOpen => self.day_open, PriceField::Open => self.open, PriceField::Close => self.close, PriceField::Last => self.last_price, } } pub fn buy_price(&self, field: PriceField) -> f64 { match field { PriceField::Last if self.ask1.is_finite() && self.ask1 > 0.0 => self.ask1, _ => self.price(field), } } pub fn sell_price(&self, field: PriceField) -> f64 { match field { PriceField::Last if self.bid1.is_finite() && self.bid1 > 0.0 => self.bid1, _ => self.price(field), } } pub fn liquidity_for_buy(&self) -> u64 { self.ask1_volume } pub fn liquidity_for_sell(&self) -> u64 { self.bid1_volume } pub fn effective_price_tick(&self) -> f64 { if self.price_tick.is_finite() && self.price_tick > 0.0 { self.price_tick } else { 0.01 } } pub fn is_at_upper_limit_price(&self, price: f64) -> bool { if !self.upper_limit.is_finite() || self.upper_limit <= 0.0 { return false; } price >= self.upper_limit - 1e-9 } pub fn is_at_lower_limit_price(&self, price: f64) -> bool { if !self.lower_limit.is_finite() || self.lower_limit <= 0.0 { return false; } price <= self.lower_limit + 1e-9 } } pub type NumericFactorMap = BTreeMap, f64>; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DailyFactorSnapshot { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub market_cap_bn: f64, pub free_float_cap_bn: f64, pub pe_ttm: f64, pub turnover_ratio: Option, pub effective_turnover_ratio: Option, #[serde(default)] pub adjustment_factor_backward1: Option, #[serde(default)] pub extra_factors: NumericFactorMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BenchmarkSnapshot { #[serde(with = "date_format")] pub date: NaiveDate, pub benchmark: String, pub open: f64, pub close: f64, pub prev_close: f64, pub volume: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CandidateEligibility { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub is_st: bool, #[serde(default)] pub is_star_st: bool, pub is_new_listing: bool, pub is_paused: bool, pub allow_buy: bool, pub allow_sell: bool, pub is_kcb: bool, pub is_one_yuan: bool, #[serde(default)] pub risk_level_code: Option, } impl CandidateEligibility { pub fn eligible_for_selection(&self) -> bool { !self.is_st && !self.is_star_st && !self.is_new_listing && !self.is_paused && !self.is_kcb && !self.is_one_yuan && self.allow_buy && self.allow_sell } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CorporateAction { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, #[serde(default, with = "optional_date_format")] pub payable_date: Option, pub share_cash: f64, pub share_bonus: f64, pub share_gift: f64, pub issue_quantity: f64, pub issue_price: f64, pub reform: bool, pub adjust_factor: Option, #[serde(default)] pub successor_symbol: Option, #[serde(default)] pub successor_ratio: Option, #[serde(default)] pub successor_cash: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IntradayExecutionQuote { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, #[serde(with = "datetime_format")] pub timestamp: NaiveDateTime, pub last_price: f64, pub bid1: f64, pub ask1: f64, pub bid1_volume: u64, pub ask1_volume: u64, #[serde(default)] pub volume_delta: u64, #[serde(default)] pub amount_delta: f64, pub trading_phase: Option, } /// Sparse same-day fields layered onto an already-built immutable daily panel. /// /// These fields do not participate in daily price series, adjustment series, /// symbol indexes, or rolling windows. Applying them in place lets the runner /// reuse the candidate-planning `DataSet` as the final execution `DataSet` /// without rebuilding the full market panel. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IntradayMarketSnapshotOverlay { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub timestamp: Option, pub last_price: Option, pub bid1: f64, pub ask1: f64, pub minute_volume: u64, pub bid1_volume: u64, pub ask1_volume: u64, pub trading_phase: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IntradayOrderBookDepthLevel { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, #[serde(with = "datetime_format")] pub timestamp: NaiveDateTime, pub level: u8, pub bid_price: f64, pub bid_volume: u64, pub ask_price: f64, pub ask_volume: u64, } impl IntradayOrderBookDepthLevel { pub fn executable_price(&self, side: crate::events::OrderSide) -> Option { match side { crate::events::OrderSide::Buy if self.ask_price.is_finite() && self.ask_price > 0.0 => { Some(self.ask_price) } crate::events::OrderSide::Sell if self.bid_price.is_finite() && self.bid_price > 0.0 => { Some(self.bid_price) } _ => None, } } pub fn executable_volume(&self, side: crate::events::OrderSide) -> u64 { match side { crate::events::OrderSide::Buy => self.ask_volume, crate::events::OrderSide::Sell => self.bid_volume, } } } impl IntradayExecutionQuote { pub fn buy_price(&self) -> Option { if self.ask1.is_finite() && self.ask1 > 0.0 { Some(self.ask1) } else if self.last_price.is_finite() && self.last_price > 0.0 { Some(self.last_price) } else { None } } pub fn sell_price(&self) -> Option { if self.bid1.is_finite() && self.bid1 > 0.0 { Some(self.bid1) } else if self.last_price.is_finite() && self.last_price > 0.0 { Some(self.last_price) } else { None } } } /// A borrowed, timestamp-ordered merge of the execution-quote streams for one /// trading day. The iterator keeps only stream cursors and never clones quote /// payloads; callers decide how much of the day they need to retain. pub struct ExecutionQuoteIterator<'a> { streams: Vec<(&'a str, &'a [IntradayExecutionQuote])>, heap: BinaryHeap>, } impl<'a> ExecutionQuoteIterator<'a> { fn new( rows_by_symbol: Option<&'a HashMap>>, symbols: Option<&BTreeSet>, ) -> Self { let mut streams = rows_by_symbol .into_iter() .flat_map(|rows_by_symbol| rows_by_symbol.iter()) .filter(|(symbol, _)| { symbols .map(|allowed_symbols| allowed_symbols.contains(*symbol)) .unwrap_or(true) }) .map(|(symbol, rows)| (symbol.as_str(), rows.as_slice())) .collect::>(); streams.sort_by_key(|(symbol, _)| *symbol); let mut heap = BinaryHeap::with_capacity(streams.len()); for (stream_index, (_, rows)) in streams.iter().enumerate() { if let Some(first) = rows.first() { heap.push(Reverse((first.timestamp, stream_index, 0))); } } Self { streams, heap } } } impl<'a> Iterator for ExecutionQuoteIterator<'a> { type Item = &'a IntradayExecutionQuote; fn next(&mut self) -> Option { let Reverse((_timestamp, stream_index, row_index)) = self.heap.pop()?; let rows = self.streams.get(stream_index)?.1; let quote = rows.get(row_index)?; let next_index = row_index + 1; if let Some(next) = rows.get(next_index) { self.heap .push(Reverse((next.timestamp, stream_index, next_index))); } Some(quote) } } impl CorporateAction { pub fn split_ratio(&self) -> f64 { 1.0 + self.share_bonus.max(0.0) + self.share_gift.max(0.0) } pub fn has_effect(&self) -> bool { self.share_cash.abs() > f64::EPSILON || (self.split_ratio() - 1.0).abs() > f64::EPSILON || self.issue_quantity.abs() > f64::EPSILON || self.reform || self.has_successor_conversion() } pub fn has_successor_conversion(&self) -> bool { self.successor_symbol .as_ref() .is_some_and(|symbol| !symbol.trim().is_empty()) && self.successor_ratio_value() > 0.0 } pub fn successor_ratio_value(&self) -> f64 { self.successor_ratio .filter(|ratio| ratio.is_finite() && *ratio > 0.0) .unwrap_or(1.0) } pub fn successor_cash_value(&self) -> f64 { self.successor_cash .filter(|cash| cash.is_finite()) .unwrap_or(0.0) } } #[derive(Debug, Clone)] pub struct DailySnapshotBundle { pub date: NaiveDate, pub benchmark: BenchmarkSnapshot, pub market: Vec, pub factors: Vec, pub candidates: Vec, pub corporate_actions: Vec, } struct GroupedSnapshotComponents { market_by_date: BTreeMap>, factor_by_date: BTreeMap>, candidate_by_date: BTreeMap>, benchmark_by_date: BTreeMap, corporate_actions_by_date: BTreeMap>, } #[derive(Debug, Clone)] pub struct DataSetSnapshotComponents { pub instruments: Vec, pub market: Vec, pub factors: Vec, pub candidates: Vec, pub benchmarks: Vec, pub corporate_actions: Vec, pub execution_quotes: Vec, } #[derive(Debug, Clone, Serialize)] pub struct PriceBar { #[serde(with = "date_format")] pub date: NaiveDate, pub timestamp: Option, pub symbol: String, pub frequency: String, pub open: f64, pub high: f64, pub low: f64, pub close: f64, pub last_price: f64, pub volume: u64, pub amount: f64, pub bid1: f64, pub ask1: f64, pub bid1_volume: u64, pub ask1_volume: u64, } #[derive(Debug, Clone, Serialize)] pub struct DividendRecord { #[serde(with = "date_format")] pub ex_dividend_date: NaiveDate, #[serde(with = "date_format")] pub payable_date: NaiveDate, pub symbol: String, pub dividend_cash_before_tax: f64, pub round_lot: u32, } #[derive(Debug, Clone, Serialize)] pub struct SplitRecord { #[serde(with = "date_format")] pub ex_dividend_date: NaiveDate, pub symbol: String, pub split_ratio: f64, } #[derive(Debug, Clone, Serialize)] pub struct FactorValue { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub field: String, pub value: f64, } #[derive(Debug, Clone, Serialize)] pub struct FactorTextValue { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub field: String, pub value: String, } #[derive(Debug, Clone, Serialize)] pub struct SecuritiesMarginRecord { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub field: String, pub value: f64, } #[derive(Debug, Clone, Serialize)] pub struct YieldCurvePoint { #[serde(with = "date_format")] pub date: NaiveDate, pub tenor: String, pub value: f64, } #[derive(Debug, Clone)] pub struct EligibleUniverseSnapshot { pub symbol: String, pub market_cap_bn: f64, pub free_float_cap_bn: f64, } pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot) -> f64 { factor.market_cap_bn } pub fn decision_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 { factor.free_float_cap_bn } #[derive(Debug, Clone)] struct SymbolPriceSeries { base: Arc, timestamps: Vec>, last_prices: Vec, bid1s: Vec, ask1s: Vec, minute_volumes: Vec, bid1_volumes: Vec, ask1_volumes: Vec, trading_phases: Vec>, last_prefix: Vec, } #[derive(Debug)] struct SymbolDailySeriesBase { symbol: String, dates: Vec, day_opens: Vec, opens: Vec, highs: Vec, lows: Vec, closes: Vec, prev_closes: Vec, volumes: Vec, paused: Vec, upper_limits: Vec, lower_limits: Vec, price_ticks: Vec, open_prefix: Vec, close_prefix: Vec, prev_close_prefix: Vec, valid_volume_sum_prefix: Vec, valid_volume_count_prefix: Vec, valid_volume_start_by_count: Vec, } impl std::ops::Deref for SymbolPriceSeries { type Target = SymbolDailySeriesBase; fn deref(&self) -> &Self::Target { &self.base } } type DenseRowPositionIndex = BTreeMap>; const MISSING_ROW_POSITION: u32 = u32::MAX; const MAX_DENSE_ROW_INDEX_BYTES: usize = 256 * 1024 * 1024; #[derive(Debug, Clone)] struct CalendarSeriesEndPositions { decision: Vec>, current: Vec>, } const MAX_SERIES_END_POSITION_INDEX_BYTES: usize = 256 * 1024 * 1024; #[derive(Debug, Clone)] struct AdjustedCloseSeries { dates: Vec, backward_factors: Vec>, back_adjusted_closes: Vec>, back_adjusted_close_prefix: Vec, missing_back_adjusted_close_prefix: Vec, } impl AdjustedCloseSeries { fn new(market: &SymbolPriceSeries, factor_rows: &[&DailyFactorSnapshot]) -> Option { debug_assert!( factor_rows .windows(2) .all(|window| window[0].date <= window[1].date) ); let mut backward_factors = Vec::with_capacity(market.dates.len()); let mut back_adjusted_closes = Vec::with_capacity(market.dates.len()); let mut back_adjusted_close_prefix = Vec::with_capacity(market.dates.len() + 1); let mut missing_back_adjusted_close_prefix = Vec::with_capacity(market.dates.len() + 1); back_adjusted_close_prefix.push(0.0); missing_back_adjusted_close_prefix.push(0); let mut factor_index = 0usize; for (date, close) in market.dates.iter().zip(&market.closes) { while factor_rows .get(factor_index) .is_some_and(|snapshot| snapshot.date < *date) { factor_index += 1; } let factor = factor_rows .get(factor_index) .filter(|snapshot| snapshot.date == *date) .and_then(|snapshot| snapshot.adjustment_factor_backward1) .filter(|factor| factor.is_finite() && *factor > 0.0); let back_adjusted_close = factor .filter(|_| close.is_finite() && *close > 0.0) .map(|factor| close * factor); backward_factors.push(factor); back_adjusted_closes.push(back_adjusted_close); back_adjusted_close_prefix.push( back_adjusted_close_prefix .last() .copied() .unwrap_or_default() + back_adjusted_close.unwrap_or_default(), ); missing_back_adjusted_close_prefix.push( missing_back_adjusted_close_prefix .last() .copied() .unwrap_or_default() + u32::from(back_adjusted_close.is_none()), ); } Some(Self { dates: market.dates.clone(), backward_factors, back_adjusted_closes, back_adjusted_close_prefix, missing_back_adjusted_close_prefix, }) } fn current_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { let end = match self.dates.binary_search(&date) { Ok(index) => index + 1, Err(0) => return None, Err(index) => index, }; self.moving_average_at_end(end, lookback) } fn decision_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { let end = match self.dates.binary_search(&date) { Ok(index) => index, Err(0) => return None, Err(index) => index, }; self.moving_average_at_end(end, lookback) } fn moving_averages( &self, date: NaiveDate, lookbacks: &[usize; N], include_now: bool, ) -> [Option; N] { let end = match self.dates.binary_search(&date) { Ok(index) if include_now => index + 1, Ok(index) => index, Err(0) => return [None; N], Err(index) => index, }; self.moving_averages_at_end(end, lookbacks) } fn moving_averages_at_end( &self, end: usize, lookbacks: &[usize; N], ) -> [Option; N] { std::array::from_fn(|index| self.moving_average_at_end(end, lookbacks[index])) } fn moving_average_at_end(&self, end: usize, lookback: usize) -> Option { if lookback == 0 || end < lookback { return None; } let base_factor = self.backward_factors.get(end - 1).copied().flatten()?; let start = end - lookback; if self.missing_back_adjusted_close_prefix[end] != self.missing_back_adjusted_close_prefix[start] { return None; } let sum = self.back_adjusted_close_prefix[end] - self.back_adjusted_close_prefix[start]; if !sum.is_finite() { return None; } Some(normalize_rolling_factor( sum / lookback as f64 / base_factor, 12, )) } fn values(&self, date: NaiveDate, lookback: usize, include_now: bool) -> Vec { if lookback == 0 { return Vec::new(); } let end = match self.dates.binary_search(&date) { Ok(index) => index + usize::from(include_now), Err(0) => return Vec::new(), Err(index) => index, }; if end == 0 { return Vec::new(); } let start = end.saturating_sub(lookback); let Some(base_factor) = self.backward_factors.get(end - 1).copied().flatten() else { return Vec::new(); }; self.back_adjusted_closes[start..end] .iter() .copied() .collect::>>() .map(|values| { values .into_iter() .map(|value| normalize_rolling_factor(value / base_factor, 12)) .collect() }) .unwrap_or_default() } fn latest_back_adjusted_close(&self, date: NaiveDate) -> Option { let index = match self.dates.binary_search(&date) { Ok(index) => index, Err(0) => return None, Err(index) => index - 1, }; self.back_adjusted_closes .get(index) .copied() .flatten() .filter(|value| value.is_finite() && *value > 0.0) } } impl SymbolPriceSeries { #[cfg(test)] fn new<'a, I>(symbol: String, rows: I) -> Self where I: IntoIterator, { let mut sorted = rows.into_iter().collect::>(); sorted.sort_by_key(|row| row.date); Self::from_sorted_rows(symbol, sorted) } fn from_sorted_rows(symbol: String, rows: Vec<&DailyMarketSnapshot>) -> Self { debug_assert!( rows.windows(2) .all(|window| window[0].date <= window[1].date) ); let row_count = rows.len(); let mut dates = Vec::with_capacity(row_count); let mut timestamps = Vec::with_capacity(row_count); let mut day_opens = Vec::with_capacity(row_count); let mut opens = Vec::with_capacity(row_count); let mut highs = Vec::with_capacity(row_count); let mut lows = Vec::with_capacity(row_count); let mut closes = Vec::with_capacity(row_count); let mut prev_closes = Vec::with_capacity(row_count); let mut last_prices = Vec::with_capacity(row_count); let mut bid1s = Vec::with_capacity(row_count); let mut ask1s = Vec::with_capacity(row_count); let mut volumes = Vec::with_capacity(row_count); let mut minute_volumes = Vec::with_capacity(row_count); let mut bid1_volumes = Vec::with_capacity(row_count); let mut ask1_volumes = Vec::with_capacity(row_count); let mut trading_phases = Vec::with_capacity(row_count); let mut paused = Vec::with_capacity(row_count); let mut upper_limits = Vec::with_capacity(row_count); let mut lower_limits = Vec::with_capacity(row_count); let mut price_ticks = Vec::with_capacity(row_count); for row in rows { dates.push(row.date); timestamps.push(row.timestamp.clone()); day_opens.push(row.day_open); opens.push(row.open); highs.push(row.high); lows.push(row.low); closes.push(row.close); prev_closes.push(row.prev_close); last_prices.push(row.last_price); bid1s.push(row.bid1); ask1s.push(row.ask1); volumes.push(row.volume); minute_volumes.push(row.minute_volume); bid1_volumes.push(row.bid1_volume); ask1_volumes.push(row.ask1_volume); trading_phases.push(row.trading_phase.clone()); paused.push(row.paused); upper_limits.push(row.upper_limit); lower_limits.push(row.lower_limit); price_ticks.push(row.price_tick); } let open_prefix = prefix_sums(&opens); let close_prefix = prefix_sums(&closes); let prev_close_prefix = prefix_sums(&prev_closes); let last_prefix = prefix_sums(&last_prices); let mut valid_volume_sum_prefix = Vec::with_capacity(volumes.len() + 1); let mut valid_volume_count_prefix = Vec::with_capacity(volumes.len() + 1); valid_volume_sum_prefix.push(0.0); valid_volume_count_prefix.push(0); for volume in &volumes { let valid = *volume > 0; valid_volume_sum_prefix.push( valid_volume_sum_prefix.last().copied().unwrap_or_default() + if valid { *volume as f64 } else { 0.0 }, ); valid_volume_count_prefix.push( valid_volume_count_prefix .last() .copied() .unwrap_or_default() + usize::from(valid), ); } let valid_volume_count = valid_volume_count_prefix .last() .copied() .unwrap_or_default(); let mut valid_volume_start_by_count = vec![0usize; valid_volume_count + 1]; for (index, count) in valid_volume_count_prefix.iter().copied().enumerate() { valid_volume_start_by_count[count] = index; } Self { base: Arc::new(SymbolDailySeriesBase { symbol, dates, day_opens, opens, highs, lows, closes, prev_closes, volumes, paused, upper_limits, lower_limits, price_ticks, open_prefix, close_prefix, prev_close_prefix, valid_volume_sum_prefix, valid_volume_count_prefix, valid_volume_start_by_count, }), timestamps, last_prices, bid1s, ask1s, minute_volumes, bid1_volumes, ask1_volumes, trading_phases, last_prefix, } } fn apply_intraday_market_overlays( &mut self, overlays: &[&IntradayMarketSnapshotOverlay], ) -> Result<(), NaiveDate> { let mut last_price_changed = false; for overlay in overlays { let index = self .dates .binary_search(&overlay.date) .map_err(|_| overlay.date)?; self.timestamps[index] = overlay.timestamp.clone(); if let Some(last_price) = overlay .last_price .filter(|value| value.is_finite() && *value > 0.0) { self.last_prices[index] = last_price; last_price_changed = true; } self.bid1s[index] = overlay.bid1; self.ask1s[index] = overlay.ask1; self.minute_volumes[index] = overlay.minute_volume; self.bid1_volumes[index] = overlay.bid1_volume; self.ask1_volumes[index] = overlay.ask1_volume; self.trading_phases[index] = overlay.trading_phase.clone(); } if last_price_changed { self.last_prefix = prefix_sums(&self.last_prices); } Ok(()) } fn moving_average(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Option { if lookback == 0 { return None; } let end = self.end_index(date)?; self.moving_average_at_end(end, lookback, field) } fn moving_average_at_end(&self, end: usize, lookback: usize, field: PriceField) -> Option { if end < lookback { return None; } let start = end - lookback; let prefix = self.prefix_for(field); let sum = prefix[end] - prefix[start]; Some(sum / lookback as f64) } fn trailing_values(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Vec { let Some(end) = self.end_index(date) else { return Vec::new(); }; let start = end.saturating_sub(lookback); self.price_values_for(field)[start..end].to_vec() } fn trailing_snapshots( &self, date: NaiveDate, lookback: usize, include_now: bool, ) -> Vec { if lookback == 0 { return Vec::new(); } let end = if include_now { self.end_index(date) } else { self.previous_completed_end_index(date) }; let Some(end) = end else { return Vec::new(); }; let start = end.saturating_sub(lookback); (start..end).map(|index| self.snapshot_at(index)).collect() } fn trailing_numeric_values( &self, date: NaiveDate, lookback: usize, field: &str, include_now: bool, ) -> Vec { if lookback == 0 { return Vec::new(); } let end = if include_now { self.end_index(date) } else { self.previous_completed_end_index(date) }; let Some(end) = end else { return Vec::new(); }; let start = end.saturating_sub(lookback); (start..end) .filter_map(|index| self.numeric_value_at(index, field)) .collect() } fn decision_price_on_or_before(&self, date: NaiveDate) -> Option { let end = self.decision_end_index(date)?; if end == 0 { return None; } self.prev_closes.get(end - 1).copied() } fn decision_end_index(&self, date: NaiveDate) -> Option { match self.dates.binary_search(&date) { Ok(idx) => Some(idx + 1), Err(0) => None, Err(idx) => Some(idx), } } fn previous_completed_end_index(&self, date: NaiveDate) -> Option { match self.dates.binary_search(&date) { Ok(idx) => Some(idx), Err(0) => None, Err(idx) => Some(idx), } } fn decision_close_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { if lookback == 0 { return None; } let end = self.decision_end_index(date)?; if end < lookback { return None; } let start = end - lookback; let sum = self.prev_close_prefix[end] - self.prev_close_prefix[start]; Some(sum / lookback as f64) } fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { let end = self.previous_completed_end_index(date)?; self.valid_volume_window(end, lookback).map(|(start, end)| { normalize_rolling_factor( (self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start]) / lookback as f64, 12, ) }) } fn current_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { let end = self.end_index(date)?; self.valid_volume_window(end, lookback).map(|(start, end)| { normalize_rolling_factor( (self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start]) / lookback as f64, 12, ) }) } fn volume_moving_averages( &self, date: NaiveDate, lookbacks: &[usize; N], include_now: bool, ) -> [Option; N] { let Some(end) = self.rolling_end_index(date, include_now) else { return [None; N]; }; self.volume_moving_averages_at_end(end, lookbacks) } fn volume_moving_averages_at_end( &self, end: usize, lookbacks: &[usize; N], ) -> [Option; N] { std::array::from_fn(|index| { let lookback = lookbacks[index]; self.valid_volume_window(end, lookback).map(|(start, end)| { normalize_rolling_factor( (self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start]) / lookback as f64, 12, ) }) }) } fn decision_volume_values(&self, date: NaiveDate, lookback: usize) -> Option> { let end = self.previous_completed_end_index(date)?; self.valid_volume_values(end, lookback) } fn current_volume_values(&self, date: NaiveDate, lookback: usize) -> Option> { let end = self.end_index(date)?; self.valid_volume_values(end, lookback) } fn valid_volume_window(&self, end: usize, lookback: usize) -> Option<(usize, usize)> { if lookback == 0 || end > self.volumes.len() { return None; } let valid_count = *self.valid_volume_count_prefix.get(end)?; if valid_count < lookback { return None; } let target_count = valid_count - lookback; let start = *self.valid_volume_start_by_count.get(target_count)?; debug_assert!(start <= end); Some((start, end)) } fn valid_volume_values(&self, end: usize, lookback: usize) -> Option> { let (start, end) = self.valid_volume_window(end, lookback)?; let values = self.volumes[start..end] .iter() .filter(|value| **value > 0) .map(|value| *value as f64) .collect::>(); (values.len() == lookback).then_some(values) } fn end_index(&self, date: NaiveDate) -> Option { match self.dates.binary_search(&date) { Ok(idx) => Some(idx + 1), Err(0) => None, Err(idx) => Some(idx), } } fn rolling_end_index(&self, date: NaiveDate, include_now: bool) -> Option { match self.dates.binary_search(&date) { Ok(index) if include_now => Some(index + 1), Ok(index) => Some(index), Err(0) => None, Err(index) => Some(index), } } fn price_values_for(&self, field: PriceField) -> &[f64] { match field { PriceField::DayOpen => &self.day_opens, PriceField::Open => &self.opens, PriceField::Close => &self.closes, PriceField::Last => &self.last_prices, } } fn price_on_or_before(&self, date: NaiveDate, field: PriceField) -> Option { let end = self.end_index(date)?; if end == 0 { return None; } self.price_values_for(field).get(end - 1).copied() } fn prefix_for(&self, field: PriceField) -> &[f64] { match field { PriceField::DayOpen => &self.open_prefix, PriceField::Open => &self.open_prefix, PriceField::Close => &self.close_prefix, PriceField::Last => &self.last_prefix, } } fn snapshot_at(&self, index: usize) -> DailyMarketSnapshot { DailyMarketSnapshot { date: self.dates[index], symbol: self.symbol.clone(), timestamp: self.timestamps[index].clone(), day_open: self.day_opens[index], open: self.opens[index], high: self.highs[index], low: self.lows[index], close: self.closes[index], last_price: self.last_prices[index], bid1: self.bid1s[index], ask1: self.ask1s[index], prev_close: self.prev_closes[index], volume: self.volumes[index], minute_volume: self.minute_volumes[index], bid1_volume: self.bid1_volumes[index], ask1_volume: self.ask1_volumes[index], trading_phase: self.trading_phases[index].clone(), paused: self.paused[index], upper_limit: self.upper_limits[index], lower_limit: self.lower_limits[index], price_tick: self.price_ticks[index], } } fn numeric_value_at(&self, index: usize, field: &str) -> Option { match normalized_field(field).as_ref() { "day_open" | "dayopen" => Some(self.day_opens[index]), "open" => Some(self.opens[index]), "high" => Some(self.highs[index]), "low" => Some(self.lows[index]), "close" | "price" => Some(self.closes[index]), "last" | "last_price" => Some(self.last_prices[index]), "prev_close" | "pre_close" => Some(self.prev_closes[index]), "volume" => Some(self.volumes[index] as f64), "minute_volume" => Some(self.minute_volumes[index] as f64), "bid1" => Some(self.bid1s[index]), "ask1" => Some(self.ask1s[index]), "bid1_volume" => Some(self.bid1_volumes[index] as f64), "ask1_volume" => Some(self.ask1_volumes[index] as f64), "upper_limit" => Some(self.upper_limits[index]), "lower_limit" => Some(self.lower_limits[index]), "price_tick" => Some(self.price_ticks[index]), _ => None, } } } #[derive(Debug, Clone)] struct BenchmarkPriceSeries { dates: Vec, opens: Vec, closes: Vec, prev_closes: Vec, open_prefix: Vec, close_prefix: Vec, } impl BenchmarkPriceSeries { fn from_sorted<'a, I>(rows: I) -> Self where I: IntoIterator, { let mut dates = Vec::new(); let mut opens = Vec::new(); let mut closes = Vec::new(); let mut prev_closes = Vec::new(); for row in rows { dates.push(row.date); opens.push(row.open); closes.push(row.close); prev_closes.push(row.prev_close); } let open_prefix = prefix_sums(&opens); let close_prefix = prefix_sums(&closes); Self { dates, opens, closes, prev_closes, open_prefix, close_prefix, } } fn moving_average(&self, date: NaiveDate, lookback: usize) -> Option { self.moving_average_for(date, lookback, PriceField::Close) } fn decision_close(&self, date: NaiveDate) -> Option { match self.dates.binary_search(&date) { Ok(idx) => self .prev_closes .get(idx) .copied() .filter(|value| value.is_finite() && *value > 0.0) .or_else(|| { idx.checked_sub(1) .and_then(|prev| self.closes.get(prev).copied()) }), Err(0) => None, Err(idx) => idx .checked_sub(1) .and_then(|prev| self.closes.get(prev).copied()), } } fn decision_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { if lookback == 0 { return None; } let end = match self.dates.binary_search(&date) { Ok(idx) => idx, Err(0) => return None, Err(idx) => idx, }; if end < lookback { return None; } let start = end - lookback; let sum = self.close_prefix[end] - self.close_prefix[start]; Some(sum / lookback as f64) } fn decision_values_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Vec { if lookback == 0 { return Vec::new(); } let end = match self.dates.binary_search(&date) { Ok(idx) => idx, Err(0) => return Vec::new(), Err(idx) => idx, }; let start = end.saturating_sub(lookback); match field { PriceField::DayOpen | PriceField::Open => self.opens[start..end].to_vec(), PriceField::Close | PriceField::Last => self.closes[start..end].to_vec(), } } fn moving_average_for( &self, date: NaiveDate, lookback: usize, field: PriceField, ) -> Option { if lookback == 0 { return None; } let end = match self.dates.binary_search(&date) { Ok(idx) => idx + 1, Err(0) => return None, Err(idx) => idx, }; if end < lookback { return None; } let start = end - lookback; let prefix = match field { PriceField::DayOpen | PriceField::Open => &self.open_prefix, PriceField::Close | PriceField::Last => &self.close_prefix, }; let sum = prefix[end] - prefix[start]; Some(sum / lookback as f64) } fn trailing_values(&self, date: NaiveDate, lookback: usize) -> Vec { self.trailing_values_for(date, lookback, PriceField::Close) } fn trailing_values_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Vec { let end = match self.dates.binary_search(&date) { Ok(idx) => idx + 1, Err(0) => return Vec::new(), Err(idx) => idx, }; let start = end.saturating_sub(lookback); match field { PriceField::DayOpen | PriceField::Open => self.opens[start..end].to_vec(), PriceField::Close | PriceField::Last => self.closes[start..end].to_vec(), } } } #[derive(Debug, Clone)] pub struct DataSet { instruments: Arc>, instruments_by_symbol_id: Arc>>, calendar: Arc, market_by_date: Arc>>, market_symbol_ids_by_date: Arc>>, market_row_positions_by_date: Arc>, factor_by_date: Arc>>, factor_symbol_ids_by_date: Arc>>, factor_row_positions_by_date: Arc>, factor_market_cap_order_by_date: Arc>>, factor_text_by_date: Arc>>, factor_text_index: Arc>, candidate_by_date: Arc>>, candidate_symbol_ids_by_date: Arc>>, candidate_row_positions_by_date: Arc>, corporate_actions_by_date: Arc>>, execution_quotes_by_date: Arc>>>, execution_quote_dates: Arc>, order_book_depth_index: Arc>>, benchmark_by_date: Arc>, market_series_by_symbol: Arc>>, adjusted_close_series_by_symbol: Arc>>, market_series_by_symbol_id: Arc>>>, adjusted_close_series_by_symbol_id: Arc>>>, market_series_end_positions_by_calendar_index: Arc>, benchmark_series_cache: Arc, symbol_id_by_code: Arc>, symbol_by_id: Arc>>, eligible_universe_by_date: Arc>>>, benchmark_code: String, 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>, factors: DailySymbolRows<'a, DailyFactorSnapshot>, 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(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> { self.factors.get(symbol_id) } pub(crate) fn factor_rows(&self) -> &'a [DailyFactorSnapshot] { self.factors.rows } pub(crate) fn factor_symbol_ids(&self) -> &'a [u32] { self.factors.symbol_ids } } #[derive(Debug, Clone, Copy)] pub(crate) struct StandardRollingMeans { pub close: [Option; 7], pub volume: [Option; 5], } impl DataSet { pub fn with_additional_trading_dates( mut self, dates: impl IntoIterator, ) -> Self { let mut calendar_dates = self.calendar.days().to_vec(); calendar_dates.extend(dates); let calendar = Arc::new(TradingCalendar::new(calendar_dates)); self.market_series_end_positions_by_calendar_index = Arc::new( build_calendar_series_end_positions(&self.market_series_by_symbol_id, &calendar), ); self.calendar = calendar; self } pub fn from_components( instruments: Vec, market: Vec, factors: Vec, candidates: Vec, benchmarks: Vec, ) -> Result { Self::from_components_with_actions_and_quotes( instruments, market, factors, candidates, benchmarks, Vec::new(), Vec::new(), ) } pub fn from_components_with_actions( instruments: Vec, market: Vec, factors: Vec, candidates: Vec, benchmarks: Vec, corporate_actions: Vec, ) -> Result { Self::from_components_with_actions_and_quotes( instruments, market, factors, candidates, benchmarks, corporate_actions, Vec::new(), ) } pub fn from_components_with_actions_and_quotes( instruments: Vec, market: Vec, factors: Vec, candidates: Vec, benchmarks: Vec, corporate_actions: Vec, execution_quotes: Vec, ) -> Result { Self::from_components_with_actions_quotes_and_futures( instruments, market, factors, candidates, benchmarks, corporate_actions, execution_quotes, Vec::new(), ) } pub fn from_daily_bundles_with_execution_quotes( instruments: Vec, mut bundles: Vec, execution_quotes: Vec, ) -> Result { if !bundles .windows(2) .all(|window| window[0].date <= window[1].date) { bundles.sort_by_key(|bundle| bundle.date); } if let Some(pair) = bundles.windows(2).find(|pair| pair[0].date == pair[1].date) { return Err(DataSetError::DuplicateDailyBundle { date: pair[1].date }); } let mut grouped = GroupedSnapshotComponents { market_by_date: BTreeMap::new(), factor_by_date: BTreeMap::new(), candidate_by_date: BTreeMap::new(), benchmark_by_date: BTreeMap::new(), corporate_actions_by_date: BTreeMap::new(), }; for mut bundle in bundles { let date = bundle.date; if bundle.benchmark.date != date { return Err(DataSetError::InvalidDailyBundleComponentDate { kind: "benchmark", bundle_date: date, row_date: bundle.benchmark.date, symbol: bundle.benchmark.benchmark.clone(), }); } validate_daily_bundle_component_dates( &bundle.market, date, "market", |row| row.date, |row| row.symbol.as_str(), )?; validate_daily_bundle_component_dates( &bundle.factors, date, "factor", |row| row.date, |row| row.symbol.as_str(), )?; validate_daily_bundle_component_dates( &bundle.candidates, date, "candidate", |row| row.date, |row| row.symbol.as_str(), )?; validate_daily_bundle_component_dates( &bundle.corporate_actions, date, "corporate_action", |row| row.date, |row| row.symbol.as_str(), )?; sort_rows_by_symbol_if_needed(&mut bundle.market, |row| row.symbol.as_str()); bundle.factors = normalize_factor_snapshots(bundle.factors)?; sort_rows_by_symbol_if_needed(&mut bundle.factors, |row| row.symbol.as_str()); sort_rows_by_symbol_if_needed(&mut bundle.candidates, |row| row.symbol.as_str()); if !bundle.market.is_empty() { grouped.market_by_date.insert(date, bundle.market); } if !bundle.factors.is_empty() { grouped.factor_by_date.insert(date, bundle.factors); } if !bundle.candidates.is_empty() { grouped.candidate_by_date.insert(date, bundle.candidates); } if !bundle.corporate_actions.is_empty() { grouped .corporate_actions_by_date .insert(date, bundle.corporate_actions); } grouped.benchmark_by_date.insert(date, bundle.benchmark); } Self::build_from_components( instruments, Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new(), execution_quotes, Vec::new(), Vec::new(), Vec::new(), Some(grouped), ) } pub fn from_components_with_actions_quotes_and_futures( instruments: Vec, market: Vec, factors: Vec, candidates: Vec, benchmarks: Vec, corporate_actions: Vec, execution_quotes: Vec, futures_params: Vec, ) -> Result { Self::from_components_with_actions_quotes_futures_and_depth( instruments, market, factors, candidates, benchmarks, corporate_actions, execution_quotes, futures_params, Vec::new(), ) } pub fn from_components_with_actions_quotes_futures_and_depth( instruments: Vec, market: Vec, factors: Vec, candidates: Vec, benchmarks: Vec, corporate_actions: Vec, execution_quotes: Vec, futures_params: Vec, order_book_depth: Vec, ) -> Result { Self::from_components_with_actions_quotes_futures_depth_and_factor_texts( instruments, market, factors, candidates, benchmarks, corporate_actions, execution_quotes, futures_params, order_book_depth, Vec::new(), ) } pub fn from_components_with_factor_texts( instruments: Vec, market: Vec, factors: Vec, candidates: Vec, benchmarks: Vec, factor_texts: Vec, ) -> Result { Self::from_components_with_actions_quotes_futures_depth_and_factor_texts( instruments, market, factors, candidates, benchmarks, Vec::new(), Vec::new(), Vec::new(), Vec::new(), factor_texts, ) } pub fn from_components_with_actions_quotes_futures_depth_and_factor_texts( instruments: Vec, market: Vec, factors: Vec, candidates: Vec, benchmarks: Vec, corporate_actions: Vec, execution_quotes: Vec, futures_params: Vec, order_book_depth: Vec, factor_texts: Vec, ) -> Result { Self::build_from_components( instruments, market, factors, candidates, benchmarks, corporate_actions, execution_quotes, futures_params, order_book_depth, factor_texts, None, ) } #[allow(clippy::too_many_arguments)] fn build_from_components( instruments: Vec, market: Vec, factors: Vec, candidates: Vec, benchmarks: Vec, corporate_actions: Vec, execution_quotes: Vec, futures_params: Vec, order_book_depth: Vec, factor_texts: Vec, grouped: Option, ) -> Result { let ( market_by_date, factor_by_date, candidate_by_date, benchmark_by_date, corporate_actions_by_date, ) = if let Some(grouped) = grouped { ( grouped.market_by_date, grouped.factor_by_date, grouped.candidate_by_date, grouped.benchmark_by_date, grouped.corporate_actions_by_date, ) } else { let mut market_by_date = group_by_date(market, |item| item.date); sort_groups_by_symbol(&mut market_by_date, |item| item.symbol.as_str()); let factors = normalize_factor_snapshots(factors)?; let mut factor_by_date = group_by_date(factors, |item| item.date); sort_groups_by_symbol(&mut factor_by_date, |item| item.symbol.as_str()); let mut candidate_by_date = group_by_date(candidates, |item| item.date); sort_groups_by_symbol(&mut candidate_by_date, |item| item.symbol.as_str()); let benchmark_by_date = benchmarks .into_iter() .map(|item| (item.date, item)) .collect::>(); let corporate_actions_by_date = group_by_date(corporate_actions, |item| item.date); ( market_by_date, factor_by_date, candidate_by_date, benchmark_by_date, corporate_actions_by_date, ) }; let benchmark_code = collect_benchmark_code(benchmark_by_date.values())?; let calendar = TradingCalendar::new(benchmark_by_date.keys().copied().collect()); let instruments = instruments .into_iter() .map(|instrument| (instrument.symbol.clone(), instrument)) .collect::>(); let symbol_id_by_code = build_symbol_id_index( &instruments, &market_by_date, &factor_by_date, &candidate_by_date, ); let symbol_count = symbol_id_by_code.len(); let mut symbol_by_id = vec![Arc::::from(""); symbol_count]; for (symbol, symbol_id) in &symbol_id_by_code { symbol_by_id[*symbol_id as usize] = Arc::::from(symbol.as_str()); } let mut instruments_by_symbol_id = vec![None; symbol_count]; for (symbol, instrument) in &instruments { if let Some(symbol_id) = symbol_id_by_code.get(symbol).copied() { instruments_by_symbol_id[symbol_id as usize] = Some(instrument.clone()); } } let market_symbol_ids_by_date = build_group_symbol_ids(&market_by_date, &symbol_id_by_code, |item| { item.symbol.as_str() }); let factor_symbol_ids_by_date = build_group_symbol_ids(&factor_by_date, &symbol_id_by_code, |item| { item.symbol.as_str() }); let candidate_symbol_ids_by_date = build_group_symbol_ids(&candidate_by_date, &symbol_id_by_code, |item| { item.symbol.as_str() }); let market_rows_by_symbol_id = group_rows_by_symbol_id( "market", &market_by_date, &market_symbol_ids_by_date, symbol_count, )?; let market_series_by_symbol_id = market_rows_by_symbol_id .into_par_iter() .enumerate() .map(|(symbol_id, rows)| { (!rows.is_empty()).then(|| { Arc::new(SymbolPriceSeries::from_sorted_rows( symbol_by_id[symbol_id].to_string(), rows, )) }) }) .collect::>(); let market_series_by_symbol = market_series_by_symbol_id .iter() .enumerate() .filter_map(|(symbol_id, series)| { series.as_ref().map(|series| { (symbol_by_id[symbol_id].to_string(), Arc::clone(series)) }) }) .collect::>(); let factor_rows_by_symbol_id = group_rows_by_symbol_id( "factor", &factor_by_date, &factor_symbol_ids_by_date, symbol_count, )?; let adjusted_close_series_by_symbol_id = market_series_by_symbol_id .par_iter() .enumerate() .map(|(symbol_id, market)| { market.as_ref().and_then(|market| { AdjustedCloseSeries::new(market, &factor_rows_by_symbol_id[symbol_id]) .map(Arc::new) }) }) .collect::>(); let adjusted_close_series_by_symbol = adjusted_close_series_by_symbol_id .iter() .enumerate() .filter_map(|(symbol_id, series)| { series.as_ref().map(|series| { (symbol_by_id[symbol_id].to_string(), Arc::clone(series)) }) }) .collect::>(); let factor_texts = factor_texts .into_iter() .filter_map(|mut item| { item.field = normalize_field(&item.field); if item.field.is_empty() { None } else { Some(item) } }) .collect::>(); let factor_text_by_date = group_by_date(factor_texts.clone(), |item| item.date); let factor_text_index = factor_texts .into_iter() .map(|item| ((item.date, item.symbol.clone(), item.field.clone()), item)) .collect::>(); let factor_market_cap_order_by_date = build_factor_market_cap_order(&factor_by_date, &factor_symbol_ids_by_date); let market_row_positions_by_date = build_dense_row_positions( &market_by_date, &market_symbol_ids_by_date, symbol_count, ); let factor_row_positions_by_date = build_dense_row_positions( &factor_by_date, &factor_symbol_ids_by_date, symbol_count, ); let candidate_row_positions_by_date = build_dense_row_positions( &candidate_by_date, &candidate_symbol_ids_by_date, symbol_count, ); let market_series_end_positions_by_calendar_index = build_calendar_series_end_positions(&market_series_by_symbol_id, &calendar); let execution_quotes_by_date = build_execution_quote_index(execution_quotes); let mut execution_quote_dates = execution_quotes_by_date.keys().copied().collect::>(); execution_quote_dates.sort_unstable(); let order_book_depth_index = build_order_book_depth_index(order_book_depth); let benchmark_series_cache = BenchmarkPriceSeries::from_sorted(benchmark_by_date.values()); let futures_params_by_symbol = build_futures_params_index(futures_params); Ok(Self { instruments: Arc::new(instruments), instruments_by_symbol_id: Arc::new(instruments_by_symbol_id), calendar: Arc::new(calendar), market_by_date: Arc::new(market_by_date), market_symbol_ids_by_date: Arc::new(market_symbol_ids_by_date), market_row_positions_by_date: Arc::new(market_row_positions_by_date), factor_by_date: Arc::new(factor_by_date), factor_symbol_ids_by_date: Arc::new(factor_symbol_ids_by_date), factor_row_positions_by_date: Arc::new(factor_row_positions_by_date), factor_market_cap_order_by_date: Arc::new(factor_market_cap_order_by_date), factor_text_by_date: Arc::new(factor_text_by_date), factor_text_index: Arc::new(factor_text_index), candidate_by_date: Arc::new(candidate_by_date), candidate_symbol_ids_by_date: Arc::new(candidate_symbol_ids_by_date), candidate_row_positions_by_date: Arc::new(candidate_row_positions_by_date), corporate_actions_by_date: Arc::new(corporate_actions_by_date), execution_quotes_by_date: Arc::new(execution_quotes_by_date), execution_quote_dates: Arc::new(execution_quote_dates), order_book_depth_index: Arc::new(order_book_depth_index), benchmark_by_date: Arc::new(benchmark_by_date), market_series_by_symbol: Arc::new(market_series_by_symbol), adjusted_close_series_by_symbol: Arc::new(adjusted_close_series_by_symbol), market_series_by_symbol_id: Arc::new(market_series_by_symbol_id), adjusted_close_series_by_symbol_id: Arc::new(adjusted_close_series_by_symbol_id), market_series_end_positions_by_calendar_index: Arc::new( market_series_end_positions_by_calendar_index, ), benchmark_series_cache: Arc::new(benchmark_series_cache), symbol_id_by_code: Arc::new(symbol_id_by_code), symbol_by_id: Arc::new(symbol_by_id), eligible_universe_by_date: Arc::new(OnceLock::new()), benchmark_code, futures_params_by_symbol: Arc::new(futures_params_by_symbol), }) } pub fn calendar(&self) -> &TradingCalendar { &self.calendar } pub fn benchmark_code(&self) -> &str { &self.benchmark_code } pub fn instruments(&self) -> &HashMap { &self.instruments } pub fn all_instruments(&self) -> Vec<&Instrument> { let mut instruments = self.instruments.values().collect::>(); instruments.sort_by(|left, right| left.symbol.cmp(&right.symbol)); instruments } pub fn instruments_history(&self, symbols: &[&str]) -> Vec<&Instrument> { symbols .iter() .filter_map(|symbol| self.instruments.get(*symbol)) .collect() } pub fn active_instruments(&self, date: NaiveDate, symbols: &[&str]) -> Vec<&Instrument> { symbols .iter() .filter_map(|symbol| self.instruments.get(*symbol)) .filter(|instrument| instrument.is_active_on(date)) .collect() } pub fn instrument(&self, symbol: &str) -> Option<&Instrument> { self.instruments.get(symbol) } pub(crate) fn instrument_by_symbol_id(&self, symbol_id: u32) -> Option<&Instrument> { self.instruments_by_symbol_id .get(symbol_id as usize)? .as_ref() } pub fn symbol_id(&self, symbol: &str) -> Option { self.symbol_id_by_code.get(symbol).copied() } pub(crate) fn shared_symbol_by_id(&self, symbol_id: u32) -> Option> { let symbol = self.symbol_by_id.get(symbol_id as usize)?; (!symbol.is_empty()).then(|| Arc::clone(symbol)) } pub(crate) fn symbol_count(&self) -> usize { self.symbol_id_by_code.len() } pub(crate) fn factor_symbol_ids_by_market_cap_on(&self, date: NaiveDate) -> &[u32] { self.factor_market_cap_order_by_date .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn market(&self, date: NaiveDate, symbol: &str) -> Option<&DailyMarketSnapshot> { let symbol_id = self.symbol_id(symbol)?; self.market_by_symbol_id(date, symbol_id) } pub fn market_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, ) -> Option<&DailyMarketSnapshot> { let rows = self.market_by_date.get(&date)?; if let Some(index) = dense_row_position(&self.market_row_positions_by_date, date, symbol_id) { return rows.get(index); } find_by_symbol_id(rows, self.market_symbol_ids_by_date.get(&date)?, symbol_id) } 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, ), factors: rows_on( date, &self.factor_by_date, &self.factor_symbol_ids_by_date, &self.factor_row_positions_by_date, ), 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) } fn market_series_by_symbol_id(&self, symbol_id: u32) -> Option<&SymbolPriceSeries> { self.market_series_by_symbol_id .get(symbol_id as usize)? .as_deref() } fn adjusted_close_series(&self, symbol: &str) -> Option<&AdjustedCloseSeries> { self.adjusted_close_series_by_symbol .get(symbol) .map(Arc::as_ref) } fn adjusted_close_series_by_symbol_id(&self, symbol_id: u32) -> Option<&AdjustedCloseSeries> { self.adjusted_close_series_by_symbol_id .get(symbol_id as usize)? .as_deref() } fn market_series_end_index_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, include_now: bool, ) -> Option { let calendar_index = self.calendar_index(date)?; self.market_series_end_index_by_symbol_id_at_calendar_index( calendar_index, symbol_id, include_now, ) } pub(crate) fn calendar_index(&self, date: NaiveDate) -> Option { self.calendar.index_of(date) } fn market_series_end_index_by_symbol_id_at_calendar_index( &self, calendar_index: usize, symbol_id: u32, include_now: bool, ) -> Option { let positions = self .market_series_end_positions_by_calendar_index .as_ref() .as_ref()?; let end = if include_now { positions .current .get(calendar_index)? .get(symbol_id as usize) } else { positions .decision .get(calendar_index)? .get(symbol_id as usize) }?; (*end != MISSING_ROW_POSITION).then_some(*end as usize) } pub(crate) fn market_current_series_end_index_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, ) -> Option { self.market_series_end_index_by_symbol_id(date, symbol_id, true) } pub(crate) fn market_current_series_end_index_by_symbol_id_at_calendar_index( &self, calendar_index: usize, symbol_id: u32, ) -> Option { self.market_series_end_index_by_symbol_id_at_calendar_index(calendar_index, symbol_id, true) } pub fn factor(&self, date: NaiveDate, symbol: &str) -> Option<&DailyFactorSnapshot> { let symbol_id = self.symbol_id(symbol)?; self.factor_by_symbol_id(date, symbol_id) } pub fn factor_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, ) -> Option<&DailyFactorSnapshot> { let rows = self.factor_by_date.get(&date)?; if let Some(index) = dense_row_position(&self.factor_row_positions_by_date, date, symbol_id) { return rows.get(index); } find_by_symbol_id(rows, self.factor_symbol_ids_by_date.get(&date)?, symbol_id) } pub fn candidate(&self, date: NaiveDate, symbol: &str) -> Option<&CandidateEligibility> { let symbol_id = self.symbol_id(symbol)?; self.candidate_by_symbol_id(date, symbol_id) } pub fn candidate_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, ) -> Option<&CandidateEligibility> { let rows = self.candidate_by_date.get(&date)?; if let Some(index) = dense_row_position(&self.candidate_row_positions_by_date, date, symbol_id) { return rows.get(index); } find_by_symbol_id( rows, self.candidate_symbol_ids_by_date.get(&date)?, symbol_id, ) } pub(crate) fn market_standard_rolling_means_by_symbol_id_with_calendar_index( &self, date: NaiveDate, calendar_index: Option, symbol_id: u32, close_lookbacks: &[usize; 7], volume_lookbacks: &[usize; 5], include_now: bool, ) -> StandardRollingMeans { let close_required = close_lookbacks.iter().any(|lookback| *lookback > 0); let volume_required = volume_lookbacks.iter().any(|lookback| *lookback > 0); if !close_required && !volume_required { return StandardRollingMeans { close: [None; 7], volume: [None; 5], }; } // Both series are built from the same market-date sequence. Reuse the // indexed boundary lookup instead of repeating it for close and volume. let series_end = calendar_index.and_then(|calendar_index| { self.market_series_end_index_by_symbol_id_at_calendar_index( calendar_index, symbol_id, include_now, ) }); let close = if close_required { self.adjusted_close_series_by_symbol_id(symbol_id) .map(|series| { series_end .map(|end| series.moving_averages_at_end(end, close_lookbacks)) .unwrap_or_else(|| { series.moving_averages(date, close_lookbacks, include_now) }) }) .unwrap_or([None; 7]) } else { [None; 7] }; let volume = if volume_required { self.market_series_by_symbol_id(symbol_id) .map(|series| { series_end .map(|end| series.volume_moving_averages_at_end(end, volume_lookbacks)) .unwrap_or_else(|| { series.volume_moving_averages(date, volume_lookbacks, include_now) }) }) .unwrap_or([None; 5]) } else { [None; 5] }; StandardRollingMeans { close, volume } } pub fn benchmark(&self, date: NaiveDate) -> Option<&BenchmarkSnapshot> { self.benchmark_by_date.get(&date) } pub fn corporate_actions_on(&self, date: NaiveDate) -> &[CorporateAction] { self.corporate_actions_by_date .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn execution_quotes_on(&self, date: NaiveDate, symbol: &str) -> &[IntradayExecutionQuote] { self.execution_quotes_by_date .get(&date) .and_then(|rows_by_symbol| rows_by_symbol.get(symbol)) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn has_execution_quotes_on_date(&self, date: NaiveDate) -> bool { self.execution_quotes_by_date .get(&date) .map(|rows_by_symbol| !rows_by_symbol.is_empty()) .unwrap_or(false) } pub fn execution_quote_key_set(&self) -> HashSet<(NaiveDate, String)> { self.execution_quotes_by_date .iter() .flat_map(|(date, rows_by_symbol)| { rows_by_symbol .keys() .map(move |symbol| (*date, symbol.clone())) }) .collect() } pub fn execution_quote_count(&self) -> usize { self.execution_quotes_by_date .values() .flat_map(|rows_by_symbol| rows_by_symbol.values()) .map(Vec::len) .sum() } /// Applies sparse intraday fields without rebuilding unaffected daily series or indexes. /// /// The daily market storage must still be uniquely owned. This is deliberate: /// silently using `Arc::make_mut` here would deep-copy the full market panel /// and defeat the candidate-plan/final-dataset reuse contract. pub fn apply_intraday_market_overlays( &mut self, overlays: Vec, ) -> Result { if overlays.is_empty() { return Ok(0); } for (component, strong_count) in [ ("daily market panel", Arc::strong_count(&self.market_by_date)), ( "market series by symbol", Arc::strong_count(&self.market_series_by_symbol), ), ( "market series by symbol id", Arc::strong_count(&self.market_series_by_symbol_id), ), ] { if strong_count != 1 { return Err(DataSetError::SharedComponentMutation { component }); } } let mut resolved = Vec::with_capacity(overlays.len()); let mut seen = HashSet::<(NaiveDate, u32)>::with_capacity(overlays.len()); let mut overlay_indexes_by_symbol_id = BTreeMap::>::new(); for overlay in overlays { let symbol_id = self .symbol_id_by_code .get(overlay.symbol.as_str()) .copied() .ok_or_else(|| DataSetError::MissingSnapshot { kind: "intraday_overlay_symbol", date: overlay.date, symbol: overlay.symbol.clone(), })?; if !seen.insert((overlay.date, symbol_id)) { return Err(DataSetError::DuplicateIntradayMarketOverlay { date: overlay.date, symbol: overlay.symbol, }); } let row_position = self .market_row_positions_by_date .as_ref() .as_ref() .and_then(|positions_by_date| positions_by_date.get(&overlay.date)) .and_then(|positions| positions.get(symbol_id as usize)) .copied() .filter(|position| *position != MISSING_ROW_POSITION) .map(|position| position as usize) .or_else(|| { self.market_symbol_ids_by_date .get(&overlay.date) .and_then(|symbol_ids| symbol_ids.binary_search(&symbol_id).ok()) }) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "intraday_overlay_market", date: overlay.date, symbol: overlay.symbol.clone(), })?; let overlay_index = resolved.len(); resolved.push((overlay.date, row_position, symbol_id, overlay)); overlay_indexes_by_symbol_id .entry(symbol_id) .or_default() .push(overlay_index); } let mut series_replacements = Vec::with_capacity(overlay_indexes_by_symbol_id.len()); for (symbol_id, overlay_indexes) in overlay_indexes_by_symbol_id { let existing = self .market_series_by_symbol_id .get(symbol_id as usize) .and_then(Option::as_ref) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "intraday_overlay_market_series", date: resolved[overlay_indexes[0]].0, symbol: resolved[overlay_indexes[0]].3.symbol.clone(), })?; let mut updated = (**existing).clone(); let series_overlays = overlay_indexes .iter() .map(|index| &resolved[*index].3) .collect::>(); updated .apply_intraday_market_overlays(&series_overlays) .map_err(|date| DataSetError::MissingSnapshot { kind: "intraday_overlay_market_series_date", date, symbol: updated.symbol.clone(), })?; series_replacements.push((symbol_id, Arc::new(updated))); } let market_by_date = Arc::get_mut(&mut self.market_by_date) .expect("daily market panel uniqueness checked before overlay"); for (date, row_position, _, overlay) in &resolved { let row = market_by_date .get_mut(date) .and_then(|rows| rows.get_mut(*row_position)) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "intraday_overlay_market_row", date: *date, symbol: overlay.symbol.clone(), })?; debug_assert_eq!(row.symbol, overlay.symbol); row.timestamp = overlay.timestamp.clone(); if let Some(last_price) = overlay .last_price .filter(|value| value.is_finite() && *value > 0.0) { row.last_price = last_price; } row.bid1 = overlay.bid1; row.ask1 = overlay.ask1; row.minute_volume = overlay.minute_volume; row.bid1_volume = overlay.bid1_volume; row.ask1_volume = overlay.ask1_volume; row.trading_phase = overlay.trading_phase.clone(); } let market_series_by_symbol = Arc::get_mut(&mut self.market_series_by_symbol) .expect("market series map uniqueness checked before overlay"); let market_series_by_symbol_id = Arc::get_mut(&mut self.market_series_by_symbol_id) .expect("market series id map uniqueness checked before overlay"); for (symbol_id, series) in series_replacements { let symbol = self.symbol_by_id[symbol_id as usize].to_string(); market_series_by_symbol.insert(symbol, Arc::clone(&series)); market_series_by_symbol_id[symbol_id as usize] = Some(series); } Ok(resolved.len()) } /// Replaces the run-local execution quote layer without touching the /// immutable daily panel. pub fn replace_execution_quotes(&mut self, quotes: Vec) -> usize { let execution_quotes_by_date = build_execution_quote_index(quotes); let quote_count = execution_quotes_by_date .values() .flat_map(|rows_by_symbol| rows_by_symbol.values()) .map(Vec::len) .sum(); let mut execution_quote_dates = execution_quotes_by_date.keys().copied().collect::>(); execution_quote_dates.sort_unstable(); self.execution_quotes_by_date = Arc::new(execution_quotes_by_date); self.execution_quote_dates = Arc::new(execution_quote_dates); quote_count } pub fn add_execution_quotes(&mut self, quotes: Vec) -> usize { let mut grouped = HashMap::>>::new(); for quote in quotes { grouped .entry(quote.date) .or_default() .entry(quote.symbol.clone()) .or_default() .push(quote); } let mut added = 0usize; let mut new_dates = Vec::new(); let execution_quotes_by_date = Arc::make_mut(&mut self.execution_quotes_by_date); for (date, rows_by_symbol) in grouped { let date_is_new = !execution_quotes_by_date.contains_key(&date); let target_by_symbol = execution_quotes_by_date.entry(date).or_default(); if date_is_new { new_dates.push(date); } for (symbol, mut incoming) in rows_by_symbol { incoming.sort_by_key(|quote| quote.timestamp); incoming.dedup_by(|left, right| left.timestamp == right.timestamp); let target = target_by_symbol.entry(symbol).or_default(); if target.is_empty() { added = added.saturating_add(incoming.len()); *target = incoming; continue; } let mut existing = std::mem::take(target).into_iter().peekable(); let mut incoming = incoming.into_iter().peekable(); let mut merged = Vec::with_capacity(existing.len() + incoming.len()); while let (Some(existing_quote), Some(incoming_quote)) = (existing.peek(), incoming.peek()) { match existing_quote.timestamp.cmp(&incoming_quote.timestamp) { std::cmp::Ordering::Less => { merged.push(existing.next().expect("peeked existing quote")); } std::cmp::Ordering::Greater => { merged.push(incoming.next().expect("peeked incoming quote")); added = added.saturating_add(1); } std::cmp::Ordering::Equal => { merged.push(existing.next().expect("peeked existing quote")); incoming.next(); } } } merged.extend(existing); for quote in incoming { merged.push(quote); added = added.saturating_add(1); } *target = merged; } } if !new_dates.is_empty() { let dates = Arc::make_mut(&mut self.execution_quote_dates); for date in new_dates { if let Err(index) = dates.binary_search(&date) { dates.insert(index, date); } } } added } pub fn order_book_depth_on( &self, date: NaiveDate, symbol: &str, ) -> &[IntradayOrderBookDepthLevel] { self.order_book_depth_index .get(&(date, symbol.to_string())) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn execution_quotes_on_date(&self, date: NaiveDate) -> Vec { self.execution_quotes_on_date_for_symbols(date, None) } pub fn execution_quotes_iter_on_date_for_symbols( &self, date: NaiveDate, symbols: Option<&BTreeSet>, ) -> ExecutionQuoteIterator<'_> { ExecutionQuoteIterator::new(self.execution_quotes_by_date.get(&date), symbols) } pub fn execution_quotes_on_date_for_symbols( &self, date: NaiveDate, symbols: Option<&BTreeSet>, ) -> Vec { self.execution_quotes_iter_on_date_for_symbols(date, symbols) .cloned() .collect() } pub fn remove_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize { let removed = Arc::make_mut(&mut self.execution_quotes_by_date).remove(&date); let Some(rows_by_symbol) = removed else { return 0; }; let dates = Arc::make_mut(&mut self.execution_quote_dates); if let Ok(index) = dates.binary_search(&date) { dates.remove(index); } rows_by_symbol.into_values().map(|rows| rows.len()).sum() } pub fn release_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize { let row_count = self .execution_quotes_by_date .get(&date) .map(|rows_by_symbol| rows_by_symbol.values().map(Vec::len).sum()) .unwrap_or(0); // Run data shares this immutable map with the prepared-data cache. Arc::make_mut here // would clone every date just to remove one entry and would not release the cached base. if row_count == 0 || Arc::strong_count(&self.execution_quotes_by_date) > 1 { return row_count; } self.remove_execution_quotes_on_date(date) } pub fn snapshot_components(&self) -> DataSetSnapshotComponents { let mut instruments = self.instruments.values().cloned().collect::>(); instruments.sort_by(|left, right| left.symbol.cmp(&right.symbol)); let market = self .market_by_date .values() .flat_map(|rows| rows.iter().cloned()) .collect::>(); let factors = self .factor_by_date .values() .flat_map(|rows| rows.iter().cloned()) .collect::>(); let candidates = self .candidate_by_date .values() .flat_map(|rows| rows.iter().cloned()) .collect::>(); let benchmarks = self.benchmark_by_date.values().cloned().collect::>(); let corporate_actions = self .corporate_actions_by_date .values() .flat_map(|rows| rows.iter().cloned()) .collect::>(); let execution_quotes = self .execution_quotes_by_date .values() .flat_map(|rows_by_symbol| rows_by_symbol.values()) .flat_map(|rows| rows.iter().cloned()) .collect::>(); DataSetSnapshotComponents { instruments, market, factors, candidates, benchmarks, corporate_actions, execution_quotes, } } pub fn benchmark_series(&self) -> Vec { self.benchmark_by_date.values().cloned().collect() } pub fn futures_trading_parameter( &self, date: NaiveDate, symbol: &str, ) -> Option<&FuturesTradingParameter> { self.futures_params_by_symbol.get(symbol).and_then(|rows| { rows.iter() .rev() .find(|row| row.effective_date.is_none_or(|effective| effective <= date)) }) } pub fn futures_settlement_price( &self, date: NaiveDate, symbol: &str, mode: &str, ) -> Option { let snapshot = self.market(date, symbol)?; match normalize_field(mode).as_str() { "settlement" | "settle" => self .factor_numeric_value(date, symbol, "settlement") .or_else(|| self.factor_numeric_value(date, symbol, "settle")) .or(Some(snapshot.close)), "prev_settlement" | "pre_settlement" => self .factor_numeric_value(date, symbol, "prev_settlement") .or_else(|| self.factor_numeric_value(date, symbol, "pre_settlement")) .or(Some(snapshot.prev_close)), _ => Some(snapshot.close), } } pub fn history_bars( &self, date: NaiveDate, symbol: &str, bar_count: usize, frequency: &str, field: &str, include_now: bool, ) -> Vec { self.history_bars_at(date, None, symbol, bar_count, frequency, field, include_now) } pub fn history_bars_at( &self, date: NaiveDate, active_datetime: Option, symbol: &str, bar_count: usize, frequency: &str, field: &str, include_now: bool, ) -> Vec { if bar_count == 0 { return Vec::new(); } match normalize_history_frequency(frequency).as_deref() { Some("1d") => self.history_daily_values(date, symbol, bar_count, field, include_now), Some("1m") => self.history_intraday_values( date, active_datetime, symbol, bar_count, field, include_now, ), _ => Vec::new(), } } pub fn history_daily_snapshots( &self, date: NaiveDate, symbol: &str, bar_count: usize, include_now: bool, ) -> Vec { self.market_series(symbol) .map(|series| series.trailing_snapshots(date, bar_count, include_now)) .unwrap_or_default() } pub fn history_intraday_quotes( &self, date: NaiveDate, symbol: &str, bar_count: usize, include_now: bool, ) -> Vec { self.history_intraday_quotes_at(date, None, symbol, bar_count, include_now) } pub fn history_intraday_quotes_at( &self, date: NaiveDate, active_datetime: Option, symbol: &str, bar_count: usize, include_now: bool, ) -> Vec { if bar_count == 0 { return Vec::new(); } let end = self .execution_quote_dates .partition_point(|quote_date| *quote_date <= date); let mut quotes = Vec::with_capacity(bar_count); 'dates: for quote_date in self.execution_quote_dates[..end].iter().rev() { let Some(rows) = self .execution_quotes_by_date .get(quote_date) .and_then(|rows_by_symbol| rows_by_symbol.get(symbol)) else { continue; }; for quote in rows.iter().rev() { if intraday_quote_visible(quote, date, active_datetime, include_now) { quotes.push(quote.clone()); if quotes.len() == bar_count { break 'dates; } } } } quotes.reverse(); quotes } pub fn trading_dates(&self, start: NaiveDate, end: NaiveDate) -> Vec { self.calendar.trading_dates(start, end) } pub fn previous_trading_date(&self, date: NaiveDate, n: usize) -> Option { self.calendar.previous_trading_date(date, n) } pub fn next_trading_date(&self, date: NaiveDate, n: usize) -> Option { self.calendar.next_trading_date(date, n) } pub fn is_suspended_flags(&self, date: NaiveDate, symbol: &str, count: usize) -> Vec { self.historical_daily_flags(date, symbol, count, |candidate, market| { candidate.is_some_and(|row| row.is_paused) || market.is_some_and(|row| row.paused) }) } pub fn is_st_stock_flags(&self, date: NaiveDate, symbol: &str, count: usize) -> Vec { self.historical_daily_flags(date, symbol, count, |candidate, _| { candidate.is_some_and(|row| row.is_st) }) } pub fn get_dividend( &self, symbol: &str, start: NaiveDate, end: NaiveDate, ) -> Vec { let mut rows = self .corporate_actions_by_date .range(start..=end) .flat_map(|(_, actions)| actions.iter()) .filter(|action| action.symbol == symbol && action.share_cash.abs() > f64::EPSILON) .map(|action| DividendRecord { ex_dividend_date: action.date, payable_date: action.payable_date.unwrap_or(action.date), symbol: action.symbol.clone(), dividend_cash_before_tax: action.share_cash, round_lot: self .instrument(symbol) .map(Instrument::effective_round_lot) .unwrap_or(100), }) .collect::>(); rows.sort_by_key(|row| row.ex_dividend_date); rows } pub fn get_split(&self, symbol: &str, start: NaiveDate, end: NaiveDate) -> Vec { let mut rows = self .corporate_actions_by_date .range(start..=end) .flat_map(|(_, actions)| actions.iter()) .filter(|action| action.symbol == symbol && (action.split_ratio() - 1.0).abs() > 1e-12) .map(|action| SplitRecord { ex_dividend_date: action.date, symbol: action.symbol.clone(), split_ratio: action.split_ratio(), }) .collect::>(); rows.sort_by_key(|row| row.ex_dividend_date); rows } pub fn get_factor( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { if start > end { return Vec::new(); } let Some(symbol_id) = self.symbol_id(symbol) else { return Vec::new(); }; let field = normalize_field(field); let mut rows = self .factor_by_date .range(start..=end) .filter_map(|(date, _)| self.factor_by_symbol_id(*date, symbol_id)) .filter_map(|snapshot| { factor_numeric_value(snapshot, &field).map(|value| FactorValue { date: snapshot.date, symbol: snapshot.symbol.clone(), field: field.clone(), value, }) }) .collect::>(); rows.sort_by_key(|row| row.date); rows } pub fn get_factor_text( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { if start > end { return Vec::new(); } let field = normalize_field(field); let mut rows = self .factor_text_by_date .range(start..=end) .flat_map(|(_, snapshots)| snapshots.iter()) .filter(|snapshot| { snapshot.symbol == symbol && normalize_field(&snapshot.field) == field }) .cloned() .collect::>(); rows.sort_by_key(|row| row.date); rows } pub fn get_yield_curve( &self, start: NaiveDate, end: NaiveDate, tenor: Option<&str>, ) -> Vec { if start > end { return Vec::new(); } let tenor_filter = tenor.map(normalize_field); let mut rows = Vec::new(); for (date, snapshots) in self.factor_by_date.range(start..=end) { for snapshot in snapshots { for (field, value) in &snapshot.extra_factors { let normalized = normalize_field(field); let Some(raw_tenor) = normalized .strip_prefix("yield_curve_") .or_else(|| normalized.strip_prefix("yc_")) else { continue; }; if tenor_filter .as_ref() .is_some_and(|expected| expected != raw_tenor) { continue; } rows.push(YieldCurvePoint { date: *date, tenor: raw_tenor.to_string(), value: *value, }); } } } rows.sort_by(|left, right| { left.date .cmp(&right.date) .then(left.tenor.cmp(&right.tenor)) }); rows } pub fn get_margin_stocks(&self, date: NaiveDate, margin_type: &str) -> Vec { let field = match normalize_field(margin_type).as_str() { "stock" => "margin_stock", "cash" => "margin_cash", _ => "margin_all", }; let mut symbols = self .factor_by_date .get(&date) .map(|rows| { rows.iter() .filter(|row| { row.extra_factors .get(field) .or_else(|| row.extra_factors.get("margin_all")) .is_some_and(|value| *value > 0.0) }) .map(|row| row.symbol.clone()) .collect::>() }) .unwrap_or_default(); if symbols.is_empty() { symbols = self .active_instruments( date, &self .instruments .keys() .map(String::as_str) .collect::>(), ) .into_iter() .filter(|instrument| !instrument.board.eq_ignore_ascii_case("FUTURE")) .map(|instrument| instrument.symbol.clone()) .collect(); } symbols.sort(); symbols.dedup(); symbols } pub fn get_securities_margin( &self, symbol: &str, start: NaiveDate, end: NaiveDate, field: &str, ) -> Vec { self.get_factor(symbol, start, end, field) .into_iter() .map(|row| SecuritiesMarginRecord { date: row.date, symbol: row.symbol, field: row.field, value: row.value, }) .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_industry_name( &self, symbol: &str, date: NaiveDate, source: &str, level: usize, ) -> Option { let fields = industry_name_factor_aliases(source, level); for (factor_date, snapshots) in self.factor_text_by_date.range(..=date).rev() { for snapshot in snapshots { if snapshot.symbol != symbol { continue; } let normalized = normalize_field(&snapshot.field); if fields.iter().any(|field| field == &normalized) { return Some(FactorTextValue { date: *factor_date, symbol: snapshot.symbol.clone(), field: snapshot.field.clone(), value: snapshot.value.clone(), }); } } } None } pub fn get_dominant_future(&self, underlying_symbol: &str, date: NaiveDate) -> Option { let underlying = normalize_field(underlying_symbol); let mut candidates = self .futures_params_by_symbol .keys() .filter(|symbol| normalize_field(symbol).starts_with(&underlying)) .filter(|symbol| { self.futures_trading_parameter(date, symbol.as_str()) .is_some() }) .cloned() .collect::>(); if candidates.is_empty() { candidates = self .instruments .values() .filter(|instrument| instrument.board.eq_ignore_ascii_case("FUTURE")) .filter(|instrument| normalize_field(&instrument.symbol).starts_with(&underlying)) .filter(|instrument| instrument.is_active_on(date)) .map(|instrument| instrument.symbol.clone()) .collect(); } candidates.sort(); candidates.into_iter().next() } pub fn get_dominant_future_price( &self, underlying_symbol: &str, start: NaiveDate, end: NaiveDate, frequency: &str, ) -> Vec { let Some(symbol) = self.get_dominant_future(underlying_symbol, end) else { return Vec::new(); }; self.get_price(&symbol, start, end, frequency) } pub fn get_price( &self, symbol: &str, start: NaiveDate, end: NaiveDate, frequency: &str, ) -> Vec { if start > end { return Vec::new(); } match normalize_history_frequency(frequency).as_deref() { Some("1d") => self .market_by_date .range(start..=end) .flat_map(|(_, rows)| rows.iter()) .filter(|row| row.symbol == symbol) .map(daily_market_price_bar) .collect(), Some("1m") => { let mut bars = self .execution_quotes_by_date .iter() .filter(|(date, _)| **date >= start && **date <= end) .filter_map(|(_, rows_by_symbol)| rows_by_symbol.get(symbol)) .flat_map(|rows| rows.iter()) .map(intraday_quote_price_bar) .collect::>(); bars.sort_by(|left, right| { left.date .cmp(&right.date) .then_with(|| left.timestamp.cmp(&right.timestamp)) }); bars } _ => Vec::new(), } } pub fn price(&self, date: NaiveDate, symbol: &str, field: PriceField) -> Option { let snapshot = self.market(date, symbol)?; Some(snapshot.price(field)) } pub fn price_on_or_before( &self, date: NaiveDate, symbol: &str, field: PriceField, ) -> Option { self.market_series(symbol) .and_then(|series| series.price_on_or_before(date, field)) } pub fn market_before(&self, date: NaiveDate, symbol: &str) -> Option<&DailyMarketSnapshot> { let series = self.market_series(symbol)?; let end = series.previous_completed_end_index(date)?; if end == 0 { return None; } let previous_date = *series.dates.get(end - 1)?; self.market(previous_date, symbol) } pub fn factor_snapshots_on(&self, date: NaiveDate) -> Vec<&DailyFactorSnapshot> { self.factor_by_date .get(&date) .map(|rows| rows.iter().collect()) .unwrap_or_default() } pub fn factor_snapshot_rows_on(&self, date: NaiveDate) -> &[DailyFactorSnapshot] { self.factor_by_date .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn factor_symbol_ids_on(&self, date: NaiveDate) -> &[u32] { self.factor_symbol_ids_by_date .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn factor_text_snapshots_on(&self, date: NaiveDate) -> Vec<&FactorTextValue> { self.factor_text_by_date .get(&date) .map(|rows| rows.iter().collect()) .unwrap_or_default() } pub fn factor_text_rows_on(&self, date: NaiveDate) -> &[FactorTextValue] { self.factor_text_by_date .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn market_snapshots_on(&self, date: NaiveDate) -> Vec<&DailyMarketSnapshot> { self.market_by_date .get(&date) .map(|rows| rows.iter().collect()) .unwrap_or_default() } pub fn market_snapshot_rows_on(&self, date: NaiveDate) -> &[DailyMarketSnapshot] { self.market_by_date .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn candidate_snapshots_on(&self, date: NaiveDate) -> Vec<&CandidateEligibility> { self.candidate_by_date .get(&date) .map(|rows| rows.iter().collect()) .unwrap_or_default() } pub fn candidate_snapshot_rows_on(&self, date: NaiveDate) -> &[CandidateEligibility] { self.candidate_by_date .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn bundle_on(&self, date: NaiveDate) -> Result { let benchmark = self .benchmark(date) .cloned() .ok_or(DataSetError::MissingBenchmark { date })?; Ok(DailySnapshotBundle { date, benchmark, market: self.market_by_date.get(&date).cloned().unwrap_or_default(), factors: self.factor_by_date.get(&date).cloned().unwrap_or_default(), candidates: self .candidate_by_date .get(&date) .cloned() .unwrap_or_default(), corporate_actions: self .corporate_actions_by_date .get(&date) .cloned() .unwrap_or_default(), }) } pub fn benchmark_closes_up_to(&self, date: NaiveDate, lookback: usize) -> Vec { self.benchmark_series_cache.trailing_values(date, lookback) } pub fn market_closes_up_to(&self, date: NaiveDate, symbol: &str, lookback: usize) -> Vec { self.market_series(symbol) .map(|series| series.trailing_values(date, lookback, PriceField::Close)) .unwrap_or_default() } fn history_daily_values( &self, date: NaiveDate, symbol: &str, bar_count: usize, field: &str, include_now: bool, ) -> Vec { self.market_series(symbol) .map(|series| series.trailing_numeric_values(date, bar_count, field, include_now)) .unwrap_or_default() } fn history_intraday_values( &self, date: NaiveDate, active_datetime: Option, symbol: &str, bar_count: usize, field: &str, include_now: bool, ) -> Vec { self.history_intraday_quotes_at(date, active_datetime, symbol, bar_count, include_now) .into_iter() .filter_map(|row| intraday_quote_numeric_value(&row, field)) .collect() } fn historical_daily_flags( &self, date: NaiveDate, symbol: &str, count: usize, evaluator: F, ) -> Vec where F: Fn(Option<&CandidateEligibility>, Option<&DailyMarketSnapshot>) -> bool, { if count == 0 { return Vec::new(); } let days = self .calendar .iter() .filter(|day| *day <= date) .collect::>(); let start = days.len().saturating_sub(count); days[start..] .iter() .map(|day| evaluator(self.candidate(*day, symbol), self.market(*day, symbol))) .collect() } pub fn market_decision_close(&self, date: NaiveDate, symbol: &str) -> Option { self.market_series(symbol) .and_then(|series| series.decision_price_on_or_before(date)) } pub fn market_decision_close_moving_average( &self, date: NaiveDate, symbol: &str, lookback: usize, ) -> Option { self.market_series(symbol) .and_then(|series| series.decision_close_moving_average(date, lookback)) } pub fn market_decision_volume_moving_average( &self, date: NaiveDate, symbol: &str, lookback: usize, ) -> Option { self.market_series(symbol) .and_then(|series| series.decision_volume_moving_average(date, lookback)) } pub fn factor_numeric_value(&self, date: NaiveDate, symbol: &str, field: &str) -> Option { self.factor(date, symbol) .and_then(|snapshot| factor_numeric_value(snapshot, field)) } pub fn factor_text_value(&self, date: NaiveDate, symbol: &str, field: &str) -> Option { self.factor_text_index .get(&(date, symbol.to_string(), normalize_field(field))) .map(|row| row.value.clone()) } 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, symbol: &str, field: &str, lookback: usize, ) -> Option { if lookback == 0 { return None; } let dates = self.calendar.trailing_days(date, lookback); if dates.is_empty() { return None; } let mut sum = 0.0_f64; let mut count = 0usize; for trading_day in dates { let snapshot = self.factor(trading_day, symbol)?; let value = factor_numeric_value(snapshot, field)?; sum += value; count += 1; } if count == 0 { None } else { Some(sum / count as f64) } } pub fn market_decision_numeric_moving_average( &self, date: NaiveDate, symbol: &str, field: &str, lookback: usize, ) -> Option { let field = normalized_field(field); match field.as_ref() { "close" | "prev_close" | "stock_close" | "price" => self .adjusted_close_series(symbol) .and_then(|series| series.decision_moving_average(date, lookback)), "volume" | "stock_volume" => self .market_series(symbol) .and_then(|series| series.decision_volume_moving_average(date, lookback)), "day_open" | "dayopen" => { self.market_moving_average(date, symbol, lookback, PriceField::DayOpen) } "open" => self.market_moving_average(date, symbol, lookback, PriceField::Open), "last" | "last_price" => { self.market_moving_average(date, symbol, lookback, PriceField::Last) } other => self.factor_moving_average(date, symbol, other, lookback), } } pub fn market_decision_numeric_moving_average_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, symbol: &str, field: &str, lookback: usize, ) -> Option { let field = normalized_field(field); match field.as_ref() { "close" | "prev_close" | "stock_close" | "price" => self .adjusted_close_series_by_symbol_id(symbol_id) .and_then(|series| { self.market_series_end_index_by_symbol_id(date, symbol_id, false) .map(|end| series.moving_average_at_end(end, lookback)) .unwrap_or_else(|| series.decision_moving_average(date, lookback)) }), "volume" | "stock_volume" => { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { self.market_series_end_index_by_symbol_id(date, symbol_id, false) .map(|end| { series .valid_volume_window(end, lookback) .map(|(start, end)| { normalize_rolling_factor( (series.valid_volume_sum_prefix[end] - series.valid_volume_sum_prefix[start]) / lookback as f64, 12, ) }) }) .unwrap_or_else(|| { series.decision_volume_moving_average(date, lookback) }) }) } "day_open" | "dayopen" => { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { self.market_series_end_index_by_symbol_id(date, symbol_id, false) .map(|end| { series.moving_average_at_end(end, lookback, PriceField::DayOpen) }) .unwrap_or_else(|| { series.moving_average(date, lookback, PriceField::DayOpen) }) }) } "open" => self .market_series_by_symbol_id(symbol_id) .and_then(|series| { self.market_series_end_index_by_symbol_id(date, symbol_id, false) .map(|end| series.moving_average_at_end(end, lookback, PriceField::Open)) .unwrap_or_else(|| series.moving_average(date, lookback, PriceField::Open)) }), "last" | "last_price" => { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { self.market_series_end_index_by_symbol_id(date, symbol_id, false) .map(|end| { series.moving_average_at_end(end, lookback, PriceField::Last) }) .unwrap_or_else(|| { series.moving_average(date, lookback, PriceField::Last) }) }) } other => self.factor_moving_average(date, symbol, other, lookback), } } pub fn market_current_numeric_moving_average( &self, date: NaiveDate, symbol: &str, field: &str, lookback: usize, ) -> Option { let field = normalized_field(field); match field.as_ref() { "close" | "prev_close" | "stock_close" | "price" => self .adjusted_close_series(symbol) .and_then(|series| series.current_moving_average(date, lookback)), "volume" | "stock_volume" => self .market_series(symbol) .and_then(|series| series.current_volume_moving_average(date, lookback)), "day_open" | "dayopen" => { self.market_moving_average(date, symbol, lookback, PriceField::DayOpen) } "open" => self.market_moving_average(date, symbol, lookback, PriceField::Open), "last" | "last_price" => { self.market_moving_average(date, symbol, lookback, PriceField::Last) } other => self.factor_moving_average(date, symbol, other, lookback), } } pub fn market_current_numeric_moving_average_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, symbol: &str, field: &str, lookback: usize, ) -> Option { let normalized = normalized_field(field); let series_end = matches!( normalized.as_ref(), "close" | "prev_close" | "stock_close" | "price" | "volume" | "stock_volume" | "day_open" | "dayopen" | "open" | "last" | "last_price" ) .then(|| self.market_current_series_end_index_by_symbol_id(date, symbol_id)) .flatten(); self.market_current_numeric_moving_average_with_end_by_symbol_id( date, symbol_id, symbol, normalized.as_ref(), lookback, series_end, ) } pub(crate) fn market_current_numeric_moving_average_with_end_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, symbol: &str, field: &str, lookback: usize, series_end: Option, ) -> Option { let field = normalized_field(field); match field.as_ref() { "close" | "prev_close" | "stock_close" | "price" => self .market_current_close_moving_average_with_end_by_symbol_id( date, symbol_id, lookback, series_end, ), "volume" | "stock_volume" => self .market_current_volume_moving_average_with_end_by_symbol_id( date, symbol_id, lookback, series_end, ), "day_open" | "dayopen" => { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { series_end .map(|end| { series.moving_average_at_end(end, lookback, PriceField::DayOpen) }) .unwrap_or_else(|| { series.moving_average(date, lookback, PriceField::DayOpen) }) }) } "open" => self .market_series_by_symbol_id(symbol_id) .and_then(|series| { series_end .map(|end| series.moving_average_at_end(end, lookback, PriceField::Open)) .unwrap_or_else(|| series.moving_average(date, lookback, PriceField::Open)) }), "last" | "last_price" => { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { series_end .map(|end| { series.moving_average_at_end(end, lookback, PriceField::Last) }) .unwrap_or_else(|| { series.moving_average(date, lookback, PriceField::Last) }) }) } other => self.factor_moving_average(date, symbol, other, lookback), } } pub(crate) fn market_current_close_moving_average_with_end_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, lookback: usize, series_end: Option, ) -> Option { self.adjusted_close_series_by_symbol_id(symbol_id) .and_then(|series| { series_end .map(|end| series.moving_average_at_end(end, lookback)) .unwrap_or_else(|| series.current_moving_average(date, lookback)) }) } pub(crate) fn market_current_volume_moving_average_with_end_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, lookback: usize, series_end: Option, ) -> Option { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { series_end .map(|end| { series .valid_volume_window(end, lookback) .map(|(start, end)| { normalize_rolling_factor( (series.valid_volume_sum_prefix[end] - series.valid_volume_sum_prefix[start]) / lookback as f64, 12, ) }) }) .unwrap_or_else(|| series.current_volume_moving_average(date, lookback)) }) } pub fn market_latest_back_adjusted_close(&self, date: NaiveDate, symbol: &str) -> Option { self.adjusted_close_series(symbol) .and_then(|series| series.latest_back_adjusted_close(date)) } pub fn market_decision_numeric_values( &self, date: NaiveDate, symbol: &str, field: &str, lookback: usize, ) -> Vec { if lookback == 0 { return Vec::new(); } let field = normalized_field(field); match field.as_ref() { "close" | "prev_close" | "stock_close" | "price" => self .adjusted_close_series(symbol) .map(|series| series.values(date, lookback, false)) .unwrap_or_default(), "volume" | "stock_volume" => self .market_series(symbol) .and_then(|series| series.decision_volume_values(date, lookback)) .unwrap_or_default(), "day_open" | "dayopen" => self .market_series(symbol) .map(|series| series.trailing_values(date, lookback, PriceField::DayOpen)) .unwrap_or_default(), "open" => self .market_series(symbol) .map(|series| series.trailing_values(date, lookback, PriceField::Open)) .unwrap_or_default(), "last" | "last_price" => self .market_series(symbol) .map(|series| series.trailing_values(date, lookback, PriceField::Last)) .unwrap_or_default(), other => self.factor_numeric_values(date, symbol, other, lookback), } } pub fn market_current_numeric_values( &self, date: NaiveDate, symbol: &str, field: &str, lookback: usize, ) -> Vec { let field = normalized_field(field); if matches!( field.as_ref(), "close" | "prev_close" | "stock_close" | "price" ) { return self .adjusted_close_series(symbol) .map(|series| series.values(date, lookback, true)) .unwrap_or_default(); } if matches!(field.as_ref(), "volume" | "stock_volume") { return self .market_series(symbol) .and_then(|series| series.current_volume_values(date, lookback)) .unwrap_or_default(); } self.market_series(symbol) .map(|series| series.trailing_numeric_values(date, lookback, field.as_ref(), true)) .unwrap_or_default() } pub fn factor_numeric_values( &self, date: NaiveDate, symbol: &str, field: &str, lookback: usize, ) -> Vec { if lookback == 0 { return Vec::new(); } self.calendar .trailing_days(date, lookback) .into_iter() .filter_map(|trading_day| self.factor(trading_day, symbol)) .filter_map(|snapshot| factor_numeric_value(snapshot, field)) .collect() } pub fn market_moving_average( &self, date: NaiveDate, symbol: &str, lookback: usize, field: PriceField, ) -> Option { self.market_series(symbol) .and_then(|series| series.moving_average(date, lookback, field)) } pub fn benchmark_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { self.benchmark_series_cache.moving_average(date, lookback) } pub fn benchmark_decision_close(&self, date: NaiveDate) -> Option { self.benchmark_series_cache.decision_close(date) } pub fn benchmark_decision_moving_average( &self, date: NaiveDate, lookback: usize, ) -> Option { self.benchmark_series_cache .decision_moving_average(date, lookback) } pub fn benchmark_open_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { self.benchmark_series_cache .moving_average_for(date, lookback, PriceField::Open) } pub fn benchmark_numeric_values( &self, date: NaiveDate, field: &str, lookback: usize, ) -> Vec { let field = normalize_field(field); match field.as_str() { "open" | "day_open" | "dayopen" | "benchmark_open" => self .benchmark_series_cache .trailing_values_for(date, lookback, PriceField::Open), _ => self.benchmark_series_cache.trailing_values(date, lookback), } } pub fn benchmark_decision_numeric_values( &self, date: NaiveDate, field: &str, lookback: usize, ) -> Vec { let field = normalize_field(field); match field.as_str() { "open" | "day_open" | "dayopen" | "benchmark_open" => self .benchmark_series_cache .trailing_values_for(date, lookback, PriceField::Open), _ => self .benchmark_series_cache .decision_values_for(date, lookback, PriceField::Close), } } pub fn market_open_moving_average( &self, date: NaiveDate, symbol: &str, lookback: usize, ) -> Option { self.market_moving_average(date, symbol, lookback, PriceField::Open) } pub fn eligible_universe_on(&self, date: NaiveDate) -> &[EligibleUniverseSnapshot] { self.eligible_universe_by_date .get_or_init(|| build_eligible_universe(&self.factor_by_date, &self.market_by_date)) .get(&date) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn fundamental_universe_on(&self, date: NaiveDate) -> Vec { build_fundamental_universe_for_date(date, &self.factor_by_date, &self.market_by_date) } pub fn eligible_universe_on_with_risk_config( &self, date: NaiveDate, risk_config: &FidcRiskControlConfig, ) -> Vec { build_eligible_universe_for_date( date, &self.factor_by_date, &self.candidate_by_date, &self.market_by_date, &self.instruments, risk_config, ) } pub fn require_market( &self, date: NaiveDate, symbol: &str, ) -> Result<&DailyMarketSnapshot, DataSetError> { self.market(date, symbol) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "market", date, symbol: symbol.to_string(), }) } pub fn require_market_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, symbol: &str, ) -> Result<&DailyMarketSnapshot, DataSetError> { self.market_by_symbol_id(date, symbol_id) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "market", date, symbol: symbol.to_string(), }) } pub fn require_candidate( &self, date: NaiveDate, symbol: &str, ) -> Result<&CandidateEligibility, DataSetError> { self.candidate(date, symbol) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "candidate", date, symbol: symbol.to_string(), }) } pub fn require_candidate_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, symbol: &str, ) -> Result<&CandidateEligibility, DataSetError> { self.candidate_by_symbol_id(date, symbol_id) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "candidate", date, symbol: symbol.to_string(), }) } pub fn require_factor( &self, date: NaiveDate, symbol: &str, ) -> Result<&DailyFactorSnapshot, DataSetError> { self.factor(date, symbol) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "factor", date, symbol: symbol.to_string(), }) } pub fn require_factor_by_symbol_id( &self, date: NaiveDate, symbol_id: u32, symbol: &str, ) -> Result<&DailyFactorSnapshot, DataSetError> { self.factor_by_symbol_id(date, symbol_id) .ok_or_else(|| DataSetError::MissingSnapshot { kind: "factor", date, symbol: symbol.to_string(), }) } } 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 industry_name_factor_aliases(source: &str, level: usize) -> Vec { let source = normalize_field(source); normalized_aliases(&[ format!("industry_{source}_l{level}_name"), format!("industry_{source}_{level}_name"), format!("industry_{source}_name_l{level}"), format!("{source}_industry_l{level}_name"), format!("{source}_industry_{level}_name"), format!("{source}_industry_name_l{level}"), format!("industry_l{level}_name"), format!("industry_{level}_name"), "industry_name".to_string(), ]) } fn factor_numeric_value(snapshot: &DailyFactorSnapshot, field: &str) -> Option { let field = normalized_field(field); match field.as_ref() { "market_cap" | "market_cap_bn" => Some(snapshot.market_cap_bn), "free_float_cap" | "free_float_market_cap" | "free_float_cap_bn" => { Some(snapshot.free_float_cap_bn) } "free_float_cap_or_market_cap" => Some( (snapshot.free_float_cap_bn.is_finite() && snapshot.free_float_cap_bn > 0.0) .then_some(snapshot.free_float_cap_bn) .unwrap_or(snapshot.market_cap_bn), ), "pe_ttm" => Some(snapshot.pe_ttm), "turnover_ratio" => snapshot.turnover_ratio, "effective_turnover_ratio" => snapshot.effective_turnover_ratio, BACKWARD_ADJUSTMENT_FACTOR_FIELD => snapshot.adjustment_factor_backward1, "ths_market_value_stock" | "ths_market_value_stock_bn" => snapshot .extra_factors .get(field.as_ref()) .copied() .or(Some(snapshot.market_cap_bn)), "ths_current_mv_stock" | "ths_current_mv_stock_bn" => snapshot .extra_factors .get(field.as_ref()) .copied() .or(Some(snapshot.free_float_cap_bn)), "ths_turnover_ratio_stock" => snapshot .extra_factors .get(field.as_ref()) .copied() .or(snapshot.turnover_ratio), "ths_vaild_turnover_stock" | "ths_valid_turnover_stock" => snapshot .extra_factors .get(field.as_ref()) .copied() .or(snapshot.effective_turnover_ratio), other => snapshot.extra_factors.get(other).copied(), } } fn intraday_quote_numeric_value(snapshot: &IntradayExecutionQuote, field: &str) -> Option { match normalized_field(field).as_ref() { "last" | "last_price" | "close" | "price" => Some(snapshot.last_price), "bid1" => Some(snapshot.bid1), "ask1" => Some(snapshot.ask1), "bid1_volume" => Some(snapshot.bid1_volume as f64), "ask1_volume" => Some(snapshot.ask1_volume as f64), "volume" | "volume_delta" => Some(snapshot.volume_delta as f64), "amount" | "amount_delta" | "total_turnover" => Some(snapshot.amount_delta), _ => None, } } fn intraday_quote_visible( quote: &IntradayExecutionQuote, date: NaiveDate, active_datetime: Option, include_now: bool, ) -> bool { if quote.date < date { return true; } if quote.date > date { return false; } let Some(active_datetime) = active_datetime.filter(|value| value.date() == date) else { return include_now; }; if include_now { quote.timestamp <= active_datetime } else { quote.timestamp < active_datetime } } fn daily_market_price_bar(snapshot: &DailyMarketSnapshot) -> PriceBar { PriceBar { date: snapshot.date, timestamp: snapshot.timestamp.clone(), symbol: snapshot.symbol.clone(), frequency: "1d".to_string(), open: snapshot.open, high: snapshot.high, low: snapshot.low, close: snapshot.close, last_price: snapshot.last_price, volume: snapshot.volume, amount: 0.0, bid1: snapshot.bid1, ask1: snapshot.ask1, bid1_volume: snapshot.bid1_volume, ask1_volume: snapshot.ask1_volume, } } fn intraday_quote_price_bar(snapshot: &IntradayExecutionQuote) -> PriceBar { PriceBar { date: snapshot.date, timestamp: Some(snapshot.timestamp.format("%Y-%m-%d %H:%M:%S").to_string()), symbol: snapshot.symbol.clone(), frequency: "1m".to_string(), open: snapshot.last_price, high: snapshot.last_price, low: snapshot.last_price, close: snapshot.last_price, last_price: snapshot.last_price, volume: snapshot.volume_delta, amount: snapshot.amount_delta, bid1: snapshot.bid1, ask1: snapshot.ask1, bid1_volume: snapshot.bid1_volume, ask1_volume: snapshot.ask1_volume, } } fn normalize_field(field: &str) -> String { normalized_field(field).into_owned() } fn normalized_field(field: &str) -> Cow<'_, str> { let trimmed = field.trim().trim_matches('"').trim_matches('\''); if trimmed.bytes().all(|byte| !byte.is_ascii_uppercase()) { Cow::Borrowed(trimmed) } else { Cow::Owned(trimmed.to_ascii_lowercase()) } } fn normalize_factor_snapshots( factors: Vec, ) -> Result, DataSetError> { factors .into_iter() .map(|mut snapshot| { if snapshot .extra_factors .contains_key(BACKWARD_ADJUSTMENT_FACTOR_FIELD) { return Err(DataSetError::ReservedTypedFactorInExtraMap { date: snapshot.date, symbol: snapshot.symbol, field: BACKWARD_ADJUSTMENT_FACTOR_FIELD, }); } if let Some(value) = snapshot.adjustment_factor_backward1 && (!value.is_finite() || value <= 0.0) { return Err(DataSetError::InvalidBackwardAdjustmentFactor { date: snapshot.date, symbol: snapshot.symbol, value, }); } let already_normalized = snapshot.extra_factors.iter().all(|(field, value)| { let trimmed = field.as_ref().trim().trim_matches('"').trim_matches('\''); !trimmed.is_empty() && trimmed == field.as_ref() && trimmed.bytes().all(|byte| !byte.is_ascii_uppercase()) && value.is_finite() }); if already_normalized { return Ok(snapshot); } snapshot.extra_factors = snapshot .extra_factors .into_iter() .filter_map(|(field, value)| { let trimmed = field.as_ref().trim().trim_matches('"').trim_matches('\''); if trimmed.is_empty() || !value.is_finite() { None } else if trimmed == field.as_ref() && trimmed.bytes().all(|byte| !byte.is_ascii_uppercase()) { Some((field, value)) } else { Some((Cow::Owned(trimmed.to_ascii_lowercase()), value)) } }) .collect(); if snapshot .extra_factors .contains_key(BACKWARD_ADJUSTMENT_FACTOR_FIELD) { return Err(DataSetError::ReservedTypedFactorInExtraMap { date: snapshot.date, symbol: snapshot.symbol, field: BACKWARD_ADJUSTMENT_FACTOR_FIELD, }); } Ok(snapshot) }) .collect() } fn normalize_history_frequency(frequency: &str) -> Option { let normalized = normalize_field(frequency); match normalized.as_str() { "1d" | "d" | "day" | "daily" => Some("1d".to_string()), "1m" | "m" | "minute" | "min" => Some("1m".to_string()), _ => None, } } fn validate_daily_bundle_component_dates( rows: &[T], bundle_date: NaiveDate, kind: &'static str, date_of: D, symbol_of: S, ) -> Result<(), DataSetError> where D: Fn(&T) -> NaiveDate, S: Fn(&T) -> &str, { if let Some(row) = rows.iter().find(|row| date_of(row) != bundle_date) { return Err(DataSetError::InvalidDailyBundleComponentDate { kind, bundle_date, row_date: date_of(row), symbol: symbol_of(row).to_string(), }); } Ok(()) } fn group_by_date(rows: Vec, mut date_of: F) -> BTreeMap> where F: FnMut(&T) -> NaiveDate, { let mut grouped = BTreeMap::>::new(); for row in rows { grouped.entry(date_of(&row)).or_default().push(row); } grouped } fn sort_groups_by_symbol(groups: &mut BTreeMap>, symbol_of: F) where F: Fn(&T) -> &str + Copy, { for rows in groups.values_mut() { rows.sort_by(|left, right| symbol_of(left).cmp(symbol_of(right))); } } fn sort_rows_by_symbol_if_needed(rows: &mut Vec, symbol_of: F) where F: Fn(&T) -> &str + Copy, { if !rows .windows(2) .all(|window| symbol_of(&window[0]) <= symbol_of(&window[1])) { rows.sort_by(|left, right| symbol_of(left).cmp(symbol_of(right))); } } fn build_symbol_id_index( instruments: &HashMap, market_by_date: &BTreeMap>, factor_by_date: &BTreeMap>, candidate_by_date: &BTreeMap>, ) -> AHashMap { let mut symbols = instruments.keys().cloned().collect::>(); for rows in market_by_date.values() { for row in rows { if !symbols.contains(row.symbol.as_str()) { symbols.insert(row.symbol.clone()); } } } for rows in factor_by_date.values() { for row in rows { if !symbols.contains(row.symbol.as_str()) { symbols.insert(row.symbol.clone()); } } } for rows in candidate_by_date.values() { for row in rows { if !symbols.contains(row.symbol.as_str()) { symbols.insert(row.symbol.clone()); } } } let mut symbols = symbols.into_iter().collect::>(); symbols.sort_unstable(); symbols .into_iter() .enumerate() .map(|(index, symbol)| { ( symbol, u32::try_from(index).expect("FIDC symbol index exceeds u32 capacity"), ) }) .collect() } fn build_group_symbol_ids( groups: &BTreeMap>, symbol_id_by_code: &AHashMap, symbol_of: F, ) -> BTreeMap> where F: Fn(&T) -> &str + Copy, { groups .iter() .map(|(date, rows)| { let symbol_ids = rows .iter() .map(|row| { *symbol_id_by_code .get(symbol_of(row)) .expect("snapshot symbol missing from FIDC symbol index") }) .collect::>(); debug_assert!(symbol_ids.windows(2).all(|window| window[0] < window[1])); (*date, symbol_ids) }) .collect() } fn group_rows_by_symbol_id<'a, T>( kind: &'static str, groups: &'a BTreeMap>, symbol_ids_by_date: &BTreeMap>, symbol_count: usize, ) -> Result>, DataSetError> { let mut row_counts_by_symbol_id = vec![0usize; symbol_count]; for symbol_id in symbol_ids_by_date.values().flatten() { row_counts_by_symbol_id[*symbol_id as usize] = row_counts_by_symbol_id[*symbol_id as usize].saturating_add(1); } let mut rows_by_symbol_id = row_counts_by_symbol_id .into_iter() .map(Vec::<&T>::with_capacity) .collect::>(); for (date, rows) in groups { let symbol_ids = symbol_ids_by_date .get(date) .expect("daily snapshot symbol ids must exist before series grouping"); if rows.len() != symbol_ids.len() { return Err(DataSetError::SnapshotSymbolIndexAlignment { kind, date: *date, row_count: rows.len(), symbol_id_count: symbol_ids.len(), }); } for (row, symbol_id) in rows.iter().zip(symbol_ids) { rows_by_symbol_id[*symbol_id as usize].push(row); } } Ok(rows_by_symbol_id) } fn build_factor_market_cap_order( factor_by_date: &BTreeMap>, factor_symbol_ids_by_date: &BTreeMap>, ) -> BTreeMap> { factor_by_date .par_iter() .map(|(date, rows)| { let symbol_ids = factor_symbol_ids_by_date .get(date) .expect("factor symbol ids missing for market-cap order"); assert_eq!( rows.len(), symbol_ids.len(), "factor rows and symbol ids diverged for {date}" ); let mut row_indices = rows .iter() .enumerate() .filter_map(|(index, row)| { let market_cap_bn = decision_market_cap_bn(row); (market_cap_bn.is_finite() && market_cap_bn > 0.0).then_some(index) }) .collect::>(); row_indices.sort_by(|left, right| { let left = &rows[*left]; let right = &rows[*right]; decision_market_cap_bn(left) .partial_cmp(&decision_market_cap_bn(right)) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| left.symbol.cmp(&right.symbol)) }); let ordered = row_indices .into_iter() .map(|index| symbol_ids[index]) .collect::>(); (*date, ordered) }) .collect::>() .into_iter() .collect() } fn build_dense_row_positions( groups: &BTreeMap>, symbol_ids_by_date: &BTreeMap>, symbol_count: usize, ) -> Option { let entries = groups.len().checked_mul(symbol_count)?; let bytes = entries.checked_mul(std::mem::size_of::())?; if bytes > MAX_DENSE_ROW_INDEX_BYTES { return None; } let mut positions_by_date = BTreeMap::new(); for (date, rows) in groups { let symbol_ids = symbol_ids_by_date.get(date)?; if rows.len() != symbol_ids.len() { return None; } let mut positions = vec![MISSING_ROW_POSITION; symbol_count]; for (row_index, symbol_id) in symbol_ids.iter().copied().enumerate() { let position = positions.get_mut(usize::try_from(symbol_id).ok()?)?; if *position != MISSING_ROW_POSITION { return None; } *position = u32::try_from(row_index).ok()?; } positions_by_date.insert(*date, positions); } Some(positions_by_date) } fn build_calendar_series_end_positions( series_by_symbol_id: &[Option>], calendar: &TradingCalendar, ) -> Option { let entries = series_by_symbol_id.len().checked_mul(calendar.len())?; let bytes = entries .checked_mul(2)? .checked_mul(std::mem::size_of::())?; if bytes > MAX_SERIES_END_POSITION_INDEX_BYTES || series_by_symbol_id.iter().flatten().any(|series| { series.dates.len() > u32::MAX as usize || calendar.len() > u32::MAX as usize }) { return None; } let calendar_days = calendar.days(); let positions_by_symbol = series_by_symbol_id .par_iter() .map(|series| { let series = series.as_deref()?; let mut decision = Vec::with_capacity(calendar_days.len()); let mut current = Vec::with_capacity(calendar_days.len()); let mut series_index = 0usize; for date in calendar_days { while series .dates .get(series_index) .is_some_and(|series_date| *series_date < *date) { series_index += 1; } decision.push(series_index as u32); let current_index = if series.dates.get(series_index) == Some(date) { series_index + 1 } else { series_index }; current.push(current_index as u32); } Some((decision, current)) }) .collect::>(); let positions_by_calendar = (0..calendar_days.len()) .into_par_iter() .map(|calendar_index| { let mut decision = Vec::with_capacity(series_by_symbol_id.len()); let mut current = Vec::with_capacity(series_by_symbol_id.len()); for positions in &positions_by_symbol { if let Some((symbol_decision, symbol_current)) = positions { decision.push(symbol_decision[calendar_index]); current.push(symbol_current[calendar_index]); } else { decision.push(MISSING_ROW_POSITION); current.push(MISSING_ROW_POSITION); } } (decision, current) }) .collect::>(); let (decision, current) = positions_by_calendar.into_iter().unzip(); Some(CalendarSeriesEndPositions { decision, current }) } fn dense_row_position( positions_by_date: &Option, date: NaiveDate, symbol_id: u32, ) -> Option { let position = positions_by_date .as_ref()? .get(&date)? .get(usize::try_from(symbol_id).ok()?) .copied()?; (position != MISSING_ROW_POSITION).then_some(position as usize) } fn find_by_symbol_id<'a, T>(rows: &'a [T], symbol_ids: &[u32], symbol_id: u32) -> Option<&'a T> { find_by_symbol_id_with_preferred_index(rows, symbol_ids, symbol_id, None) } fn find_by_symbol_id_with_preferred_index<'a, T>( rows: &'a [T], symbol_ids: &[u32], symbol_id: u32, preferred_index: Option, ) -> Option<&'a T> { if rows.len() != symbol_ids.len() { return None; } if let Some(index) = preferred_index && symbol_ids.get(index).copied() == Some(symbol_id) { return rows.get(index); } symbol_ids .binary_search(&symbol_id) .ok() .and_then(|index| rows.get(index)) } fn find_by_symbol<'a, T, F>(rows: &'a [T], symbol: &str, symbol_of: F) -> Option<&'a T> where F: Fn(&T) -> &str, { rows.binary_search_by(|row| symbol_of(row).cmp(symbol)) .ok() .map(|index| &rows[index]) } fn collect_benchmark_code<'a, I>(benchmarks: I) -> Result where I: IntoIterator, { let mut benchmark_code = None; for benchmark in benchmarks { match benchmark_code { None => benchmark_code = Some(benchmark.benchmark.as_str()), Some(code) if code == benchmark.benchmark => {} Some(_) => return Err(DataSetError::MultipleBenchmarks), } } benchmark_code .map(str::to_owned) .ok_or(DataSetError::MultipleBenchmarks) } fn prefix_sums(values: &[f64]) -> Vec { let mut prefix = Vec::with_capacity(values.len() + 1); prefix.push(0.0); for value in values { let next = prefix.last().copied().unwrap_or_default() + *value; prefix.push(next); } prefix } fn normalize_rolling_factor(value: f64, decimals: i32) -> f64 { let scale = 10_f64.powi(decimals); (value * scale).round() / scale } mod optional_date_format { use chrono::NaiveDate; use serde::{self, Deserialize, Deserializer, Serializer}; const FORMAT: &str = "%Y-%m-%d"; pub fn serialize(date: &Option, serializer: S) -> Result where S: Serializer, { match date { Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()), None => serializer.serialize_none(), } } pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let text = Option::::deserialize(deserializer)?; match text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { Some(text) => NaiveDate::parse_from_str(text, FORMAT) .map(Some) .map_err(serde::de::Error::custom), None => Ok(None), } } } fn build_futures_params_index( rows: Vec, ) -> HashMap> { let mut grouped = HashMap::>::new(); for row in rows { grouped.entry(row.symbol.clone()).or_default().push(row); } for rows in grouped.values_mut() { rows.sort_by_key(|row| row.effective_date); } grouped } fn build_execution_quote_index( execution_quotes: Vec, ) -> HashMap>> { let mut grouped = HashMap::>>::new(); for quote in execution_quotes { grouped .entry(quote.date) .or_default() .entry(quote.symbol.clone()) .or_default() .push(quote); } for rows_by_symbol in grouped.values_mut() { for quotes in rows_by_symbol.values_mut() { quotes.sort_by_key(|quote| quote.timestamp); } } grouped } fn build_order_book_depth_index( order_book_depth: Vec, ) -> HashMap<(NaiveDate, String), Vec> { let mut grouped = HashMap::<(NaiveDate, String), Vec>::new(); for level in order_book_depth { grouped .entry((level.date, level.symbol.clone())) .or_default() .push(level); } for levels in grouped.values_mut() { levels.sort_by(|left, right| { left.timestamp .cmp(&right.timestamp) .then(left.level.cmp(&right.level)) }); } grouped } fn build_eligible_universe( factor_by_date: &BTreeMap>, market_by_date: &BTreeMap>, ) -> BTreeMap> { let mut per_date = BTreeMap::>::new(); for date in factor_by_date.keys() { let rows = build_fundamental_universe_for_date(*date, factor_by_date, market_by_date); per_date.insert(*date, rows); } per_date } fn build_fundamental_universe_for_date( date: NaiveDate, factor_by_date: &BTreeMap>, market_by_date: &BTreeMap>, ) -> Vec { let mut rows = Vec::new(); let Some(factors) = factor_by_date.get(&date) else { return rows; }; for factor in factors { if market_by_date .get(&date) .and_then(|rows| find_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())) .is_none() { continue; } let market_cap_bn = decision_market_cap_bn(factor); if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { continue; } rows.push(EligibleUniverseSnapshot { symbol: factor.symbol.clone(), market_cap_bn, free_float_cap_bn: decision_free_float_cap_bn(factor), }); } rows.sort_by(|left, right| { left.market_cap_bn .partial_cmp(&right.market_cap_bn) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| left.symbol.cmp(&right.symbol)) }); rows } fn build_eligible_universe_for_date( date: NaiveDate, factor_by_date: &BTreeMap>, candidate_by_date: &BTreeMap>, market_by_date: &BTreeMap>, instruments: &HashMap, risk_config: &FidcRiskControlConfig, ) -> Vec { factor_by_date .get(&date) .map(|factors| { build_eligible_universe_for_date_from_factors( date, factors, candidate_by_date, market_by_date, instruments, risk_config, ) }) .unwrap_or_default() } fn build_eligible_universe_for_date_from_factors( date: NaiveDate, factors: &[DailyFactorSnapshot], candidate_by_date: &BTreeMap>, market_by_date: &BTreeMap>, instruments: &HashMap, risk_config: &FidcRiskControlConfig, ) -> Vec { let mut rows = Vec::new(); for factor in factors { if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { continue; } let synthetic_candidate; let candidate = if let Some(candidate) = candidate_by_date .get(&date) .and_then(|rows| find_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())) { candidate } else { synthetic_candidate = missing_candidate_risk_state(date, &factor.symbol); &synthetic_candidate }; let Some(market) = market_by_date .get(&date) .and_then(|rows| find_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())) else { continue; }; if ChinaAShareRiskControl::selection_rejection_reason_with_config( date, candidate, market, instruments.get(&factor.symbol), risk_config, ) .is_some() { continue; } let market_cap_bn = decision_market_cap_bn(factor); if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { continue; } let free_float_cap_bn = decision_free_float_cap_bn(factor); rows.push(EligibleUniverseSnapshot { symbol: factor.symbol.clone(), market_cap_bn, free_float_cap_bn, }); } rows.sort_by(|left, right| { left.market_cap_bn .partial_cmp(&right.market_cap_bn) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| left.symbol.cmp(&right.symbol)) }); rows } pub(crate) fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility { CandidateEligibility { date, symbol: symbol.to_string(), 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: Some( "missing_risk_state:is_st,is_star_st,is_paused,listed_days,is_kcb,is_one_yuan" .to_string(), ), } } #[cfg(test)] fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool { ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none() } #[cfg(test)] mod tests { use super::*; fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot { DailyMarketSnapshot { date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), symbol: "000001.SZ".to_string(), timestamp: None, day_open: prev_close, open: prev_close, high: prev_close, low: prev_close, close: prev_close, last_price: prev_close, bid1: prev_close, ask1: prev_close, prev_close, volume, minute_volume: 0, bid1_volume: 0, ask1_volume: 0, trading_phase: None, paused: false, upper_limit: prev_close * 1.1, lower_limit: prev_close * 0.9, price_tick: 0.01, } } fn benchmark_row(date: &str, close: f64) -> BenchmarkSnapshot { BenchmarkSnapshot { date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), benchmark: "000852.SH".to_string(), open: close, close, prev_close: close - 1.0, volume: 1_000_000, } } #[test] fn dataset_clone_shares_immutable_base_and_isolates_execution_quotes() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "平安银行".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }], vec![market_row("2025-01-02", 10.0, 1_000_000)], Vec::new(), Vec::new(), vec![benchmark_row("2025-01-02", 12.0)], ) .unwrap(); let mut run_data = data.clone(); assert!(Arc::ptr_eq(&data.instruments, &run_data.instruments)); assert!(Arc::ptr_eq(&data.market_by_date, &run_data.market_by_date)); assert!(Arc::ptr_eq( &data.market_row_positions_by_date, &run_data.market_row_positions_by_date )); assert!(Arc::ptr_eq(&data.factor_by_date, &run_data.factor_by_date)); assert!(Arc::ptr_eq( &data.factor_row_positions_by_date, &run_data.factor_row_positions_by_date )); assert!(Arc::ptr_eq( &data.candidate_by_date, &run_data.candidate_by_date )); assert!(Arc::ptr_eq( &data.candidate_row_positions_by_date, &run_data.candidate_row_positions_by_date )); assert!(Arc::ptr_eq( &data.benchmark_by_date, &run_data.benchmark_by_date )); assert!(Arc::ptr_eq( &data.execution_quotes_by_date, &run_data.execution_quotes_by_date )); assert!(Arc::ptr_eq( &data.execution_quote_dates, &run_data.execution_quote_dates )); run_data.add_execution_quotes(vec![IntradayExecutionQuote { date, timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S") .unwrap(), symbol: "000001.SZ".to_string(), last_price: 10.01, bid1: 10.0, ask1: 10.01, bid1_volume: 10_000, ask1_volume: 10_000, volume_delta: 10_000, amount_delta: 100_100.0, trading_phase: Some("continuous".to_string()), }]); assert_eq!(data.execution_quote_count(), 0); assert_eq!(run_data.execution_quote_count(), 1); assert!(!Arc::ptr_eq( &data.execution_quotes_by_date, &run_data.execution_quotes_by_date )); assert!(!Arc::ptr_eq( &data.execution_quote_dates, &run_data.execution_quote_dates )); } #[test] fn unique_dataset_applies_sparse_intraday_overlay_to_daily_and_symbol_views() { let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let mut data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "平安银行".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }], vec![market_row("2025-01-02", 10.0, 1_000_000)], Vec::new(), Vec::new(), vec![benchmark_row("2025-01-02", 12.0)], ) .unwrap(); let market_series_before = Arc::clone( data.market_series_by_symbol_id[data.symbol_id("000001.SZ").unwrap() as usize] .as_ref() .unwrap(), ); let daily_base_before = Arc::clone(&market_series_before.base); assert_eq!( data.apply_intraday_market_overlays(vec![IntradayMarketSnapshotOverlay { date, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-02 10:18:00".to_string()), last_price: Some(10.08), bid1: 10.07, ask1: 10.08, minute_volume: 12_300, bid1_volume: 4_500, ask1_volume: 3_200, trading_phase: Some("continuous".to_string()), }]) .unwrap(), 1 ); let market = data.market(date, "000001.SZ").unwrap(); assert_eq!(market.last_price, 10.08); assert_eq!(market.bid1, 10.07); assert_eq!(market.ask1, 10.08); assert_eq!(market.minute_volume, 12_300); assert_eq!(market.bid1_volume, 4_500); assert_eq!(market.ask1_volume, 3_200); assert_eq!(market.trading_phase.as_deref(), Some("continuous")); assert_eq!(market.close, 10.0); let market_series_after = data.market_series_by_symbol_id [data.symbol_id("000001.SZ").unwrap() as usize] .as_ref() .unwrap(); assert!(!Arc::ptr_eq( &market_series_before, market_series_after )); assert!(Arc::ptr_eq(&daily_base_before, &market_series_after.base)); assert_eq!( serde_json::to_value(market_series_after.snapshot_at(0)).unwrap(), serde_json::to_value(market).unwrap() ); assert_eq!( market_series_after.moving_average(date, 1, PriceField::Last), Some(10.08) ); } #[test] fn intraday_overlay_fails_closed_when_daily_panel_is_shared() { let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let mut data = DataSet::from_components( Vec::new(), vec![market_row("2025-01-02", 10.0, 1_000_000)], Vec::new(), Vec::new(), vec![benchmark_row("2025-01-02", 12.0)], ) .unwrap(); let shared = data.clone(); let error = data .apply_intraday_market_overlays(vec![IntradayMarketSnapshotOverlay { date, symbol: "000001.SZ".to_string(), timestamp: None, last_price: None, bid1: 0.0, ask1: 0.0, minute_volume: 0, bid1_volume: 0, ask1_volume: 0, trading_phase: None, }]) .unwrap_err(); assert!(matches!( error, DataSetError::SharedComponentMutation { component: "daily market panel" } )); assert_eq!(shared.market(date, "000001.SZ").unwrap().last_price, 10.0); } #[test] fn replacing_execution_quotes_preserves_duplicate_timestamp_rows() { let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let timestamp = date.and_hms_opt(10, 18, 0).unwrap(); let mut data = DataSet::from_components( Vec::new(), vec![market_row("2025-01-02", 10.0, 1_000_000)], Vec::new(), Vec::new(), vec![benchmark_row("2025-01-02", 12.0)], ) .unwrap(); let quote = IntradayExecutionQuote { date, symbol: "000001.SZ".to_string(), timestamp, last_price: 10.08, bid1: 10.07, ask1: 10.08, bid1_volume: 4_500, ask1_volume: 3_200, volume_delta: 12_300, amount_delta: 123_000.0, trading_phase: Some("continuous".to_string()), }; assert_eq!(data.replace_execution_quotes(vec![quote.clone(), quote]), 2); assert_eq!(data.execution_quotes_on(date, "000001.SZ").len(), 2); } #[test] fn daily_bundle_constructor_matches_flat_component_constructor() { let dates = [ NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), ]; let symbols = ["000001.SZ", "600000.SH"]; let instruments = symbols .iter() .map(|symbol| Instrument { symbol: (*symbol).to_string(), name: (*symbol).to_string(), board: symbol.rsplit_once('.').unwrap().1.to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }) .collect::>(); let mut market = Vec::new(); let mut factors = Vec::new(); let mut candidates = Vec::new(); let mut benchmarks = Vec::new(); let mut corporate_actions = Vec::new(); let mut execution_quotes = Vec::new(); let mut bundles = Vec::new(); for (date_index, date) in dates.into_iter().enumerate() { let date_text = date.format("%Y-%m-%d").to_string(); let mut day_market = Vec::new(); let mut day_factors = Vec::new(); let mut day_candidates = Vec::new(); for (symbol_index, symbol) in symbols.into_iter().enumerate().rev() { let close = 10.0 + date_index as f64 + symbol_index as f64; let mut market_row = market_row(&date_text, close, 1_000_000); market_row.symbol = symbol.to_string(); let factor_row = DailyFactorSnapshot { date, symbol: symbol.to_string(), market_cap_bn: 100.0 + close, free_float_cap_bn: 80.0 + close, pe_ttm: 0.0, turnover_ratio: Some(0.02), effective_turnover_ratio: Some(0.01), adjustment_factor_backward1: None, extra_factors: NumericFactorMap::from([(Cow::Borrowed("quality"), close)]), }; let candidate_row = CandidateEligibility { date, symbol: symbol.to_string(), 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, }; market.push(market_row.clone()); factors.push(factor_row.clone()); candidates.push(candidate_row.clone()); day_market.push(market_row); day_factors.push(factor_row); day_candidates.push(candidate_row); } let benchmark = benchmark_row(&date_text, 20.0 + date_index as f64); benchmarks.push(benchmark.clone()); let corporate_action = CorporateAction { date, symbol: symbols[0].to_string(), payable_date: Some(date), share_cash: 0.1, share_bonus: 0.02, share_gift: 0.03, issue_quantity: 0.0, issue_price: 0.0, reform: false, adjust_factor: Some(1.05), successor_symbol: None, successor_ratio: None, successor_cash: None, }; corporate_actions.push(corporate_action.clone()); execution_quotes.push(IntradayExecutionQuote { date, symbol: symbols[0].to_string(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(), last_price: 12.3 + date_index as f64, bid1: 12.2 + date_index as f64, ask1: 12.4 + date_index as f64, bid1_volume: 1000, ask1_volume: 1200, volume_delta: 500, amount_delta: 6150.0, trading_phase: Some("continuous_auction".to_string()), }); bundles.push(DailySnapshotBundle { date, benchmark, market: day_market, factors: day_factors, candidates: day_candidates, corporate_actions: vec![corporate_action], }); } let flat = DataSet::from_components_with_actions_and_quotes( instruments.clone(), market, factors, candidates, benchmarks, corporate_actions, execution_quotes.clone(), ) .expect("flat dataset"); let grouped = DataSet::from_daily_bundles_with_execution_quotes( instruments, bundles, execution_quotes, ) .expect("daily bundle dataset"); assert_eq!(flat.calendar().days(), grouped.calendar().days()); assert_eq!(flat.benchmark_code(), grouped.benchmark_code()); for date in dates { for symbol in symbols { assert_eq!( flat.market(date, symbol).map(|row| row.close), grouped.market(date, symbol).map(|row| row.close) ); assert_eq!( flat.factor(date, symbol).map(|row| row.market_cap_bn), grouped.factor(date, symbol).map(|row| row.market_cap_bn) ); assert_eq!( flat.candidate(date, symbol).map(|row| row.allow_buy), grouped.candidate(date, symbol).map(|row| row.allow_buy) ); } assert_eq!( flat.corporate_actions_on(date).len(), grouped.corporate_actions_on(date).len() ); assert_eq!( flat.corporate_actions_on(date)[0].adjust_factor, grouped.corporate_actions_on(date)[0].adjust_factor ); let flat_quote = flat.execution_quotes_on(date, symbols[0]); let grouped_quote = grouped.execution_quotes_on(date, symbols[0]); assert_eq!(flat_quote.len(), grouped_quote.len()); assert_eq!(flat_quote[0].timestamp, grouped_quote[0].timestamp); assert_eq!(flat_quote[0].last_price, grouped_quote[0].last_price); assert_eq!(flat_quote[0].volume_delta, grouped_quote[0].volume_delta); } } #[test] fn daily_bundle_constructor_rejects_duplicate_or_mismatched_dates() { let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let benchmark = benchmark_row("2025-01-02", 20.0); let empty_bundle = || DailySnapshotBundle { date, benchmark: benchmark.clone(), market: Vec::new(), factors: Vec::new(), candidates: Vec::new(), corporate_actions: Vec::new(), }; let duplicate = DataSet::from_daily_bundles_with_execution_quotes( Vec::new(), vec![empty_bundle(), empty_bundle()], Vec::new(), ); assert!(matches!( duplicate, Err(DataSetError::DuplicateDailyBundle { date: value }) if value == date )); let mut mismatched = empty_bundle(); mismatched.market.push(market_row("2025-01-03", 10.0, 1)); let mismatch = DataSet::from_daily_bundles_with_execution_quotes( Vec::new(), vec![mismatched], Vec::new(), ); assert!(matches!( mismatch, Err(DataSetError::InvalidDailyBundleComponentDate { kind: "market", bundle_date, row_date, .. }) if bundle_date == date && row_date == NaiveDate::from_ymd_opt(2025, 1, 3).unwrap() )); } #[test] fn direct_symbol_id_snapshot_lookups_preserve_alignment_for_sparse_rows() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let instrument = |symbol: &str| Instrument { symbol: symbol.to_string(), name: symbol.to_string(), board: symbol .rsplit_once('.') .map(|(_, value)| value) .unwrap_or("") .to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }; let market = |symbol: &str, close: f64| { let mut row = market_row("2025-01-02", close, 1_000_000); row.symbol = symbol.to_string(); row }; let factor = |symbol: &str, market_cap_bn: f64| DailyFactorSnapshot { date, symbol: symbol.to_string(), market_cap_bn, free_float_cap_bn: market_cap_bn, pe_ttm: 0.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: None, extra_factors: NumericFactorMap::new(), }; let candidate = |symbol: &str| CandidateEligibility { date, symbol: symbol.to_string(), 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, }; let data = DataSet::from_components( vec![ instrument("000001.SZ"), instrument("000300.SH"), instrument("600000.SH"), ], vec![ market("000001.SZ", 10.0), market("000300.SH", 20.0), market("600000.SH", 12.0), ], vec![factor("000001.SZ", 100.0), factor("600000.SH", 120.0)], vec![candidate("000001.SZ"), candidate("600000.SH")], vec![benchmark_row("2025-01-02", 20.0)], ) .unwrap(); let lexical_symbols = ["000001.SZ", "000300.SH", "600000.SH"]; let lexical_symbol_ids = lexical_symbols.map(|symbol| data.symbol_id(symbol).unwrap()); assert!( lexical_symbol_ids .windows(2) .all(|window| window[0] < window[1]), "symbol ids are the stable lexical tie-break key" ); 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.instrument_by_symbol_id(symbol_id) .map(|row| row.symbol.as_str()), Some(symbol) ); let first_shared = data.shared_symbol_by_id(symbol_id).expect("shared symbol"); let second_shared = data.shared_symbol_by_id(symbol_id).expect("shared symbol"); assert_eq!(first_shared.as_ref(), symbol); assert!(Arc::ptr_eq(&first_shared, &second_shared)); 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()), Some(symbol) ); assert_eq!( day.factor(symbol_id).map(|row| row.symbol.as_str()), Some(symbol) ); assert_eq!( data.candidate_by_symbol_id(date, symbol_id) .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!(day.factor(signal_id).is_none()); assert!(data.candidate_by_symbol_id(date, signal_id).is_none()); assert!(day.candidate(signal_id).is_none()); assert_eq!( data.instrument_by_symbol_id(signal_id) .map(|row| row.symbol.as_str()), Some("000300.SH") ); // `get_factor` must use the same symbol-id index as direct snapshot // lookups. Sparse factor rows must not accidentally select another // symbol's row or disappear when the date group contains gaps. let market_cap = data.get_factor("000001.SZ", date, date, "MARKET_CAP"); assert_eq!( market_cap .iter() .map(|row| (row.date, row.symbol.as_str(), row.value)) .collect::>(), vec![(date, "000001.SZ", 100.0)] ); assert!( data.get_factor("000300.SH", date, date, "market_cap") .is_empty() ); assert!( data.get_factor("999999.SZ", date, date, "market_cap") .is_empty() ); } #[test] #[ignore = "manual release-mode instrument lookup benchmark"] fn benchmark_instrument_symbol_id_lookup() { let rows = (0..6_000u32) .map(|symbol_id| { let symbol = format!("{:06}.SZ", symbol_id); let instrument = Instrument { symbol: symbol.clone(), name: symbol.clone(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }; (symbol, symbol_id, instrument) }) .collect::>(); let map = rows .iter() .map(|(symbol, _, instrument)| (symbol.clone(), instrument.clone())) .collect::>(); let mut dense = vec![None; rows.len()]; for (_, symbol_id, instrument) in &rows { dense[*symbol_id as usize] = Some(instrument.clone()); } let iterations = 1_000usize; let mut map_nanos = 0u128; let mut dense_nanos = 0u128; let mut map_checksum = 0u64; let mut dense_checksum = 0u64; for iteration in 0..iterations { if iteration % 2 == 0 { let started = std::time::Instant::now(); for (symbol, _, _) in &rows { map_checksum += map.get(symbol).unwrap().round_lot as u64; } map_nanos += started.elapsed().as_nanos(); let started = std::time::Instant::now(); for (_, symbol_id, _) in &rows { dense_checksum += dense[*symbol_id as usize].as_ref().unwrap().round_lot as u64; } dense_nanos += started.elapsed().as_nanos(); } else { let started = std::time::Instant::now(); for (_, symbol_id, _) in &rows { dense_checksum += dense[*symbol_id as usize].as_ref().unwrap().round_lot as u64; } dense_nanos += started.elapsed().as_nanos(); let started = std::time::Instant::now(); for (symbol, _, _) in &rows { map_checksum += map.get(symbol).unwrap().round_lot as u64; } map_nanos += started.elapsed().as_nanos(); } } assert_eq!(map_checksum, dense_checksum); let map_seconds = map_nanos as f64 / 1_000_000_000.0; let dense_seconds = dense_nanos as f64 / 1_000_000_000.0; eprintln!( "{}", serde_json::json!({ "schemaVersion": "fidc-instrument-symbol-id-lookup-benchmark/v1", "rows": rows.len(), "iterations": iterations, "mapSeconds": map_seconds, "denseSeconds": dense_seconds, "speedup": map_seconds / dense_seconds, "checksum": map_checksum, }) ); } #[test] #[ignore = "manual release-mode current rolling boundary benchmark"] fn benchmark_current_rolling_reuses_symbol_boundary() { let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); let dates = (0..160) .map(|offset| start + chrono::Duration::days(offset)) .collect::>(); let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "000001.SZ".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }], dates .iter() .enumerate() .map(|(index, date)| { market_row( &date.format("%Y-%m-%d").to_string(), 10.0 + index as f64 / 100.0, 100_000 + index as u64, ) }) .collect(), dates .iter() .map(|date| DailyFactorSnapshot { date: *date, symbol: "000001.SZ".to_string(), market_cap_bn: 10.0, free_float_cap_bn: 8.0, pe_ttm: 10.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: Some(1.0), extra_factors: NumericFactorMap::new(), }) .collect(), Vec::new(), dates .iter() .enumerate() .map(|(index, date)| { benchmark_row(&date.format("%Y-%m-%d").to_string(), 1_000.0 + index as f64) }) .collect(), ) .unwrap(); let date = *dates.last().unwrap(); let symbol = "000001.SZ"; let symbol_id = data.symbol_id(symbol).unwrap(); let requirements = [ ("close", 5usize), ("close", 10usize), ("close", 30usize), ("volume", 5usize), ("volume", 100usize), ]; let iterations = 100_000usize; let mut repeated_nanos = 0u128; let mut reused_nanos = 0u128; let mut repeated_checksum = 0.0; let mut reused_checksum = 0.0; for iteration in 0..iterations { if iteration % 2 == 0 { let started = std::time::Instant::now(); for (field, lookback) in requirements { repeated_checksum += data .market_current_numeric_moving_average_by_symbol_id( date, symbol_id, symbol, field, lookback, ) .unwrap(); } repeated_nanos += started.elapsed().as_nanos(); let started = std::time::Instant::now(); let series_end = data.market_current_series_end_index_by_symbol_id(date, symbol_id); for (field, lookback) in requirements { reused_checksum += data .market_current_numeric_moving_average_with_end_by_symbol_id( date, symbol_id, symbol, field, lookback, series_end, ) .unwrap(); } reused_nanos += started.elapsed().as_nanos(); } else { let started = std::time::Instant::now(); let series_end = data.market_current_series_end_index_by_symbol_id(date, symbol_id); for (field, lookback) in requirements { reused_checksum += data .market_current_numeric_moving_average_with_end_by_symbol_id( date, symbol_id, symbol, field, lookback, series_end, ) .unwrap(); } reused_nanos += started.elapsed().as_nanos(); let started = std::time::Instant::now(); for (field, lookback) in requirements { repeated_checksum += data .market_current_numeric_moving_average_by_symbol_id( date, symbol_id, symbol, field, lookback, ) .unwrap(); } repeated_nanos += started.elapsed().as_nanos(); } } assert!((repeated_checksum - reused_checksum).abs() < 1e-6); let repeated_seconds = repeated_nanos as f64 / 1_000_000_000.0; let reused_seconds = reused_nanos as f64 / 1_000_000_000.0; eprintln!( "{}", serde_json::json!({ "schemaVersion": "fidc-current-rolling-boundary-benchmark/v1", "iterations": iterations, "helperCallsPerIteration": requirements.len(), "repeatedLookupSeconds": repeated_seconds, "reusedBoundarySeconds": reused_seconds, "speedup": repeated_seconds / reused_seconds, "equal": true, }) ); } #[test] #[ignore = "manual release-mode calendar index reuse benchmark"] fn benchmark_series_boundary_reuses_calendar_index() { use std::hint::black_box; use std::time::Instant; let data = volume_contract_data(Some([1.0, 1.0, 1.0])); let symbol_id = data.symbol_id("000001.SZ").expect("symbol id"); let date = *data.calendar().days().last().expect("calendar date"); let calendar_index = data.calendar_index(date).expect("calendar index"); let iterations = 5_000_000usize; let mut date_lookup_nanos = 0u128; let mut reused_nanos = 0u128; let mut date_lookup_checksum = 0usize; let mut reused_checksum = 0usize; for sample in 0..6 { let measure_date_lookup = || { let started = Instant::now(); let mut checksum = 0usize; for _ in 0..iterations { checksum += black_box( data.market_series_end_index_by_symbol_id( black_box(date), black_box(symbol_id), true, ) .unwrap(), ); } (started.elapsed().as_nanos(), checksum) }; let measure_reused = || { let started = Instant::now(); let mut checksum = 0usize; for _ in 0..iterations { checksum += black_box( data.market_series_end_index_by_symbol_id_at_calendar_index( black_box(calendar_index), black_box(symbol_id), true, ) .unwrap(), ); } (started.elapsed().as_nanos(), checksum) }; let (first_nanos, first_checksum, second_nanos, second_checksum) = if sample % 2 == 0 { let (date_nanos, date_checksum) = measure_date_lookup(); let (reused_nanos, reused_checksum) = measure_reused(); (date_nanos, date_checksum, reused_nanos, reused_checksum) } else { let (reused_nanos, reused_checksum) = measure_reused(); let (date_nanos, date_checksum) = measure_date_lookup(); (date_nanos, date_checksum, reused_nanos, reused_checksum) }; date_lookup_nanos += first_nanos; date_lookup_checksum += first_checksum; reused_nanos += second_nanos; reused_checksum += second_checksum; } assert_eq!(date_lookup_checksum, reused_checksum); let date_lookup_seconds = date_lookup_nanos as f64 / 1_000_000_000.0; let reused_seconds = reused_nanos as f64 / 1_000_000_000.0; eprintln!( "{}", serde_json::json!({ "schemaVersion": "fidc-series-boundary-calendar-index-benchmark/v1", "samples": 6, "iterationsPerSample": iterations, "dateLookupSeconds": date_lookup_seconds, "reusedCalendarIndexSeconds": reused_seconds, "speedup": date_lookup_seconds / reused_seconds, "checksum": date_lookup_checksum, }) ); } #[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, adjustment_factor_backward1: 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] fn additional_terminal_calendar_dates_are_isolated_from_shared_market_data() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let next_date = NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(); let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "平安银行".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }], vec![market_row("2025-01-02", 10.0, 1_000_000)], Vec::new(), Vec::new(), vec![benchmark_row("2025-01-02", 12.0)], ) .unwrap(); let run_data = data .clone() .with_additional_trading_dates([next_date, next_date]); assert_eq!(data.next_trading_date(date, 1), None); assert_eq!(run_data.next_trading_date(date, 1), Some(next_date)); assert!(run_data.market(next_date, "000001.SZ").is_none()); assert!(Arc::ptr_eq(&data.market_by_date, &run_data.market_by_date)); } #[test] fn execution_quotes_use_stable_k_way_merge_and_release_by_date() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "平安银行".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: None, delisted_at: None, status: "active".to_string(), }], vec![market_row("2025-01-02", 10.0, 1_000_000)], Vec::new(), Vec::new(), vec![benchmark_row("2025-01-02", 12.0)], ) .unwrap(); let quote = |symbol: &str, time: &str| IntradayExecutionQuote { date, timestamp: NaiveDateTime::parse_from_str( &format!("2025-01-02 {time}"), "%Y-%m-%d %H:%M:%S", ) .unwrap(), symbol: symbol.to_string(), last_price: 10.0, bid1: 0.0, ask1: 0.0, bid1_volume: 0, ask1_volume: 0, volume_delta: 100, amount_delta: 1_000.0, trading_phase: Some("continuous".to_string()), }; let mut run_data = data.clone(); assert_eq!( run_data.add_execution_quotes(vec![ quote("000002.SZ", "09:31:00"), quote("000001.SZ", "09:31:00"), quote("000002.SZ", "09:30:00"), quote("000001.SZ", "09:30:00"), ]), 4 ); let mut conflicting = quote("000001.SZ", "09:31:00"); conflicting.last_price = 99.0; assert_eq!( run_data.add_execution_quotes(vec![ conflicting, quote("000001.SZ", "09:32:00"), quote("000001.SZ", "09:32:00"), ]), 1 ); let merged = run_data.execution_quotes_on_date(date); let keys = merged .iter() .map(|row| (row.timestamp.time().to_string(), row.symbol.clone())) .collect::>(); assert_eq!( keys, vec![ ("09:30:00".to_string(), "000001.SZ".to_string()), ("09:30:00".to_string(), "000002.SZ".to_string()), ("09:31:00".to_string(), "000001.SZ".to_string()), ("09:31:00".to_string(), "000002.SZ".to_string()), ("09:32:00".to_string(), "000001.SZ".to_string()), ] ); let streamed_keys = run_data .execution_quotes_iter_on_date_for_symbols(date, None) .map(|row| (row.timestamp.time().to_string(), row.symbol.clone())) .collect::>(); assert_eq!(streamed_keys, keys); assert_eq!(merged[2].last_price, 10.0); let allowed_symbols = BTreeSet::from(["000001.SZ".to_string()]); let filtered = run_data.execution_quotes_on_date_for_symbols(date, Some(&allowed_symbols)); assert_eq!( filtered .iter() .map(|row| (row.timestamp.time().to_string(), row.symbol.clone())) .collect::>(), vec![ ("09:30:00".to_string(), "000001.SZ".to_string()), ("09:31:00".to_string(), "000001.SZ".to_string()), ("09:32:00".to_string(), "000001.SZ".to_string()), ] ); assert_eq!(run_data.remove_execution_quotes_on_date(date), 5); assert_eq!(run_data.execution_quote_count(), 0); } #[test] fn shared_execution_quote_release_does_not_clone_the_base_map() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let quote = IntradayExecutionQuote { date, timestamp: NaiveDateTime::parse_from_str( "2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S", ) .unwrap(), symbol: "000001.SZ".to_string(), last_price: 10.0, bid1: 10.0, ask1: 10.0, bid1_volume: 10_000, ask1_volume: 10_000, volume_delta: 10_000, amount_delta: 100_000.0, trading_phase: Some("continuous".to_string()), }; let data = DataSet::from_components_with_actions_and_quotes( Vec::new(), vec![market_row("2025-01-02", 10.0, 1_000_000)], Vec::new(), Vec::new(), vec![benchmark_row("2025-01-02", 12.0)], Vec::new(), vec![quote], ) .unwrap(); let mut run_data = data.clone(); assert!(Arc::ptr_eq( &data.execution_quotes_by_date, &run_data.execution_quotes_by_date )); assert_eq!(run_data.release_execution_quotes_on_date(date), 1); assert!(Arc::ptr_eq( &data.execution_quotes_by_date, &run_data.execution_quotes_by_date )); assert_eq!(run_data.execution_quote_count(), 1); drop(data); assert_eq!(run_data.release_execution_quotes_on_date(date), 1); assert_eq!(run_data.execution_quote_count(), 0); } #[test] fn baseline_selection_uses_structured_instrument_dates_and_status_only() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let instrument = |name: &str, status: &str, delisted_at: Option| Instrument { symbol: "000001.SZ".to_string(), name: name.to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()), delisted_at, status: status.to_string(), }; assert!(instrument_passes_baseline_selection( Some(&instrument("Short History Stock", "active", None)), date )); assert!(instrument_passes_baseline_selection( Some(&instrument("*ST测试", "active", None)), date )); assert!(instrument_passes_baseline_selection( Some(&instrument("ST测试", "active", None)), date )); assert!(instrument_passes_baseline_selection( Some(&instrument("退市测试", "active", None)), date )); assert!(!instrument_passes_baseline_selection( Some(&instrument("正常名称", "delisted", None)), date )); assert!(instrument_passes_baseline_selection( Some(&instrument( "正常名称", "delisted", Some(NaiveDate::parse_from_str("2025-04-30", "%Y-%m-%d").unwrap()), )), date )); assert!(!instrument_passes_baseline_selection( Some(&instrument( "正常名称", "active", Some(NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap()), )), date )); } #[test] fn factor_numeric_value_normalizes_fields_without_changing_aliases() { let snapshot = DailyFactorSnapshot { date: NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(), symbol: "000001.SZ".to_string(), market_cap_bn: 12.5, free_float_cap_bn: 8.0, pe_ttm: 10.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: Some(1.25), extra_factors: BTreeMap::from([("custom_factor".into(), 3.5)]), }; assert_eq!(factor_numeric_value(&snapshot, " MARKET_CAP "), Some(12.5)); assert_eq!(factor_numeric_value(&snapshot, "CUSTOM_FACTOR"), Some(3.5)); assert_eq!( factor_numeric_value(&snapshot, "ADJUSTMENT_FACTOR_BACKWARD1"), Some(1.25) ); } #[test] fn factor_snapshot_normalization_moves_clean_maps_and_repairs_dirty_maps() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let clean = normalize_factor_snapshots(vec![DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 1.0, free_float_cap_bn: 1.0, pe_ttm: 1.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: None, extra_factors: BTreeMap::from([(Cow::Borrowed("amount"), 10.0)]), }]) .expect("normalize clean factor snapshot"); assert!(matches!( clean[0].extra_factors.keys().next(), Some(Cow::Borrowed("amount")) )); let dirty = normalize_factor_snapshots(vec![DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 1.0, free_float_cap_bn: 1.0, pe_ttm: 1.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: None, extra_factors: BTreeMap::from([ (Cow::Owned(" CUSTOM_FACTOR ".to_string()), 2.0), (Cow::Borrowed("bad_nan"), f64::NAN), ]), }]) .expect("normalize dirty factor snapshot"); assert_eq!(dirty[0].extra_factors.get("custom_factor"), Some(&2.0)); assert!(!dirty[0].extra_factors.contains_key("bad_nan")); } #[test] fn factor_snapshot_rejects_legacy_or_invalid_adjustment_storage() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let snapshot = |adjustment_factor_backward1, extra_factors| DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 1.0, free_float_cap_bn: 1.0, pe_ttm: 1.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1, extra_factors, }; assert!(matches!( normalize_factor_snapshots(vec![snapshot( Some(1.0), BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]), )]), Err(DataSetError::ReservedTypedFactorInExtraMap { .. }) )); assert!(matches!( normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new())]), Err(DataSetError::InvalidBackwardAdjustmentFactor { .. }) )); for field in [ " ADJUSTMENT_FACTOR_BACKWARD1 ", "\"adjustment_factor_backward1\"", "'adjustment_factor_backward1'", ] { for typed_value in [None, Some(1.0)] { assert!(matches!( normalize_factor_snapshots(vec![snapshot( typed_value, BTreeMap::from([(Cow::Borrowed(field), 2.0)]), )]), Err(DataSetError::ReservedTypedFactorInExtraMap { .. }) ), "reserved alias accepted: {field}"); } } } #[test] fn symbol_price_series_test_constructor_sorts_unsorted_rows() { let series = SymbolPriceSeries::new( "000001.SZ".to_string(), &[ market_row("2025-01-06", 12.0, 300), market_row("2025-01-02", 10.0, 100), market_row("2025-01-03", 11.0, 200), ], ); assert!(series.dates.windows(2).all(|window| window[0] < window[1])); assert_eq!(series.closes, vec![10.0, 11.0, 12.0]); } #[test] fn decision_volume_average_uses_previous_completed_days_only() { let series = SymbolPriceSeries::new( "000001.SZ".to_string(), &[ market_row("2025-01-02", 10.0, 100), market_row("2025-01-03", 11.0, 200), market_row("2025-01-06", 12.0, 10_000), ], ); assert_eq!( series.decision_close_moving_average( NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), 2 ), Some(11.5) ); assert_eq!( series.decision_volume_moving_average( NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), 2 ), Some(150.0) ); assert_eq!( series.decision_volume_moving_average( NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), 3 ), None ); } fn volume_contract_data(availability: Option<[f64; 3]>) -> DataSet { let dates = [ NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), ]; let volumes = [100_u64, 0, 300]; DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "000001.SZ".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(dates[0]), delisted_at: None, status: "active".to_string(), }], dates .iter() .zip(volumes) .map(|(date, volume)| { market_row(&date.format("%Y-%m-%d").to_string(), 10.0, volume) }) .collect(), dates .iter() .enumerate() .map(|(index, date)| { let mut extra_factors = BTreeMap::new(); if let Some(values) = availability { extra_factors.insert("source_daily_volume_available".into(), values[index]); if values[index] >= 0.5 { extra_factors.insert("daily_volume".into(), volumes[index] as f64); } } DailyFactorSnapshot { date: *date, symbol: "000001.SZ".to_string(), market_cap_bn: 10.0, free_float_cap_bn: 8.0, pe_ttm: 10.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: None, extra_factors, } }) .collect(), Vec::new(), dates .iter() .map(|date| BenchmarkSnapshot { date: *date, benchmark: "000852.SH".to_string(), open: 100.0, close: 100.0, prev_close: 100.0, volume: 1_000_000, }) .collect(), ) .expect("volume contract dataset") } #[test] fn batched_standard_rolling_means_match_scalar_lookups() { let dates = [ NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), ]; let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "000001.SZ".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(dates[0]), delisted_at: None, status: "active".to_string(), }], dates .iter() .enumerate() .map(|(index, date)| { market_row( &date.format("%Y-%m-%d").to_string(), 10.0 + index as f64, 100 + index as u64, ) }) .collect(), dates .iter() .map(|date| DailyFactorSnapshot { date: *date, symbol: "000001.SZ".to_string(), market_cap_bn: 10.0, free_float_cap_bn: 8.0, pe_ttm: 10.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: Some(1.0), extra_factors: BTreeMap::new(), }) .collect(), Vec::new(), dates .iter() .map(|date| BenchmarkSnapshot { date: *date, benchmark: "000852.SH".to_string(), open: 100.0, close: 100.0, prev_close: 100.0, volume: 1_000_000, }) .collect(), ) .expect("standard rolling dataset"); let date = dates[2]; let symbol_id = data.symbol_id("000001.SZ").unwrap(); let close_lookbacks = [1, 2, 3, 1, 2, 3, 0]; let volume_lookbacks = [1, 2, 3, 0, 2]; let calendar_index = data.calendar_index(date); let batched = data.market_standard_rolling_means_by_symbol_id_with_calendar_index( date, calendar_index, symbol_id, &close_lookbacks, &volume_lookbacks, false, ); for (index, lookback) in close_lookbacks.iter().copied().enumerate() { assert_eq!( batched.close[index], data.market_decision_numeric_moving_average_by_symbol_id( date, symbol_id, "000001.SZ", "close", lookback, ) ); } for (index, lookback) in volume_lookbacks.iter().copied().enumerate() { assert_eq!( batched.volume[index], data.market_decision_numeric_moving_average_by_symbol_id( date, symbol_id, "000001.SZ", "volume", lookback, ) ); } let current = data.market_standard_rolling_means_by_symbol_id_with_calendar_index( date, calendar_index, symbol_id, &close_lookbacks, &volume_lookbacks, true, ); assert_eq!( current.close[1], data.market_current_numeric_moving_average_by_symbol_id( date, symbol_id, "000001.SZ", "close", 2, ) ); assert_eq!( current.volume[1], data.market_current_numeric_moving_average_by_symbol_id( date, symbol_id, "000001.SZ", "volume", 2, ) ); } #[test] fn series_end_position_index_preserves_decision_and_current_boundaries() { let data = volume_contract_data(Some([1.0, 1.0, 1.0])); let symbol_id = data.symbol_id("000001.SZ").expect("symbol id"); let dates = data.calendar().days(); assert!( data.market_series_end_positions_by_calendar_index .as_ref() .is_some() ); assert_eq!( data.market_series_end_index_by_symbol_id(dates[0], symbol_id, false), Some(0) ); assert_eq!( data.market_series_end_index_by_symbol_id(dates[0], symbol_id, true), Some(1) ); assert_eq!( data.market_series_end_index_by_symbol_id(dates[2], symbol_id, false), Some(2) ); assert_eq!( data.market_series_end_index_by_symbol_id(dates[2], symbol_id, true), Some(3) ); let calendar_index = data.calendar_index(dates[2]).expect("calendar index"); assert_eq!( data.market_series_end_index_by_symbol_id_at_calendar_index( calendar_index, symbol_id, false, ), Some(2) ); assert_eq!( data.market_current_series_end_index_by_symbol_id_at_calendar_index( calendar_index, symbol_id, ), Some(3) ); let extended = data .clone() .with_additional_trading_dates([NaiveDate::from_ymd_opt(2025, 1, 7).unwrap()]); assert_eq!( extended.market_series_end_index_by_symbol_id( NaiveDate::from_ymd_opt(2025, 1, 7).unwrap(), symbol_id, false, ), Some(3) ); assert_eq!( extended.market_series_end_index_by_symbol_id( NaiveDate::from_ymd_opt(2025, 1, 7).unwrap(), symbol_id, true, ), Some(3) ); } #[test] fn source_volume_contract_rejects_windows_containing_missing_values() { let data = volume_contract_data(Some([1.0, 0.0, 1.0])); let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); assert_eq!( data.market_current_numeric_moving_average(date, "000001.SZ", "volume", 3), None ); assert!( data.market_current_numeric_values(date, "000001.SZ", "volume", 3) .is_empty() ); assert_eq!( data.market_decision_numeric_moving_average(date, "000001.SZ", "volume", 2), None ); assert!( data.market_decision_numeric_values(date, "000001.SZ", "volume", 2) .is_empty() ); } #[test] fn volume_rolling_ignores_zero_volume_rows_for_source_and_legacy_data() { let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); for data in [ volume_contract_data(Some([1.0, 1.0, 1.0])), volume_contract_data(None), ] { let symbol_id = data.symbol_id("000001.SZ").expect("symbol id"); assert!(std::ptr::eq( data.market_by_symbol_id(date, symbol_id) .expect("market by id"), data.market(date, "000001.SZ").expect("market by code"), )); assert!(std::ptr::eq( data.factor_by_symbol_id(date, symbol_id) .expect("factor by id"), data.factor(date, "000001.SZ").expect("factor by code"), )); assert_eq!( data.market_current_numeric_moving_average(date, "000001.SZ", "volume", 2), Some(200.0) ); assert_eq!( data.market_current_numeric_moving_average_by_symbol_id( date, symbol_id, "000001.SZ", "volume", 2, ), Some(200.0) ); assert_eq!( data.market_current_numeric_values(date, "000001.SZ", "volume", 2), vec![100.0, 300.0] ); assert_eq!( data.market_current_numeric_moving_average(date, "000001.SZ", "volume", 3), None ); assert_eq!( data.market_decision_numeric_moving_average(date, "000001.SZ", "volume", 1), Some(100.0) ); assert_eq!( data.market_decision_numeric_moving_average_by_symbol_id( date, symbol_id, "000001.SZ", "volume", 1, ), Some(100.0) ); assert_eq!( data.market_decision_numeric_values(date, "000001.SZ", "volume", 1), vec![100.0] ); } } #[test] fn decision_close_average_ignores_current_day_close() { let mut current = market_row("2025-01-06", 12.0, 10_000); current.close = 9_999.0; current.last_price = 9_999.0; let series = SymbolPriceSeries::new( "000001.SZ".to_string(), &[ market_row("2025-01-02", 10.0, 100), market_row("2025-01-03", 11.0, 200), current, ], ); let decision_date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); assert_eq!( series.decision_close_moving_average(decision_date, 2), Some(11.5) ); assert_eq!( series.moving_average(decision_date, 2, PriceField::Close), Some((11.0 + 9_999.0) / 2.0) ); } #[test] fn current_close_average_uses_backward_adjustment_factor_and_current_base() { let dates = [ NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), ]; let factors = [1.0, 1.0, 2.0]; let closes = [10.0, 11.0, 6.0]; let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "000001.SZ".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(dates[0]), delisted_at: None, status: "active".to_string(), }], dates .iter() .zip(closes) .map(|(date, close)| market_row(&date.format("%Y-%m-%d").to_string(), close, 100)) .collect(), dates .iter() .zip(factors) .map(|(date, factor)| DailyFactorSnapshot { date: *date, symbol: "000001.SZ".to_string(), market_cap_bn: 10.0, free_float_cap_bn: 8.0, pe_ttm: 10.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: Some(factor), extra_factors: BTreeMap::new(), }) .collect(), Vec::new(), dates .iter() .map(|date| BenchmarkSnapshot { date: *date, benchmark: "000852.SH".to_string(), open: 100.0, close: 100.0, prev_close: 100.0, volume: 1_000_000, }) .collect(), ) .expect("dataset"); assert_eq!( data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3), Some(5.5) ); assert_eq!( data.market_decision_numeric_moving_average(dates[2], "000001.SZ", "close", 2), Some(10.5) ); assert_ne!( data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3), data.market_moving_average(dates[2], "000001.SZ", 3, PriceField::Close) ); } #[test] fn adjusted_close_average_normalization_prevents_strict_crossover_drift() { let pattern = [ 2.953, 1.093, 2.717, 1.579, 1.289, 1.236, 1.617, 2.632, 1.361, 2.163, ]; let start = NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap(); let values = (0..30) .map(|index| pattern[index % pattern.len()]) .collect::>(); let series = AdjustedCloseSeries { dates: (0..30) .map(|index| start + chrono::Duration::days(index as i64)) .collect(), backward_factors: vec![Some(1.0); 30], back_adjusted_closes: values.iter().copied().map(Some).collect(), back_adjusted_close_prefix: prefix_sums(&values), missing_back_adjusted_close_prefix: vec![0; 31], }; let date = *series.dates.last().expect("last date"); assert_eq!( series.current_moving_average(date, 10), series.current_moving_average(date, 30) ); } #[test] fn future_missing_adjustment_factor_does_not_invalidate_historical_window() { let dates = [ NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(), ]; let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "000001.SZ".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(dates[0]), delisted_at: None, status: "active".to_string(), }], dates .iter() .enumerate() .map(|(index, date)| { market_row( &date.format("%Y-%m-%d").to_string(), 10.0 + index as f64, 100, ) }) .collect(), dates .iter() .map(|date| DailyFactorSnapshot { date: *date, symbol: "000001.SZ".to_string(), market_cap_bn: 10.0, free_float_cap_bn: 8.0, pe_ttm: 10.0, turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: (*date != dates[3]).then_some(1.0), extra_factors: BTreeMap::new(), }) .collect(), Vec::new(), dates .iter() .map(|date| BenchmarkSnapshot { date: *date, benchmark: "000852.SH".to_string(), open: 100.0, close: 100.0, prev_close: 100.0, volume: 1_000_000, }) .collect(), ) .expect("dataset"); assert_eq!( data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3), Some(11.0) ); assert_eq!( data.market_current_numeric_moving_average(dates[3], "000001.SZ", "close", 3), None ); } #[test] fn decision_volume_average_ignores_paused_zero_volume_days() { let mut paused = market_row("2025-01-03", 11.0, 0); paused.paused = true; let series = SymbolPriceSeries::new( "000001.SZ".to_string(), &[ market_row("2025-01-02", 10.0, 100), paused, market_row("2025-01-06", 12.0, 300), market_row("2025-01-07", 13.0, 10_000), ], ); assert_eq!( series.decision_volume_moving_average( NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(), 2 ), Some(200.0) ); assert_eq!( series.decision_volume_moving_average( NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(), 3 ), None ); } #[test] fn eligible_universe_uses_decision_market_cap_same_date() { let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); let instrument = |symbol: &str| Instrument { symbol: symbol.to_string(), name: symbol.to_string(), board: if symbol.ends_with(".SH") { "SH" } else { "SZ" }.to_string(), round_lot: 100, listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()), delisted_at: None, status: "active".to_string(), }; let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot { date, symbol: symbol.to_string(), timestamp: Some("2025-01-06 10:18:00".to_string()), day_open: prev_close, open: prev_close, high: close.max(prev_close), low: close.min(prev_close), close, last_price: prev_close, bid1: prev_close, ask1: prev_close, prev_close, volume: 100_000, minute_volume: 1_000, bid1_volume: 1_000, ask1_volume: 1_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: prev_close * 1.1, lower_limit: prev_close * 0.9, price_tick: 0.01, }; let factor = |symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot { date, symbol: symbol.to_string(), market_cap_bn, free_float_cap_bn, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), adjustment_factor_backward1: None, extra_factors: BTreeMap::new(), }; let candidate = |symbol: &str| CandidateEligibility { date, symbol: symbol.to_string(), 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, }; let data = DataSet::from_components( vec![instrument("000001.SZ"), instrument("000002.SZ")], vec![ market("000001.SZ", 10.0, 20.0), market("000002.SZ", 10.0, 10.0), ], vec![ factor("000001.SZ", 12.0, 4.0), factor("000002.SZ", 10.0, 5.0), ], vec![candidate("000001.SZ"), candidate("000002.SZ")], vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 100.0, close: 101.0, prev_close: 99.0, volume: 1_000_000, }], ) .expect("dataset"); let rows = data.eligible_universe_on(date); assert_eq!(rows.len(), 2); assert_eq!(rows[0].symbol, "000002.SZ"); assert!((rows[0].market_cap_bn - 10.0).abs() < 1e-9); assert_eq!(rows[1].symbol, "000001.SZ"); assert!((rows[1].market_cap_bn - 12.0).abs() < 1e-9); assert!((rows[1].free_float_cap_bn - 4.0).abs() < 1e-9); assert_eq!( data.factor_symbol_ids_by_market_cap_on(date), &[ data.symbol_id("000002.SZ").unwrap(), data.symbol_id("000001.SZ").unwrap(), ] ); } #[test] fn eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled() { let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); let symbol = "000001.SZ"; let data = DataSet::from_components( vec![Instrument { symbol: symbol.to_string(), name: symbol.to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()), delisted_at: None, status: "active".to_string(), }], vec![DailyMarketSnapshot { date, symbol: symbol.to_string(), timestamp: Some("2025-01-06 10:18:00".to_string()), day_open: 10.0, open: 10.0, high: 10.2, low: 9.8, close: 10.1, last_price: 10.1, bid1: 10.0, ask1: 10.1, prev_close: 10.0, volume: 100_000, minute_volume: 1_000, bid1_volume: 1_000, ask1_volume: 1_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }], vec![DailyFactorSnapshot { date, symbol: symbol.to_string(), market_cap_bn: 10.0, free_float_cap_bn: 9.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), adjustment_factor_backward1: None, extra_factors: BTreeMap::new(), }], Vec::new(), vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 100.0, close: 101.0, prev_close: 99.0, volume: 1_000_000, }], ) .expect("dataset"); assert_eq!( data.eligible_universe_on(date) .iter() .map(|row| row.symbol.as_str()) .collect::>(), vec![symbol] ); assert_eq!( data.eligible_universe_on_with_risk_config(date, &FidcRiskControlConfig::default()) .iter() .map(|row| row.symbol.as_str()) .collect::>(), vec![symbol], "execution-risk defaults must not make selection depend on candidate risk facts" ); let mut selection_risk_config = FidcRiskControlConfig::default(); selection_risk_config.static_rules.reject_st_selection = true; assert!( data.eligible_universe_on_with_risk_config(date, &selection_risk_config) .is_empty(), "explicit selection risk must reject when required candidate facts are missing" ); } #[test] fn eligible_universe_can_use_configured_risk_policy() { let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); let symbol = "688001.SH"; let data = DataSet::from_components( vec![Instrument { symbol: symbol.to_string(), name: symbol.to_string(), board: "SH".to_string(), round_lot: 100, listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()), delisted_at: None, status: "active".to_string(), }], vec![DailyMarketSnapshot { date, symbol: symbol.to_string(), timestamp: Some("2025-01-06 10:18:00".to_string()), day_open: 10.0, open: 10.0, high: 10.2, low: 9.8, close: 10.1, last_price: 10.1, bid1: 10.0, ask1: 10.1, prev_close: 10.0, volume: 100_000, minute_volume: 1_000, bid1_volume: 1_000, ask1_volume: 1_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }], vec![DailyFactorSnapshot { date, symbol: symbol.to_string(), market_cap_bn: 10.0, free_float_cap_bn: 9.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), adjustment_factor_backward1: None, extra_factors: BTreeMap::new(), }], vec![CandidateEligibility { date, symbol: symbol.to_string(), is_st: false, is_star_st: false, is_new_listing: false, is_paused: false, allow_buy: true, allow_sell: true, is_kcb: true, is_one_yuan: false, risk_level_code: None, }], vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 100.0, close: 101.0, prev_close: 99.0, volume: 1_000_000, }], ) .expect("dataset"); assert_eq!(data.eligible_universe_on(date).len(), 1); let mut risk_config = FidcRiskControlConfig::default(); risk_config.static_rules.reject_kcb_selection = true; assert!( data.eligible_universe_on_with_risk_config(date, &risk_config) .is_empty() ); risk_config.static_rules.reject_kcb_selection = false; let rows = data.eligible_universe_on_with_risk_config(date, &risk_config); assert_eq!(rows.len(), 1); assert_eq!(rows[0].symbol, symbol); } #[test] fn decision_market_cap_uses_factor_date_snapshot_without_price_reconstruction() { let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); let factor = DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 12.0, free_float_cap_bn: 4.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), adjustment_factor_backward1: None, extra_factors: BTreeMap::new(), }; assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9); assert!((decision_free_float_cap_bn(&factor) - 4.0).abs() < 1e-9); } #[test] fn benchmark_decision_close_windows_exclude_current_close() { let rows = [ benchmark_row("2025-01-02", 100.0), benchmark_row("2025-01-03", 200.0), benchmark_row("2025-01-06", 9_999.0), ]; let series = BenchmarkPriceSeries::from_sorted(rows.iter()); let decision_date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); assert_eq!(series.decision_close(decision_date), Some(9_998.0)); assert_eq!( series.decision_moving_average(decision_date, 2), Some(150.0) ); assert_eq!( series.decision_values_for(decision_date, 2, PriceField::Close), vec![100.0, 200.0] ); assert_eq!( series.moving_average(decision_date, 2), Some((200.0 + 9_999.0) / 2.0) ); } }