diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 167cd77..d903539 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -83,6 +83,10 @@ pub enum DataSetError { 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 }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -273,6 +277,27 @@ pub struct IntradayExecutionQuote { pub trading_phase: Option, } +/// Sparse same-day fields layered onto an already-built immutable daily panel. +/// +/// These fields do not participate in daily price series, adjustment series, +/// symbol indexes, or rolling windows. Applying them in place lets the runner +/// reuse the candidate-planning `DataSet` as the final execution `DataSet` +/// without rebuilding the full market panel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntradayMarketSnapshotOverlay { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub timestamp: Option, + pub last_price: Option, + pub bid1: f64, + pub ask1: f64, + pub minute_volume: u64, + pub bid1_volume: u64, + pub ask1_volume: u64, + pub trading_phase: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IntradayOrderBookDepthLevel { #[serde(with = "date_format")] @@ -2213,6 +2238,97 @@ impl DataSet { .sum() } + /// Applies sparse intraday fields without rebuilding daily series or indexes. + /// + /// The daily market storage must still be uniquely owned. This is deliberate: + /// silently using `Arc::make_mut` here would deep-copy the full market panel + /// and defeat the candidate-plan/final-dataset reuse contract. + pub fn apply_intraday_market_overlays( + &mut self, + overlays: Vec, + ) -> Result { + if overlays.is_empty() { + return Ok(0); + } + + let mut resolved = Vec::with_capacity(overlays.len()); + let mut seen = HashSet::<(NaiveDate, u32)>::with_capacity(overlays.len()); + 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_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(), + })?; + resolved.push((overlay.date, row_position, overlay)); + } + + let market_by_date = Arc::get_mut(&mut self.market_by_date).ok_or( + DataSetError::SharedComponentMutation { + component: "daily market panel", + }, + )?; + 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, + symbol: overlay.symbol.clone(), + })?; + debug_assert_eq!(row.symbol, overlay.symbol); + row.timestamp = overlay.timestamp; + 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; + } + Ok(seen.len()) + } + + /// Replaces the run-local execution quote layer without touching the + /// immutable daily panel. + pub fn replace_execution_quotes(&mut self, quotes: Vec) -> usize { + let execution_quotes_by_date = build_execution_quote_index(quotes); + let quote_count = execution_quotes_by_date + .values() + .flat_map(|rows_by_symbol| rows_by_symbol.values()) + .map(Vec::len) + .sum(); + let mut execution_quote_dates = execution_quotes_by_date.keys().copied().collect::>(); + execution_quote_dates.sort_unstable(); + self.execution_quotes_by_date = Arc::new(execution_quotes_by_date); + self.execution_quote_dates = Arc::new(execution_quote_dates); + quote_count + } + pub fn add_execution_quotes(&mut self, quotes: Vec) -> usize { let mut grouped = HashMap::>>::new(); for quote in quotes { @@ -4849,6 +4965,131 @@ mod tests { )); } + #[test] + fn unique_dataset_applies_sparse_intraday_overlay_without_rebuilding_daily_series() { + 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(), + ); + + 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); + assert!(Arc::ptr_eq( + &market_series_before, + data.market_series_by_symbol_id[data.symbol_id("000001.SZ").unwrap() as usize] + .as_ref() + .unwrap() + )); + } + + #[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 = [ diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index e6cbf1c..aabbffa 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -31,8 +31,8 @@ pub use data::{ BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot, DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord, EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote, - IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord, - SplitRecord, YieldCurvePoint, + IntradayMarketSnapshotOverlay, IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, + PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint, }; pub use engine::{ AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,