Files
fidc-backtest-engine/crates/fidc-core/src/data.rs
T

7294 lines
252 KiB
Rust

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<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&date.format(FORMAT).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
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<S>(date: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&date.format(FORMAT).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDateTime, D::Error>
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<String>,
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<String>,
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<Cow<'static, str>, 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<f64>,
pub effective_turnover_ratio: Option<f64>,
#[serde(default)]
pub adjustment_factor_backward1: Option<f64>,
#[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<String>,
}
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<NaiveDate>,
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<f64>,
#[serde(default)]
pub successor_symbol: Option<String>,
#[serde(default)]
pub successor_ratio: Option<f64>,
#[serde(default)]
pub successor_cash: Option<f64>,
}
#[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<String>,
}
/// 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<String>,
pub last_price: Option<f64>,
pub bid1: f64,
pub ask1: f64,
pub minute_volume: u64,
pub bid1_volume: u64,
pub ask1_volume: u64,
pub trading_phase: Option<String>,
}
#[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<f64> {
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<f64> {
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<f64> {
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<Reverse<(NaiveDateTime, usize, usize)>>,
}
impl<'a> ExecutionQuoteIterator<'a> {
fn new(
rows_by_symbol: Option<&'a HashMap<String, Vec<IntradayExecutionQuote>>>,
symbols: Option<&BTreeSet<String>>,
) -> 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::<Vec<_>>();
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<Self::Item> {
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<DailyMarketSnapshot>,
pub factors: Vec<DailyFactorSnapshot>,
pub candidates: Vec<CandidateEligibility>,
pub corporate_actions: Vec<CorporateAction>,
}
struct GroupedSnapshotComponents {
market_by_date: BTreeMap<NaiveDate, Vec<DailyMarketSnapshot>>,
factor_by_date: BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
candidate_by_date: BTreeMap<NaiveDate, Vec<CandidateEligibility>>,
benchmark_by_date: BTreeMap<NaiveDate, BenchmarkSnapshot>,
corporate_actions_by_date: BTreeMap<NaiveDate, Vec<CorporateAction>>,
}
#[derive(Debug, Clone)]
pub struct DataSetSnapshotComponents {
pub instruments: Vec<Instrument>,
pub market: Vec<DailyMarketSnapshot>,
pub factors: Vec<DailyFactorSnapshot>,
pub candidates: Vec<CandidateEligibility>,
pub benchmarks: Vec<BenchmarkSnapshot>,
pub corporate_actions: Vec<CorporateAction>,
pub execution_quotes: Vec<IntradayExecutionQuote>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PriceBar {
#[serde(with = "date_format")]
pub date: NaiveDate,
pub timestamp: Option<String>,
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<SymbolDailySeriesBase>,
timestamps: Vec<Option<String>>,
last_prices: Vec<f64>,
bid1s: Vec<f64>,
ask1s: Vec<f64>,
minute_volumes: Vec<u64>,
bid1_volumes: Vec<u64>,
ask1_volumes: Vec<u64>,
trading_phases: Vec<Option<String>>,
last_prefix: Vec<f64>,
}
#[derive(Debug)]
struct SymbolDailySeriesBase {
symbol: String,
dates: Vec<NaiveDate>,
day_opens: Vec<f64>,
opens: Vec<f64>,
highs: Vec<f64>,
lows: Vec<f64>,
closes: Vec<f64>,
prev_closes: Vec<f64>,
volumes: Vec<u64>,
paused: Vec<bool>,
upper_limits: Vec<f64>,
lower_limits: Vec<f64>,
price_ticks: Vec<f64>,
open_prefix: Vec<f64>,
close_prefix: Vec<f64>,
prev_close_prefix: Vec<f64>,
valid_volume_sum_prefix: Vec<f64>,
valid_volume_count_prefix: Vec<usize>,
valid_volume_start_by_count: Vec<usize>,
}
impl std::ops::Deref for SymbolPriceSeries {
type Target = SymbolDailySeriesBase;
fn deref(&self) -> &Self::Target {
&self.base
}
}
type DenseRowPositionIndex = BTreeMap<NaiveDate, Vec<u32>>;
const MISSING_ROW_POSITION: u32 = u32::MAX;
const MAX_DENSE_ROW_INDEX_BYTES: usize = 256 * 1024 * 1024;
#[derive(Debug, Clone)]
struct CalendarSeriesEndPositions {
decision: Vec<Vec<u32>>,
current: Vec<Vec<u32>>,
}
const MAX_SERIES_END_POSITION_INDEX_BYTES: usize = 256 * 1024 * 1024;
#[derive(Debug, Clone)]
struct AdjustedCloseSeries {
dates: Vec<NaiveDate>,
backward_factors: Vec<Option<f64>>,
back_adjusted_closes: Vec<Option<f64>>,
back_adjusted_close_prefix: Vec<f64>,
missing_back_adjusted_close_prefix: Vec<u32>,
}
impl AdjustedCloseSeries {
fn new(market: &SymbolPriceSeries, factor_rows: &[&DailyFactorSnapshot]) -> Option<Self> {
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<f64> {
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<f64> {
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<const N: usize>(
&self,
date: NaiveDate,
lookbacks: &[usize; N],
include_now: bool,
) -> [Option<f64>; 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<const N: usize>(
&self,
end: usize,
lookbacks: &[usize; N],
) -> [Option<f64>; 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<f64> {
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<f64> {
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::<Option<Vec<_>>>()
.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<f64> {
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<Item = &'a DailyMarketSnapshot>,
{
let mut sorted = rows.into_iter().collect::<Vec<_>>();
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<f64> {
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<f64> {
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<f64> {
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<DailyMarketSnapshot> {
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<f64> {
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<f64> {
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<usize> {
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<usize> {
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<f64> {
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<f64> {
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<f64> {
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<const N: usize>(
&self,
date: NaiveDate,
lookbacks: &[usize; N],
include_now: bool,
) -> [Option<f64>; 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<const N: usize>(
&self,
end: usize,
lookbacks: &[usize; N],
) -> [Option<f64>; 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<Vec<f64>> {
let end = self.previous_completed_end_index(date)?;
self.valid_volume_values(end, lookback)
}
fn current_volume_values(&self, date: NaiveDate, lookback: usize) -> Option<Vec<f64>> {
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<Vec<f64>> {
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::<Vec<_>>();
(values.len() == lookback).then_some(values)
}
fn end_index(&self, date: NaiveDate) -> Option<usize> {
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<usize> {
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<f64> {
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<f64> {
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<NaiveDate>,
opens: Vec<f64>,
closes: Vec<f64>,
prev_closes: Vec<f64>,
open_prefix: Vec<f64>,
close_prefix: Vec<f64>,
}
impl BenchmarkPriceSeries {
fn from_sorted<'a, I>(rows: I) -> Self
where
I: IntoIterator<Item = &'a BenchmarkSnapshot>,
{
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<f64> {
self.moving_average_for(date, lookback, PriceField::Close)
}
fn decision_close(&self, date: NaiveDate) -> Option<f64> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
self.trailing_values_for(date, lookback, PriceField::Close)
}
fn trailing_values_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Vec<f64> {
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<HashMap<String, Instrument>>,
instruments_by_symbol_id: Arc<Vec<Option<Instrument>>>,
calendar: Arc<TradingCalendar>,
market_by_date: Arc<BTreeMap<NaiveDate, Vec<DailyMarketSnapshot>>>,
market_symbol_ids_by_date: Arc<BTreeMap<NaiveDate, Vec<u32>>>,
market_row_positions_by_date: Arc<Option<DenseRowPositionIndex>>,
factor_by_date: Arc<BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>>,
factor_symbol_ids_by_date: Arc<BTreeMap<NaiveDate, Vec<u32>>>,
factor_row_positions_by_date: Arc<Option<DenseRowPositionIndex>>,
factor_market_cap_order_by_date: Arc<BTreeMap<NaiveDate, Vec<u32>>>,
factor_text_by_date: Arc<BTreeMap<NaiveDate, Vec<FactorTextValue>>>,
factor_text_index: Arc<HashMap<(NaiveDate, String, String), FactorTextValue>>,
candidate_by_date: Arc<BTreeMap<NaiveDate, Vec<CandidateEligibility>>>,
candidate_symbol_ids_by_date: Arc<BTreeMap<NaiveDate, Vec<u32>>>,
candidate_row_positions_by_date: Arc<Option<DenseRowPositionIndex>>,
corporate_actions_by_date: Arc<BTreeMap<NaiveDate, Vec<CorporateAction>>>,
execution_quotes_by_date: Arc<HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>>,
execution_quote_dates: Arc<Vec<NaiveDate>>,
order_book_depth_index: Arc<HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>>,
benchmark_by_date: Arc<BTreeMap<NaiveDate, BenchmarkSnapshot>>,
market_series_by_symbol: Arc<AHashMap<String, Arc<SymbolPriceSeries>>>,
adjusted_close_series_by_symbol: Arc<AHashMap<String, Arc<AdjustedCloseSeries>>>,
market_series_by_symbol_id: Arc<Vec<Option<Arc<SymbolPriceSeries>>>>,
adjusted_close_series_by_symbol_id: Arc<Vec<Option<Arc<AdjustedCloseSeries>>>>,
market_series_end_positions_by_calendar_index: Arc<Option<CalendarSeriesEndPositions>>,
benchmark_series_cache: Arc<BenchmarkPriceSeries>,
symbol_id_by_code: Arc<AHashMap<String, u32>>,
symbol_by_id: Arc<Vec<Arc<str>>>,
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
benchmark_code: String,
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
}
struct DailySymbolRows<'a, T> {
rows: &'a [T],
symbol_ids: &'a [u32],
row_positions: Option<&'a [u32]>,
}
impl<'a, T> DailySymbolRows<'a, T> {
fn get(&self, symbol_id: u32) -> Option<&'a T> {
if let Some(positions) = self.row_positions {
let position = positions.get(symbol_id as usize).copied()?;
if position == MISSING_ROW_POSITION {
return None;
}
return self.rows.get(position as usize);
}
find_by_symbol_id(self.rows, self.symbol_ids, symbol_id)
}
}
/// Borrowed, immutable snapshots for one trading date.
///
/// A strategy evaluates thousands of symbols for the same date. Resolving the
/// date in three BTreeMaps for every symbol is unnecessary; this view freezes
/// the already indexed slices once and keeps all lookups read-only.
pub(crate) struct DailySnapshotView<'a> {
market: DailySymbolRows<'a, DailyMarketSnapshot>,
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<f64>; 7],
pub volume: [Option<f64>; 5],
}
impl DataSet {
pub fn with_additional_trading_dates(
mut self,
dates: impl IntoIterator<Item = NaiveDate>,
) -> 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<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
) -> Result<Self, DataSetError> {
Self::from_components_with_actions_and_quotes(
instruments,
market,
factors,
candidates,
benchmarks,
Vec::new(),
Vec::new(),
)
}
pub fn from_components_with_actions(
instruments: Vec<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
corporate_actions: Vec<CorporateAction>,
) -> Result<Self, DataSetError> {
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<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
corporate_actions: Vec<CorporateAction>,
execution_quotes: Vec<IntradayExecutionQuote>,
) -> Result<Self, DataSetError> {
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<Instrument>,
mut bundles: Vec<DailySnapshotBundle>,
execution_quotes: Vec<IntradayExecutionQuote>,
) -> Result<Self, DataSetError> {
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<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
corporate_actions: Vec<CorporateAction>,
execution_quotes: Vec<IntradayExecutionQuote>,
futures_params: Vec<FuturesTradingParameter>,
) -> Result<Self, DataSetError> {
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<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
corporate_actions: Vec<CorporateAction>,
execution_quotes: Vec<IntradayExecutionQuote>,
futures_params: Vec<FuturesTradingParameter>,
order_book_depth: Vec<IntradayOrderBookDepthLevel>,
) -> Result<Self, DataSetError> {
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<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
factor_texts: Vec<FactorTextValue>,
) -> Result<Self, DataSetError> {
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<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
corporate_actions: Vec<CorporateAction>,
execution_quotes: Vec<IntradayExecutionQuote>,
futures_params: Vec<FuturesTradingParameter>,
order_book_depth: Vec<IntradayOrderBookDepthLevel>,
factor_texts: Vec<FactorTextValue>,
) -> Result<Self, DataSetError> {
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<Instrument>,
market: Vec<DailyMarketSnapshot>,
factors: Vec<DailyFactorSnapshot>,
candidates: Vec<CandidateEligibility>,
benchmarks: Vec<BenchmarkSnapshot>,
corporate_actions: Vec<CorporateAction>,
execution_quotes: Vec<IntradayExecutionQuote>,
futures_params: Vec<FuturesTradingParameter>,
order_book_depth: Vec<IntradayOrderBookDepthLevel>,
factor_texts: Vec<FactorTextValue>,
grouped: Option<GroupedSnapshotComponents>,
) -> Result<Self, DataSetError> {
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::<BTreeMap<_, _>>();
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::<HashMap<_, _>>();
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::<str>::from(""); symbol_count];
for (symbol, symbol_id) in &symbol_id_by_code {
symbol_by_id[*symbol_id as usize] = Arc::<str>::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::<Vec<_>>();
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::<AHashMap<_, _>>();
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::<Vec<_>>();
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::<AHashMap<_, _>>();
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::<Vec<_>>();
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::<HashMap<_, _>>();
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::<Vec<_>>();
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<String, Instrument> {
&self.instruments
}
pub fn all_instruments(&self) -> Vec<&Instrument> {
let mut instruments = self.instruments.values().collect::<Vec<_>>();
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<u32> {
self.symbol_id_by_code.get(symbol).copied()
}
pub(crate) fn shared_symbol_by_id(&self, symbol_id: u32) -> Option<Arc<str>> {
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<NaiveDate, Vec<T>>,
symbol_ids_by_date: &'a BTreeMap<NaiveDate, Vec<u32>>,
row_positions_by_date: &'a Option<DenseRowPositionIndex>,
) -> DailySymbolRows<'a, T> {
DailySymbolRows {
rows: rows_by_date.get(&date).map(Vec::as_slice).unwrap_or(&[]),
symbol_ids: symbol_ids_by_date
.get(&date)
.map(Vec::as_slice)
.unwrap_or(&[]),
row_positions: row_positions_by_date
.as_ref()
.and_then(|positions| positions.get(&date))
.map(Vec::as_slice),
}
}
DailySnapshotView {
market: rows_on(
date,
&self.market_by_date,
&self.market_symbol_ids_by_date,
&self.market_row_positions_by_date,
),
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<usize> {
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<usize> {
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<usize> {
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<usize> {
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<usize> {
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<usize>,
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<IntradayMarketSnapshotOverlay>,
) -> Result<usize, DataSetError> {
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::<u32, Vec<usize>>::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::<Vec<_>>();
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<IntradayExecutionQuote>) -> 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::<Vec<_>>();
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<IntradayExecutionQuote>) -> usize {
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::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<IntradayExecutionQuote> {
self.execution_quotes_on_date_for_symbols(date, None)
}
pub fn execution_quotes_iter_on_date_for_symbols(
&self,
date: NaiveDate,
symbols: Option<&BTreeSet<String>>,
) -> 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<String>>,
) -> Vec<IntradayExecutionQuote> {
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::<Vec<_>>();
instruments.sort_by(|left, right| left.symbol.cmp(&right.symbol));
let market = self
.market_by_date
.values()
.flat_map(|rows| rows.iter().cloned())
.collect::<Vec<_>>();
let factors = self
.factor_by_date
.values()
.flat_map(|rows| rows.iter().cloned())
.collect::<Vec<_>>();
let candidates = self
.candidate_by_date
.values()
.flat_map(|rows| rows.iter().cloned())
.collect::<Vec<_>>();
let benchmarks = self.benchmark_by_date.values().cloned().collect::<Vec<_>>();
let corporate_actions = self
.corporate_actions_by_date
.values()
.flat_map(|rows| rows.iter().cloned())
.collect::<Vec<_>>();
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::<Vec<_>>();
DataSetSnapshotComponents {
instruments,
market,
factors,
candidates,
benchmarks,
corporate_actions,
execution_quotes,
}
}
pub fn benchmark_series(&self) -> Vec<BenchmarkSnapshot> {
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<f64> {
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<f64> {
self.history_bars_at(date, None, symbol, bar_count, frequency, field, include_now)
}
pub fn history_bars_at(
&self,
date: NaiveDate,
active_datetime: Option<NaiveDateTime>,
symbol: &str,
bar_count: usize,
frequency: &str,
field: &str,
include_now: bool,
) -> Vec<f64> {
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<DailyMarketSnapshot> {
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<IntradayExecutionQuote> {
self.history_intraday_quotes_at(date, None, symbol, bar_count, include_now)
}
pub fn history_intraday_quotes_at(
&self,
date: NaiveDate,
active_datetime: Option<NaiveDateTime>,
symbol: &str,
bar_count: usize,
include_now: bool,
) -> Vec<IntradayExecutionQuote> {
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<NaiveDate> {
self.calendar.trading_dates(start, end)
}
pub fn previous_trading_date(&self, date: NaiveDate, n: usize) -> Option<NaiveDate> {
self.calendar.previous_trading_date(date, n)
}
pub fn next_trading_date(&self, date: NaiveDate, n: usize) -> Option<NaiveDate> {
self.calendar.next_trading_date(date, n)
}
pub fn is_suspended_flags(&self, date: NaiveDate, symbol: &str, count: usize) -> Vec<bool> {
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<bool> {
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<DividendRecord> {
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::<Vec<_>>();
rows.sort_by_key(|row| row.ex_dividend_date);
rows
}
pub fn get_split(&self, symbol: &str, start: NaiveDate, end: NaiveDate) -> Vec<SplitRecord> {
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::<Vec<_>>();
rows.sort_by_key(|row| row.ex_dividend_date);
rows
}
pub fn get_factor(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorValue> {
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::<Vec<_>>();
rows.sort_by_key(|row| row.date);
rows
}
pub fn get_factor_text(
&self,
symbol: &str,
start: NaiveDate,
end: NaiveDate,
field: &str,
) -> Vec<FactorTextValue> {
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::<Vec<_>>();
rows.sort_by_key(|row| row.date);
rows
}
pub fn get_yield_curve(
&self,
start: NaiveDate,
end: NaiveDate,
tenor: Option<&str>,
) -> Vec<YieldCurvePoint> {
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<String> {
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::<Vec<_>>()
})
.unwrap_or_default();
if symbols.is_empty() {
symbols = self
.active_instruments(
date,
&self
.instruments
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
)
.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<SecuritiesMarginRecord> {
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<FactorValue> {
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<FactorValue> {
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<FactorValue> {
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::<Vec<_>>();
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<FactorValue> {
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<FactorValue> {
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<FactorValue> {
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<FactorValue> {
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<FactorValue> {
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<FactorValue> {
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<FactorTextValue> {
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<String> {
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::<Vec<_>>();
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<PriceBar> {
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<PriceBar> {
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::<Vec<_>>();
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<f64> {
let snapshot = self.market(date, symbol)?;
Some(snapshot.price(field))
}
pub fn price_on_or_before(
&self,
date: NaiveDate,
symbol: &str,
field: PriceField,
) -> Option<f64> {
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<DailySnapshotBundle, DataSetError> {
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<f64> {
self.benchmark_series_cache.trailing_values(date, lookback)
}
pub fn market_closes_up_to(&self, date: NaiveDate, symbol: &str, lookback: usize) -> Vec<f64> {
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<f64> {
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<NaiveDateTime>,
symbol: &str,
bar_count: usize,
field: &str,
include_now: bool,
) -> Vec<f64> {
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<F>(
&self,
date: NaiveDate,
symbol: &str,
count: usize,
evaluator: F,
) -> Vec<bool>
where
F: Fn(Option<&CandidateEligibility>, Option<&DailyMarketSnapshot>) -> bool,
{
if count == 0 {
return Vec::new();
}
let days = self
.calendar
.iter()
.filter(|day| *day <= date)
.collect::<Vec<_>>();
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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<String> {
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<FactorValue> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<usize>,
) -> Option<f64> {
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<usize>,
) -> Option<f64> {
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<usize>,
) -> Option<f64> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
self.market_series(symbol)
.and_then(|series| series.moving_average(date, lookback, field))
}
pub fn benchmark_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
self.benchmark_series_cache.moving_average(date, lookback)
}
pub fn benchmark_decision_close(&self, date: NaiveDate) -> Option<f64> {
self.benchmark_series_cache.decision_close(date)
}
pub fn benchmark_decision_moving_average(
&self,
date: NaiveDate,
lookback: usize,
) -> Option<f64> {
self.benchmark_series_cache
.decision_moving_average(date, lookback)
}
pub fn benchmark_open_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
self.benchmark_series_cache
.moving_average_for(date, lookback, PriceField::Open)
}
pub fn benchmark_numeric_values(
&self,
date: NaiveDate,
field: &str,
lookback: usize,
) -> Vec<f64> {
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<f64> {
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<f64> {
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<EligibleUniverseSnapshot> {
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<EligibleUniverseSnapshot> {
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<String> {
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<String> {
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::<Vec<_>>(),
)
}
fn turnover_rate_factor_aliases(field: &str) -> Vec<String> {
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::<Vec<_>>(),
)
}
fn stock_connect_factor_aliases(field: &str) -> Vec<String> {
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::<Vec<_>>(),
)
}
fn prefixed_factor_aliases(prefix: &str, field: &str) -> Vec<String> {
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<String> {
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<String> {
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<f64> {
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<f64> {
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<NaiveDateTime>,
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<DailyFactorSnapshot>,
) -> Result<Vec<DailyFactorSnapshot>, 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<String> {
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<T, D, S>(
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<T, F>(rows: Vec<T>, mut date_of: F) -> BTreeMap<NaiveDate, Vec<T>>
where
F: FnMut(&T) -> NaiveDate,
{
let mut grouped = BTreeMap::<NaiveDate, Vec<T>>::new();
for row in rows {
grouped.entry(date_of(&row)).or_default().push(row);
}
grouped
}
fn sort_groups_by_symbol<T, F>(groups: &mut BTreeMap<NaiveDate, Vec<T>>, 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<T, F>(rows: &mut Vec<T>, 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<String, Instrument>,
market_by_date: &BTreeMap<NaiveDate, Vec<DailyMarketSnapshot>>,
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
candidate_by_date: &BTreeMap<NaiveDate, Vec<CandidateEligibility>>,
) -> AHashMap<String, u32> {
let mut symbols = instruments.keys().cloned().collect::<HashSet<_>>();
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::<Vec<_>>();
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<T, F>(
groups: &BTreeMap<NaiveDate, Vec<T>>,
symbol_id_by_code: &AHashMap<String, u32>,
symbol_of: F,
) -> BTreeMap<NaiveDate, Vec<u32>>
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::<Vec<_>>();
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<NaiveDate, Vec<T>>,
symbol_ids_by_date: &BTreeMap<NaiveDate, Vec<u32>>,
symbol_count: usize,
) -> Result<Vec<Vec<&'a T>>, 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::<Vec<_>>();
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<NaiveDate, Vec<DailyFactorSnapshot>>,
factor_symbol_ids_by_date: &BTreeMap<NaiveDate, Vec<u32>>,
) -> BTreeMap<NaiveDate, Vec<u32>> {
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::<Vec<_>>();
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::<Vec<_>>();
(*date, ordered)
})
.collect::<Vec<_>>()
.into_iter()
.collect()
}
fn build_dense_row_positions<T>(
groups: &BTreeMap<NaiveDate, Vec<T>>,
symbol_ids_by_date: &BTreeMap<NaiveDate, Vec<u32>>,
symbol_count: usize,
) -> Option<DenseRowPositionIndex> {
let entries = groups.len().checked_mul(symbol_count)?;
let bytes = entries.checked_mul(std::mem::size_of::<u32>())?;
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<Arc<SymbolPriceSeries>>],
calendar: &TradingCalendar,
) -> Option<CalendarSeriesEndPositions> {
let entries = series_by_symbol_id.len().checked_mul(calendar.len())?;
let bytes = entries
.checked_mul(2)?
.checked_mul(std::mem::size_of::<u32>())?;
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::<Vec<_>>();
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::<Vec<_>>();
let (decision, current) = positions_by_calendar.into_iter().unzip();
Some(CalendarSeriesEndPositions { decision, current })
}
fn dense_row_position(
positions_by_date: &Option<DenseRowPositionIndex>,
date: NaiveDate,
symbol_id: u32,
) -> Option<usize> {
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<usize>,
) -> 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<String, DataSetError>
where
I: IntoIterator<Item = &'a BenchmarkSnapshot>,
{
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<f64> {
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<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
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<Option<NaiveDate>, D::Error>
where
D: Deserializer<'de>,
{
let text = Option::<String>::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<FuturesTradingParameter>,
) -> HashMap<String, Vec<FuturesTradingParameter>> {
let mut grouped = HashMap::<String, Vec<FuturesTradingParameter>>::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<IntradayExecutionQuote>,
) -> HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>> {
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::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<IntradayOrderBookDepthLevel>,
) -> HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>> {
let mut grouped = HashMap::<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>::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<NaiveDate, Vec<DailyFactorSnapshot>>,
market_by_date: &BTreeMap<NaiveDate, Vec<DailyMarketSnapshot>>,
) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> {
let mut per_date = BTreeMap::<NaiveDate, Vec<EligibleUniverseSnapshot>>::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<NaiveDate, Vec<DailyFactorSnapshot>>,
market_by_date: &BTreeMap<NaiveDate, Vec<DailyMarketSnapshot>>,
) -> Vec<EligibleUniverseSnapshot> {
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<NaiveDate, Vec<DailyFactorSnapshot>>,
candidate_by_date: &BTreeMap<NaiveDate, Vec<CandidateEligibility>>,
market_by_date: &BTreeMap<NaiveDate, Vec<DailyMarketSnapshot>>,
instruments: &HashMap<String, Instrument>,
risk_config: &FidcRiskControlConfig,
) -> Vec<EligibleUniverseSnapshot> {
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<NaiveDate, Vec<CandidateEligibility>>,
market_by_date: &BTreeMap<NaiveDate, Vec<DailyMarketSnapshot>>,
instruments: &HashMap<String, Instrument>,
risk_config: &FidcRiskControlConfig,
) -> Vec<EligibleUniverseSnapshot> {
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::<Vec<_>>();
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<_>>(),
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::<Vec<_>>();
let map = rows
.iter()
.map(|(symbol, _, instrument)| (symbol.clone(), instrument.clone()))
.collect::<HashMap<_, _>>();
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::<Vec<_>>();
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::<Vec<_>>();
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: symbol.clone(),
name: symbol.clone(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
})
.collect::<Vec<_>>();
let market = symbols
.iter()
.enumerate()
.map(|(index, symbol)| {
let mut row = market_row("2025-01-02", 10.0 + index as f64 / 1000.0, 1_000_000);
row.symbol = symbol.clone();
row
})
.collect::<Vec<_>>();
let factors = symbols
.iter()
.enumerate()
.map(|(index, symbol)| DailyFactorSnapshot {
date,
symbol: symbol.clone(),
market_cap_bn: 10.0 + index as f64 / 1000.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: NumericFactorMap::new(),
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| CandidateEligibility {
date,
symbol: symbol.clone(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
})
.collect::<Vec<_>>();
let data = DataSet::from_components(
instruments,
market,
factors,
candidates,
vec![benchmark_row("2025-01-02", 20.0)],
)
.unwrap();
let symbol_ids = symbols
.iter()
.map(|symbol| data.symbol_id(symbol).unwrap())
.collect::<Vec<_>>();
let rounds = 200usize;
let started = Instant::now();
let mut baseline_sum = 0.0;
for _ in 0..rounds {
for symbol_id in symbol_ids.iter().copied() {
baseline_sum += black_box(
data.market_by_symbol_id(date, symbol_id).unwrap().close
+ data
.candidate_by_symbol_id(date, symbol_id)
.unwrap()
.allow_buy as u8 as f64,
);
}
}
let baseline = started.elapsed();
let day = data.daily_snapshot_view(date);
let started = Instant::now();
let mut view_sum = 0.0;
for _ in 0..rounds {
for symbol_id in symbol_ids.iter().copied() {
view_sum += black_box(
day.market(symbol_id).unwrap().close
+ day.candidate(symbol_id).unwrap().allow_buy as u8 as f64,
);
}
}
let view = started.elapsed();
assert_eq!(baseline_sum, view_sum);
println!(
"daily_snapshot_view rows={} rounds={} baseline_seconds={:.6} view_seconds={:.6}",
symbol_count,
rounds,
baseline.as_secs_f64(),
view.as_secs_f64(),
);
}
#[test]
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::<Vec<_>>();
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::<Vec<_>>();
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<_>>(),
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<NaiveDate>| 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::<Vec<_>>();
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<_>>(),
vec![symbol]
);
assert_eq!(
data.eligible_universe_on_with_risk_config(date, &FidcRiskControlConfig::default())
.iter()
.map(|row| row.symbol.as_str())
.collect::<Vec<_>>(),
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)
);
}
}