diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 1dfd4e7..0901494 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -2012,6 +2012,14 @@ impl DataSet { Some(*end as usize) } + pub(crate) fn market_current_series_end_index_by_symbol_id( + &self, + date: NaiveDate, + symbol_id: u32, + ) -> Option { + self.market_series_end_index_by_symbol_id(date, symbol_id, true) + } + pub fn factor(&self, date: NaiveDate, symbol: &str) -> Option<&DailyFactorSnapshot> { let symbol_id = self.symbol_id(symbol)?; self.factor_by_symbol_id(date, symbol_id) @@ -3347,20 +3355,56 @@ impl DataSet { symbol: &str, field: &str, lookback: usize, + ) -> Option { + let normalized = normalized_field(field); + let series_end = matches!( + normalized.as_ref(), + "close" + | "prev_close" + | "stock_close" + | "price" + | "volume" + | "stock_volume" + | "day_open" + | "dayopen" + | "open" + | "last" + | "last_price" + ) + .then(|| self.market_current_series_end_index_by_symbol_id(date, symbol_id)) + .flatten(); + self.market_current_numeric_moving_average_with_end_by_symbol_id( + date, + symbol_id, + symbol, + normalized.as_ref(), + lookback, + series_end, + ) + } + + pub(crate) fn market_current_numeric_moving_average_with_end_by_symbol_id( + &self, + date: NaiveDate, + symbol_id: u32, + symbol: &str, + field: &str, + lookback: usize, + series_end: Option, ) -> Option { let field = normalized_field(field); match field.as_ref() { "close" | "prev_close" | "stock_close" | "price" => self .adjusted_close_series_by_symbol_id(symbol_id) .and_then(|series| { - self.market_series_end_index_by_symbol_id(date, symbol_id, true) + series_end .map(|end| series.moving_average_at_end(end, lookback)) .unwrap_or_else(|| series.current_moving_average(date, lookback)) }), "volume" | "stock_volume" => { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { - self.market_series_end_index_by_symbol_id(date, symbol_id, true) + series_end .map(|end| { series .valid_volume_window(end, lookback) @@ -3379,7 +3423,7 @@ impl DataSet { "day_open" | "dayopen" => { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { - self.market_series_end_index_by_symbol_id(date, symbol_id, true) + series_end .map(|end| { series.moving_average_at_end(end, lookback, PriceField::DayOpen) }) @@ -3391,14 +3435,14 @@ impl DataSet { "open" => self .market_series_by_symbol_id(symbol_id) .and_then(|series| { - self.market_series_end_index_by_symbol_id(date, symbol_id, true) + series_end .map(|end| series.moving_average_at_end(end, lookback, PriceField::Open)) .unwrap_or_else(|| series.moving_average(date, lookback, PriceField::Open)) }), "last" | "last_price" => { self.market_series_by_symbol_id(symbol_id) .and_then(|series| { - self.market_series_end_index_by_symbol_id(date, symbol_id, true) + series_end .map(|end| { series.moving_average_at_end(end, lookback, PriceField::Last) }) @@ -5052,6 +5096,137 @@ mod tests { ); } + #[test] + #[ignore = "manual release-mode current rolling boundary benchmark"] + fn benchmark_current_rolling_reuses_symbol_boundary() { + let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let dates = (0..160) + .map(|offset| start + chrono::Duration::days(offset)) + .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(), + }], + dates + .iter() + .enumerate() + .map(|(index, date)| { + market_row( + &date.format("%Y-%m-%d").to_string(), + 10.0 + index as f64 / 100.0, + 100_000 + index as u64, + ) + }) + .collect(), + dates + .iter() + .map(|date| DailyFactorSnapshot { + date: *date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 8.0, + pe_ttm: 10.0, + turnover_ratio: None, + effective_turnover_ratio: None, + extra_factors: NumericFactorMap::from([( + Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), + 1.0, + )]), + }) + .collect(), + Vec::new(), + dates + .iter() + .enumerate() + .map(|(index, date)| { + benchmark_row(&date.format("%Y-%m-%d").to_string(), 1_000.0 + index as f64) + }) + .collect(), + ) + .unwrap(); + let date = *dates.last().unwrap(); + let symbol = "000001.SZ"; + let symbol_id = data.symbol_id(symbol).unwrap(); + let requirements = [ + ("close", 5usize), + ("close", 10usize), + ("close", 30usize), + ("volume", 5usize), + ("volume", 100usize), + ]; + let iterations = 100_000usize; + let mut repeated_nanos = 0u128; + let mut reused_nanos = 0u128; + let mut repeated_checksum = 0.0; + let mut reused_checksum = 0.0; + + for iteration in 0..iterations { + if iteration % 2 == 0 { + let started = std::time::Instant::now(); + for (field, lookback) in requirements { + repeated_checksum += data + .market_current_numeric_moving_average_by_symbol_id( + date, symbol_id, symbol, field, lookback, + ) + .unwrap(); + } + repeated_nanos += started.elapsed().as_nanos(); + let started = std::time::Instant::now(); + let series_end = data.market_current_series_end_index_by_symbol_id(date, symbol_id); + for (field, lookback) in requirements { + reused_checksum += data + .market_current_numeric_moving_average_with_end_by_symbol_id( + date, symbol_id, symbol, field, lookback, series_end, + ) + .unwrap(); + } + reused_nanos += started.elapsed().as_nanos(); + } else { + let started = std::time::Instant::now(); + let series_end = data.market_current_series_end_index_by_symbol_id(date, symbol_id); + for (field, lookback) in requirements { + reused_checksum += data + .market_current_numeric_moving_average_with_end_by_symbol_id( + date, symbol_id, symbol, field, lookback, series_end, + ) + .unwrap(); + } + reused_nanos += started.elapsed().as_nanos(); + let started = std::time::Instant::now(); + for (field, lookback) in requirements { + repeated_checksum += data + .market_current_numeric_moving_average_by_symbol_id( + date, symbol_id, symbol, field, lookback, + ) + .unwrap(); + } + repeated_nanos += started.elapsed().as_nanos(); + } + } + + assert!((repeated_checksum - reused_checksum).abs() < 1e-6); + let repeated_seconds = repeated_nanos as f64 / 1_000_000_000.0; + let reused_seconds = reused_nanos as f64 / 1_000_000_000.0; + eprintln!( + "{}", + serde_json::json!({ + "schemaVersion": "fidc-current-rolling-boundary-benchmark/v1", + "iterations": iterations, + "helperCallsPerIteration": requirements.len(), + "repeatedLookupSeconds": repeated_seconds, + "reusedBoundarySeconds": reused_seconds, + "speedup": repeated_seconds / reused_seconds, + "equal": true, + }) + ); + } + #[test] #[ignore = "manual component benchmark"] fn benchmark_daily_snapshot_view_lookup() { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c7c86d0..20be0ad 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -718,6 +718,7 @@ struct StockExpressionState { stock_volume_ma20: f64, stock_volume_ma60: f64, stock_volume_ma100: f64, + current_series_end: Option, extra_factors: BTreeMap, extra_text_factors: BTreeMap, } @@ -1044,6 +1045,7 @@ pub struct PlatformExprStrategy { stock_filter_expr_present: bool, selection_quote_usage: StockFilterQuoteUsage, stock_rolling_requirements: StockRollingRequirements, + stock_current_rolling_mean_required: bool, stock_extra_factors_required: bool, stock_extra_factor_identifiers: BTreeSet, stock_extra_factor_map_required: bool, @@ -1321,6 +1323,8 @@ impl PlatformExprStrategy { let selection_quote_usage = Self::selection_quote_usage_for_config(&config, &normalized_stock_filter_expr); let stock_rolling_requirements = Self::stock_rolling_requirements_for_config(&config); + let stock_current_rolling_mean_required = + Self::stock_current_rolling_mean_required_for_config(&config); let stock_extra_factors_required = Self::stock_extra_factors_required_for_config(&config, &prelude_declared_identifiers); let stock_extra_factor_identifiers = @@ -1363,6 +1367,7 @@ impl PlatformExprStrategy { stock_filter_expr_present, selection_quote_usage, stock_rolling_requirements, + stock_current_rolling_mean_required, stock_extra_factors_required, stock_extra_factor_identifiers, stock_extra_factor_map_required, @@ -3913,14 +3918,19 @@ impl PlatformExprStrategy { &self, ctx: &StrategyContext<'_>, date: NaiveDate, - symbol_id: u32, - symbol: &str, + stock: &StockExpressionState, field: &str, lookback: usize, ) -> Option { - ctx.data.market_current_numeric_moving_average_by_symbol_id( - date, symbol_id, symbol, field, lookback, - ) + ctx.data + .market_current_numeric_moving_average_with_end_by_symbol_id( + date, + stock.symbol_id, + &stock.symbol, + field, + lookback, + stock.current_series_end, + ) } fn stock_state_at_time( @@ -4065,6 +4075,13 @@ impl PlatformExprStrategy { None }; let instrument = ctx.data.instrument_by_symbol_id(symbol_id); + let current_series_end = self + .stock_current_rolling_mean_required + .then(|| { + ctx.data + .market_current_series_end_index_by_symbol_id(date, symbol_id) + }) + .flatten(); let required_rolling = |field: &'static str, lookback: usize| { self.stock_rolling_requirements .requires(field, lookback) @@ -4258,6 +4275,7 @@ impl PlatformExprStrategy { stock_volume_ma20, stock_volume_ma60, stock_volume_ma100, + current_series_end, extra_factors, extra_text_factors: if self.stock_text_factors_required { ctx.data @@ -7091,14 +7109,7 @@ impl PlatformExprStrategy { "rolling_mean_current(\"{other}\", {lookback}) requires stock context" )) })?; - self.stock_current_rolling_mean( - ctx, - day.date, - stock.symbol_id, - &stock.symbol, - other, - lookback, - ) + self.stock_current_rolling_mean(ctx, day.date, stock, other, lookback) } }; value.ok_or_else(|| { @@ -9993,6 +10004,30 @@ impl PlatformExprStrategy { requirements } + fn stock_current_rolling_mean_required_for_config(config: &PlatformExprStrategyConfig) -> bool { + let mut requirements = StockRollingRequirements::default(); + for expr in [ + config.prelude.as_str(), + config.stock_filter_expr.as_str(), + config.buy_scale_expr.as_str(), + config.stop_loss_expr.as_str(), + config.take_profit_expr.as_str(), + config.rank_expr.as_str(), + config.market_cap_field.as_str(), + ] { + let compact = Self::compact_expr(&Self::normalize_expr(expr)); + Self::require_stock_rollings_for_named_helper( + &mut requirements, + &compact, + "rolling_mean_current", + ); + if !requirements.fields.is_empty() { + return true; + } + } + false + } + fn stock_extra_factors_required_for_config( config: &PlatformExprStrategyConfig, prelude_declared_identifiers: &BTreeSet, @@ -14272,6 +14307,16 @@ mod tests { assert!(!strategy.stock_rolling_requirements.requires("close", 10)); assert!(!strategy.stock_rolling_requirements.requires("volume", 5)); assert!(!strategy.stock_rolling_requirements.requires("volume", 100)); + assert!(strategy.stock_current_rolling_mean_required); + + let mut signal_only = PlatformExprStrategyConfig::microcap_rotation(); + signal_only.stock_filter_expr = "true".to_string(); + signal_only.prelude = + "let signal_ma = rolling_mean_current(\"signal_close\", 20);".to_string(); + assert!( + !PlatformExprStrategy::new(signal_only).stock_current_rolling_mean_required, + "signal-only rolling helpers must not add per-stock current boundaries" + ); } #[test]