支持复用只读日线基础面板
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user