From 8f098e4da19841785c0b3328efdcb7699104a0f5 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 17 Jul 2026 09:47:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=82=A1=E7=A5=A8=E6=97=A5?= =?UTF-8?q?=E7=BA=BF=E5=A4=8D=E6=9D=83=E6=BB=9A=E5=8A=A8=E5=9B=A0=E5=AD=90?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 208 +++++++++++++++++- .../fidc-core/src/platform_expr_strategy.rs | 10 +- 2 files changed, 211 insertions(+), 7 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 1433342..1ce6963 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -486,6 +486,76 @@ struct SymbolPriceSeries { volume_prefix: Vec, } +#[derive(Debug, Clone)] +struct AdjustedCloseSeries { + dates: Vec, + backward_factors: Vec, + back_adjusted_closes: Vec, +} + +impl AdjustedCloseSeries { + fn new( + market: &SymbolPriceSeries, + factor_by_date: &BTreeMap>>, + ) -> Option { + let mut backward_factors = Vec::with_capacity(market.dates.len()); + let mut back_adjusted_closes = Vec::with_capacity(market.dates.len()); + for (date, close) in market.dates.iter().zip(&market.closes) { + if !close.is_finite() || *close <= 0.0 { + return None; + } + let factor = factor_by_date + .get(date) + .and_then(|rows| { + find_arc_by_symbol(rows, &market.symbol, |row| row.symbol.as_str()) + }) + .and_then(|snapshot| { + factor_numeric_value(snapshot, "adjustment_factor_backward1") + })?; + if !factor.is_finite() || factor <= 0.0 { + return None; + } + backward_factors.push(factor); + back_adjusted_closes.push(close * factor); + } + Some(Self { + dates: market.dates.clone(), + backward_factors, + back_adjusted_closes, + }) + } + + fn current_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { + if lookback == 0 { + return None; + } + let end = match self.dates.binary_search(&date) { + Ok(index) => index + 1, + Err(0) => return None, + Err(index) => index, + }; + if end < lookback { + return None; + } + let base_factor = *self.backward_factors.get(end - 1)?; + if !base_factor.is_finite() || base_factor <= 0.0 { + return None; + } + let start = end - lookback; + let sum = self.back_adjusted_closes[start..end] + .iter() + .copied() + .sum::(); + if !sum.is_finite() { + return None; + } + Some(normalize_rolling_factor( + sum / lookback as f64 / base_factor, + 12, + )) + } +} + impl SymbolPriceSeries { fn new<'a, I>(symbol: String, rows: I) -> Self where @@ -701,8 +771,14 @@ impl SymbolPriceSeries { return None; } let start = end - lookback; - let sum = self.volume_prefix[end] - self.volume_prefix[start]; - Some(sum / lookback as f64) + let sum = self.volumes[start..end] + .iter() + .map(|value| *value as f64) + .sum::(); + if !sum.is_finite() { + return None; + } + Some(normalize_rolling_factor(sum / lookback as f64, 6)) } fn decision_volume_values(&self, date: NaiveDate, lookback: usize) -> Option> { @@ -948,6 +1024,7 @@ pub struct DataSet { order_book_depth_index: HashMap<(NaiveDate, String), Vec>, benchmark_by_date: BTreeMap, market_series_by_symbol: Arc>>>, + adjusted_close_series_by_symbol: Arc>>>, benchmark_series_cache: BenchmarkPriceSeries, eligible_universe_by_date: Arc>>>, benchmark_code: String, @@ -1158,6 +1235,7 @@ impl DataSet { order_book_depth_index, benchmark_by_date, market_series_by_symbol: Arc::new(RwLock::new(HashMap::new())), + adjusted_close_series_by_symbol: Arc::new(RwLock::new(HashMap::new())), benchmark_series_cache, eligible_universe_by_date: Arc::new(OnceLock::new()), benchmark_code, @@ -1241,6 +1319,31 @@ impl DataSet { ) } + fn adjusted_close_series(&self, symbol: &str) -> Option> { + if let Some(series) = self + .adjusted_close_series_by_symbol + .read() + .expect("adjusted close series cache lock poisoned") + .get(symbol) + .cloned() + { + return Some(series); + } + + let market = self.market_series(symbol)?; + let series = Arc::new(AdjustedCloseSeries::new(&market, &self.factor_by_date)?); + let mut cache = self + .adjusted_close_series_by_symbol + .write() + .expect("adjusted close series cache lock poisoned"); + Some( + cache + .entry(symbol.to_string()) + .or_insert_with(|| Arc::clone(&series)) + .clone(), + ) + } + pub fn factor(&self, date: NaiveDate, symbol: &str) -> Option<&DailyFactorSnapshot> { self.factor_by_date .get(&date) @@ -2308,9 +2411,9 @@ impl DataSet { ) -> Option { let field = normalize_field(field); match field.as_str() { - "close" | "prev_close" | "stock_close" | "price" => { - self.market_moving_average(date, symbol, lookback, PriceField::Close) - } + "close" | "prev_close" | "stock_close" | "price" => self + .adjusted_close_series(symbol) + .and_then(|series| series.current_moving_average(date, lookback)), "volume" | "stock_volume" => self .market_series(symbol) .and_then(|series| series.current_volume_moving_average(date, lookback)) @@ -2908,6 +3011,11 @@ fn prefix_sums(values: &[f64]) -> Vec { prefix } +fn normalize_rolling_factor(value: f64, decimals: i32) -> f64 { + let scale = 10_f64.powi(decimals); + (value * scale).round() / scale +} + mod optional_date_format { use chrono::NaiveDate; use serde::{self, Deserialize, Deserializer, Serializer}; @@ -3307,6 +3415,96 @@ mod tests { ); } + #[test] + fn current_close_average_uses_backward_adjustment_factor_and_current_base() { + let dates = [ + NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(), + NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(), + NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), + ]; + let factors = [1.0, 1.0, 2.0]; + let closes = [10.0, 11.0, 6.0]; + 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: Some(dates[0]), + delisted_at: None, + status: "active".to_string(), + }], + dates + .iter() + .zip(closes) + .map(|(date, close)| market_row(&date.format("%Y-%m-%d").to_string(), close, 100)) + .collect(), + dates + .iter() + .zip(factors) + .map(|(date, factor)| 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: BTreeMap::from([( + "adjustment_factor_backward1".to_string(), + factor, + )]), + }) + .collect(), + Vec::new(), + dates + .iter() + .map(|date| BenchmarkSnapshot { + date: *date, + benchmark: "000852.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 100.0, + volume: 1_000_000, + }) + .collect(), + ) + .expect("dataset"); + + assert_eq!( + data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3), + Some(5.5) + ); + assert_ne!( + data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3), + data.market_moving_average(dates[2], "000001.SZ", 3, PriceField::Close) + ); + } + + #[test] + fn adjusted_close_average_normalization_prevents_strict_crossover_drift() { + let pattern = [ + 2.953, 1.093, 2.717, 1.579, 1.289, 1.236, 1.617, 2.632, 1.361, 2.163, + ]; + let start = NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap(); + let values = (0..30) + .map(|index| pattern[index % pattern.len()]) + .collect::>(); + let series = AdjustedCloseSeries { + dates: (0..30) + .map(|index| start + chrono::Duration::days(index as i64)) + .collect(), + backward_factors: vec![1.0; 30], + back_adjusted_closes: values, + }; + let date = *series.dates.last().expect("last date"); + + assert_eq!( + series.current_moving_average(date, 10), + series.current_moving_average(date, 30) + ); + } + #[test] fn decision_volume_average_includes_paused_zero_volume_days() { let mut paused = market_row("2025-01-03", 11.0, 0); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index e270330..3679f17 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -20916,7 +20916,10 @@ mod tests { pe_ttm: 8.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), - extra_factors: BTreeMap::from([("Mixed_Factor".to_string(), index as f64 + 5.0)]), + extra_factors: BTreeMap::from([ + ("Mixed_Factor".to_string(), index as f64 + 5.0), + ("adjustment_factor_backward1".to_string(), 1.0), + ]), }) .collect::>(); let candidate_rows = dates @@ -30069,7 +30072,10 @@ mod tests { pe_ttm: 8.0, turnover_ratio: Some(22.0), effective_turnover_ratio: Some(18.0), - extra_factors: BTreeMap::new(), + extra_factors: BTreeMap::from([( + "adjustment_factor_backward1".to_string(), + 1.0, + )]), }) .collect(), dates