支持复用只读日线基础面板

This commit is contained in:
boris
2026-09-07 12:54:49 +08:00
parent 1ec0bb65f7
commit d5af51c02b
2 changed files with 243 additions and 2 deletions
+241
View File
@@ -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<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")]
@@ -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<IntradayMarketSnapshotOverlay>,
) -> Result<usize, DataSetError> {
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<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 {
@@ -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 = [
+2 -2
View File
@@ -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,