支持复用只读日线基础面板
This commit is contained in:
@@ -83,6 +83,10 @@ pub enum DataSetError {
|
|||||||
row_date: NaiveDate,
|
row_date: NaiveDate,
|
||||||
symbol: String,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -273,6 +277,27 @@ pub struct IntradayExecutionQuote {
|
|||||||
pub trading_phase: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct IntradayOrderBookDepthLevel {
|
pub struct IntradayOrderBookDepthLevel {
|
||||||
#[serde(with = "date_format")]
|
#[serde(with = "date_format")]
|
||||||
@@ -2213,6 +2238,97 @@ impl DataSet {
|
|||||||
.sum()
|
.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 {
|
pub fn add_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
|
||||||
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::new();
|
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::new();
|
||||||
for quote in quotes {
|
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]
|
#[test]
|
||||||
fn daily_bundle_constructor_matches_flat_component_constructor() {
|
fn daily_bundle_constructor_matches_flat_component_constructor() {
|
||||||
let dates = [
|
let dates = [
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ pub use data::{
|
|||||||
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
||||||
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
|
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
|
||||||
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
|
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
|
||||||
IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord,
|
IntradayMarketSnapshotOverlay, IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar,
|
||||||
SplitRecord, YieldCurvePoint,
|
PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
|
||||||
};
|
};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||||
|
|||||||
Reference in New Issue
Block a user