修正股票日线复权滚动因子口径

This commit is contained in:
boris
2026-07-17 09:47:47 +08:00
parent a734cbeaec
commit 8f098e4da1
2 changed files with 211 additions and 7 deletions
+203 -5
View File
@@ -486,6 +486,76 @@ struct SymbolPriceSeries {
volume_prefix: Vec<f64>,
}
#[derive(Debug, Clone)]
struct AdjustedCloseSeries {
dates: Vec<NaiveDate>,
backward_factors: Vec<f64>,
back_adjusted_closes: Vec<f64>,
}
impl AdjustedCloseSeries {
fn new(
market: &SymbolPriceSeries,
factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
) -> Option<Self> {
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<f64> {
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::<f64>();
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::<f64>();
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<Vec<f64>> {
@@ -948,6 +1024,7 @@ pub struct DataSet {
order_book_depth_index: HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>,
benchmark_by_date: BTreeMap<NaiveDate, BenchmarkSnapshot>,
market_series_by_symbol: Arc<RwLock<HashMap<String, Arc<SymbolPriceSeries>>>>,
adjusted_close_series_by_symbol: Arc<RwLock<HashMap<String, Arc<AdjustedCloseSeries>>>>,
benchmark_series_cache: BenchmarkPriceSeries,
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
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<Arc<AdjustedCloseSeries>> {
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<f64> {
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<f64> {
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::<Vec<_>>();
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);