diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 4725d40..49f9d70 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -646,14 +646,22 @@ impl AdjustedCloseSeries { } fn decision_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { + let end = self.decision_end_index(date)?; + self.decision_moving_average_at_end(end, lookback) + } + + fn decision_end_index(&self, date: NaiveDate) -> Option { + match self.dates.binary_search(&date) { + Ok(index) => Some(index), + Err(0) => None, + Err(index) => Some(index), + } + } + + fn decision_moving_average_at_end(&self, end: usize, lookback: usize) -> Option { if lookback == 0 { return None; } - let end = match self.dates.binary_search(&date) { - Ok(index) => index, - Err(0) => return None, - Err(index) => index, - }; if end < lookback { return None; } @@ -945,6 +953,14 @@ impl SymbolPriceSeries { fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { let end = self.previous_completed_end_index(date)?; + self.decision_volume_moving_average_at_end(end, lookback) + } + + fn decision_volume_moving_average_at_end( + &self, + end: usize, + lookback: usize, + ) -> Option { self.valid_volume_window(end, lookback).map(|(start, end)| { normalize_rolling_factor( (self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start]) @@ -1246,6 +1262,28 @@ pub(crate) struct SymbolSnapshotRefs<'a> { pub candidate: Option<&'a CandidateEligibility>, } +#[derive(Debug, Clone, Copy)] +pub(crate) struct DecisionRollingCursor<'a> { + adjusted_close: Option<(&'a AdjustedCloseSeries, usize)>, + volume: Option<(&'a SymbolPriceSeries, usize)>, +} + +impl DecisionRollingCursor<'_> { + pub(crate) fn moving_average(&self, field: &str, lookback: usize) -> Option { + match field { + "close" => self + .adjusted_close + .and_then(|(series, end)| series.decision_moving_average_at_end(end, lookback)), + "volume" => self + .volume + .and_then(|(series, end)| { + series.decision_volume_moving_average_at_end(end, lookback) + }), + _ => None, + } + } +} + impl DataSet { pub fn with_additional_trading_dates( mut self, @@ -2881,6 +2919,27 @@ impl DataSet { } } + pub(crate) fn market_decision_rolling_cursor_by_symbol_id( + &self, + date: NaiveDate, + symbol_id: u32, + ) -> DecisionRollingCursor<'_> { + let adjusted_close = self + .adjusted_close_series_by_symbol_id(symbol_id) + .and_then(|series| series.decision_end_index(date).map(|end| (series, end))); + let volume = self + .market_series_by_symbol_id(symbol_id) + .and_then(|series| { + series + .previous_completed_end_index(date) + .map(|end| (series, end)) + }); + DecisionRollingCursor { + adjusted_close, + volume, + } + } + pub fn market_current_numeric_moving_average( &self, date: NaiveDate, @@ -4132,6 +4191,78 @@ mod tests { assert!(signal.candidate.is_none()); } + #[test] + fn decision_rolling_cursor_matches_existing_close_and_volume_contract() { + let dates = ["2025-01-02", "2025-01-03", "2025-01-06", "2025-01-07"]; + let market = dates + .iter() + .enumerate() + .map(|(index, date)| market_row(date, 10.0 + index as f64, 1_000 + index as u64 * 100)) + .collect::>(); + let factors = dates + .iter() + .map(|date| DailyFactorSnapshot { + date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), + symbol: "000001.SZ".to_string(), + market_cap_bn: 100.0, + free_float_cap_bn: 80.0, + pe_ttm: 0.0, + turnover_ratio: None, + effective_turnover_ratio: None, + extra_factors: NumericFactorMap::from([( + Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), + 1.0, + )]), + }) + .collect::>(); + let benchmarks = dates + .iter() + .enumerate() + .map(|(index, date)| benchmark_row(date, 20.0 + index as f64)) + .collect::>(); + 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(), + }], + market, + factors, + Vec::new(), + benchmarks, + ) + .unwrap(); + let symbol_id = data.symbol_id("000001.SZ").unwrap(); + let date = NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(); + let cursor = data.market_decision_rolling_cursor_by_symbol_id(date, symbol_id); + for lookback in [1, 2, 3] { + assert_eq!( + cursor.moving_average("close", lookback), + data.market_decision_numeric_moving_average_by_symbol_id( + date, + symbol_id, + "000001.SZ", + "close", + lookback, + ) + ); + assert_eq!( + cursor.moving_average("volume", lookback), + data.market_decision_numeric_moving_average_by_symbol_id( + date, + symbol_id, + "000001.SZ", + "volume", + lookback, + ) + ); + } + } + #[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(); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b7abec7..9fc6fd6 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3935,11 +3935,15 @@ impl PlatformExprStrategy { None }; let instrument = ctx.data.instrument(symbol); + let rolling_cursor = ctx + .data + .market_decision_rolling_cursor_by_symbol_id(date, symbol_id); let rolling = |field: &'static str, lookback: usize| -> f64 { if !self.stock_rolling_requirements.requires(field, lookback) { return f64::NAN; } - self.stock_decision_rolling_mean(ctx, date, symbol_id, symbol, field, lookback) + rolling_cursor + .moving_average(field, lookback) .unwrap_or(f64::NAN) }; let stock_ma_short = rolling("close", self.config.stock_short_ma_days);