perf: reuse current rolling series boundary

This commit is contained in:
boris
2026-09-04 23:06:52 +08:00
parent 914820cc07
commit 52b07be19b
2 changed files with 238 additions and 18 deletions
+180 -5
View File
@@ -2012,6 +2012,14 @@ impl DataSet {
Some(*end as usize) Some(*end as usize)
} }
pub(crate) fn market_current_series_end_index_by_symbol_id(
&self,
date: NaiveDate,
symbol_id: u32,
) -> Option<usize> {
self.market_series_end_index_by_symbol_id(date, symbol_id, true)
}
pub fn factor(&self, date: NaiveDate, symbol: &str) -> Option<&DailyFactorSnapshot> { pub fn factor(&self, date: NaiveDate, symbol: &str) -> Option<&DailyFactorSnapshot> {
let symbol_id = self.symbol_id(symbol)?; let symbol_id = self.symbol_id(symbol)?;
self.factor_by_symbol_id(date, symbol_id) self.factor_by_symbol_id(date, symbol_id)
@@ -3347,20 +3355,56 @@ impl DataSet {
symbol: &str, symbol: &str,
field: &str, field: &str,
lookback: usize, lookback: usize,
) -> Option<f64> {
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<usize>,
) -> Option<f64> { ) -> Option<f64> {
let field = normalized_field(field); let field = normalized_field(field);
match field.as_ref() { match field.as_ref() {
"close" | "prev_close" | "stock_close" | "price" => self "close" | "prev_close" | "stock_close" | "price" => self
.adjusted_close_series_by_symbol_id(symbol_id) .adjusted_close_series_by_symbol_id(symbol_id)
.and_then(|series| { .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)) .map(|end| series.moving_average_at_end(end, lookback))
.unwrap_or_else(|| series.current_moving_average(date, lookback)) .unwrap_or_else(|| series.current_moving_average(date, lookback))
}), }),
"volume" | "stock_volume" => { "volume" | "stock_volume" => {
self.market_series_by_symbol_id(symbol_id) self.market_series_by_symbol_id(symbol_id)
.and_then(|series| { .and_then(|series| {
self.market_series_end_index_by_symbol_id(date, symbol_id, true) series_end
.map(|end| { .map(|end| {
series series
.valid_volume_window(end, lookback) .valid_volume_window(end, lookback)
@@ -3379,7 +3423,7 @@ impl DataSet {
"day_open" | "dayopen" => { "day_open" | "dayopen" => {
self.market_series_by_symbol_id(symbol_id) self.market_series_by_symbol_id(symbol_id)
.and_then(|series| { .and_then(|series| {
self.market_series_end_index_by_symbol_id(date, symbol_id, true) series_end
.map(|end| { .map(|end| {
series.moving_average_at_end(end, lookback, PriceField::DayOpen) series.moving_average_at_end(end, lookback, PriceField::DayOpen)
}) })
@@ -3391,14 +3435,14 @@ impl DataSet {
"open" => self "open" => self
.market_series_by_symbol_id(symbol_id) .market_series_by_symbol_id(symbol_id)
.and_then(|series| { .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)) .map(|end| series.moving_average_at_end(end, lookback, PriceField::Open))
.unwrap_or_else(|| series.moving_average(date, lookback, PriceField::Open)) .unwrap_or_else(|| series.moving_average(date, lookback, PriceField::Open))
}), }),
"last" | "last_price" => { "last" | "last_price" => {
self.market_series_by_symbol_id(symbol_id) self.market_series_by_symbol_id(symbol_id)
.and_then(|series| { .and_then(|series| {
self.market_series_end_index_by_symbol_id(date, symbol_id, true) series_end
.map(|end| { .map(|end| {
series.moving_average_at_end(end, lookback, PriceField::Last) 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::<Vec<_>>();
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] #[test]
#[ignore = "manual component benchmark"] #[ignore = "manual component benchmark"]
fn benchmark_daily_snapshot_view_lookup() { fn benchmark_daily_snapshot_view_lookup() {
+58 -13
View File
@@ -718,6 +718,7 @@ struct StockExpressionState {
stock_volume_ma20: f64, stock_volume_ma20: f64,
stock_volume_ma60: f64, stock_volume_ma60: f64,
stock_volume_ma100: f64, stock_volume_ma100: f64,
current_series_end: Option<usize>,
extra_factors: BTreeMap<String, f64>, extra_factors: BTreeMap<String, f64>,
extra_text_factors: BTreeMap<String, String>, extra_text_factors: BTreeMap<String, String>,
} }
@@ -1044,6 +1045,7 @@ pub struct PlatformExprStrategy {
stock_filter_expr_present: bool, stock_filter_expr_present: bool,
selection_quote_usage: StockFilterQuoteUsage, selection_quote_usage: StockFilterQuoteUsage,
stock_rolling_requirements: StockRollingRequirements, stock_rolling_requirements: StockRollingRequirements,
stock_current_rolling_mean_required: bool,
stock_extra_factors_required: bool, stock_extra_factors_required: bool,
stock_extra_factor_identifiers: BTreeSet<String>, stock_extra_factor_identifiers: BTreeSet<String>,
stock_extra_factor_map_required: bool, stock_extra_factor_map_required: bool,
@@ -1321,6 +1323,8 @@ impl PlatformExprStrategy {
let selection_quote_usage = let selection_quote_usage =
Self::selection_quote_usage_for_config(&config, &normalized_stock_filter_expr); Self::selection_quote_usage_for_config(&config, &normalized_stock_filter_expr);
let stock_rolling_requirements = Self::stock_rolling_requirements_for_config(&config); 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 = let stock_extra_factors_required =
Self::stock_extra_factors_required_for_config(&config, &prelude_declared_identifiers); Self::stock_extra_factors_required_for_config(&config, &prelude_declared_identifiers);
let stock_extra_factor_identifiers = let stock_extra_factor_identifiers =
@@ -1363,6 +1367,7 @@ impl PlatformExprStrategy {
stock_filter_expr_present, stock_filter_expr_present,
selection_quote_usage, selection_quote_usage,
stock_rolling_requirements, stock_rolling_requirements,
stock_current_rolling_mean_required,
stock_extra_factors_required, stock_extra_factors_required,
stock_extra_factor_identifiers, stock_extra_factor_identifiers,
stock_extra_factor_map_required, stock_extra_factor_map_required,
@@ -3913,14 +3918,19 @@ impl PlatformExprStrategy {
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
date: NaiveDate, date: NaiveDate,
symbol_id: u32, stock: &StockExpressionState,
symbol: &str,
field: &str, field: &str,
lookback: usize, lookback: usize,
) -> Option<f64> { ) -> Option<f64> {
ctx.data.market_current_numeric_moving_average_by_symbol_id( ctx.data
date, symbol_id, symbol, field, lookback, .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( fn stock_state_at_time(
@@ -4065,6 +4075,13 @@ impl PlatformExprStrategy {
None None
}; };
let instrument = ctx.data.instrument_by_symbol_id(symbol_id); 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| { let required_rolling = |field: &'static str, lookback: usize| {
self.stock_rolling_requirements self.stock_rolling_requirements
.requires(field, lookback) .requires(field, lookback)
@@ -4258,6 +4275,7 @@ impl PlatformExprStrategy {
stock_volume_ma20, stock_volume_ma20,
stock_volume_ma60, stock_volume_ma60,
stock_volume_ma100, stock_volume_ma100,
current_series_end,
extra_factors, extra_factors,
extra_text_factors: if self.stock_text_factors_required { extra_text_factors: if self.stock_text_factors_required {
ctx.data ctx.data
@@ -7091,14 +7109,7 @@ impl PlatformExprStrategy {
"rolling_mean_current(\"{other}\", {lookback}) requires stock context" "rolling_mean_current(\"{other}\", {lookback}) requires stock context"
)) ))
})?; })?;
self.stock_current_rolling_mean( self.stock_current_rolling_mean(ctx, day.date, stock, other, lookback)
ctx,
day.date,
stock.symbol_id,
&stock.symbol,
other,
lookback,
)
} }
}; };
value.ok_or_else(|| { value.ok_or_else(|| {
@@ -9993,6 +10004,30 @@ impl PlatformExprStrategy {
requirements 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( fn stock_extra_factors_required_for_config(
config: &PlatformExprStrategyConfig, config: &PlatformExprStrategyConfig,
prelude_declared_identifiers: &BTreeSet<String>, prelude_declared_identifiers: &BTreeSet<String>,
@@ -14272,6 +14307,16 @@ mod tests {
assert!(!strategy.stock_rolling_requirements.requires("close", 10)); assert!(!strategy.stock_rolling_requirements.requires("close", 10));
assert!(!strategy.stock_rolling_requirements.requires("volume", 5)); assert!(!strategy.stock_rolling_requirements.requires("volume", 5));
assert!(!strategy.stock_rolling_requirements.requires("volume", 100)); 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] #[test]