统一复权滚动因子计算口径

This commit is contained in:
boris
2026-08-24 02:51:58 +08:00
parent 9a7e5c7903
commit cea079a770
2 changed files with 197 additions and 243 deletions
+95 -38
View File
@@ -11,7 +11,6 @@ use crate::futures::FuturesTradingParameter;
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig}; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig};
mod date_format { mod date_format {
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer}; use serde::{self, Deserialize, Deserializer, Serializer};
@@ -575,6 +574,64 @@ impl AdjustedCloseSeries {
)) ))
} }
fn decision_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,
Err(0) => return None,
Err(index) => index,
};
if end < lookback {
return None;
}
let base_factor = self.backward_factors.get(end - 1).copied().flatten()?;
let start = end - lookback;
if self.missing_back_adjusted_close_prefix[end]
!= self.missing_back_adjusted_close_prefix[start]
{
return None;
}
let sum = self.back_adjusted_close_prefix[end] - self.back_adjusted_close_prefix[start];
if !sum.is_finite() {
return None;
}
Some(normalize_rolling_factor(
sum / lookback as f64 / base_factor,
12,
))
}
fn values(&self, date: NaiveDate, lookback: usize, include_now: bool) -> Vec<f64> {
if lookback == 0 {
return Vec::new();
}
let end = match self.dates.binary_search(&date) {
Ok(index) => index + usize::from(include_now),
Err(0) => return Vec::new(),
Err(index) => index,
};
if end == 0 {
return Vec::new();
}
let start = end.saturating_sub(lookback);
let Some(base_factor) = self.backward_factors.get(end - 1).copied().flatten() else {
return Vec::new();
};
self.back_adjusted_closes[start..end]
.iter()
.copied()
.collect::<Option<Vec<_>>>()
.map(|values| {
values
.into_iter()
.map(|value| normalize_rolling_factor(value / base_factor, 12))
.collect()
})
.unwrap_or_default()
}
fn latest_back_adjusted_close(&self, date: NaiveDate) -> Option<f64> { fn latest_back_adjusted_close(&self, date: NaiveDate) -> Option<f64> {
let index = match self.dates.binary_search(&date) { let index = match self.dates.binary_search(&date) {
Ok(index) => index, Ok(index) => index,
@@ -641,7 +698,10 @@ impl SymbolPriceSeries {
+ if valid { *volume as f64 } else { 0.0 }, + if valid { *volume as f64 } else { 0.0 },
); );
valid_volume_count_prefix.push( valid_volume_count_prefix.push(
valid_volume_count_prefix.last().copied().unwrap_or_default() valid_volume_count_prefix
.last()
.copied()
.unwrap_or_default()
+ usize::from(valid), + usize::from(valid),
); );
} }
@@ -781,40 +841,26 @@ impl SymbolPriceSeries {
Some(sum / lookback as f64) Some(sum / lookback as f64)
} }
fn decision_prev_close_values(&self, date: NaiveDate, lookback: usize) -> Option<Vec<f64>> {
if lookback == 0 {
return None;
}
let end = self.decision_end_index(date)?;
if end < lookback {
return None;
}
let start = end - lookback;
Some(self.prev_closes[start..end].to_vec())
}
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> { fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
let end = self.previous_completed_end_index(date)?; let end = self.previous_completed_end_index(date)?;
self.valid_volume_window(end, lookback) self.valid_volume_window(end, lookback).map(|(start, end)| {
.map(|(start, end)| { normalize_rolling_factor(
normalize_rolling_factor( (self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start])
(self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start]) / lookback as f64,
/ lookback as f64, 12,
12, )
) })
})
} }
fn current_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> { fn current_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
let end = self.end_index(date)?; let end = self.end_index(date)?;
self.valid_volume_window(end, lookback) self.valid_volume_window(end, lookback).map(|(start, end)| {
.map(|(start, end)| { normalize_rolling_factor(
normalize_rolling_factor( (self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start])
(self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start]) / lookback as f64,
/ lookback as f64, 12,
12, )
) })
})
} }
fn decision_volume_values(&self, date: NaiveDate, lookback: usize) -> Option<Vec<f64>> { fn decision_volume_values(&self, date: NaiveDate, lookback: usize) -> Option<Vec<f64>> {
@@ -2409,8 +2455,8 @@ impl DataSet {
let field = normalize_field(field); let field = normalize_field(field);
match field.as_str() { match field.as_str() {
"close" | "prev_close" | "stock_close" | "price" => self "close" | "prev_close" | "stock_close" | "price" => self
.market_series(symbol) .adjusted_close_series(symbol)
.and_then(|series| series.decision_close_moving_average(date, lookback)), .and_then(|series| series.decision_moving_average(date, lookback)),
"volume" | "stock_volume" => { "volume" | "stock_volume" => {
if !self.source_daily_volume_window_available(date, symbol, lookback, false) { if !self.source_daily_volume_window_available(date, symbol, lookback, false) {
None None
@@ -2482,8 +2528,8 @@ impl DataSet {
let field = normalize_field(field); let field = normalize_field(field);
match field.as_str() { match field.as_str() {
"close" | "prev_close" | "stock_close" | "price" => self "close" | "prev_close" | "stock_close" | "price" => self
.market_series(symbol) .adjusted_close_series(symbol)
.and_then(|series| series.decision_prev_close_values(date, lookback)) .map(|series| series.values(date, lookback, false))
.unwrap_or_default(), .unwrap_or_default(),
"volume" | "stock_volume" => { "volume" | "stock_volume" => {
if !self.source_daily_volume_window_available(date, symbol, lookback, false) { if !self.source_daily_volume_window_available(date, symbol, lookback, false) {
@@ -2523,6 +2569,15 @@ impl DataSet {
{ {
return Vec::new(); return Vec::new();
} }
if matches!(
field.as_str(),
"close" | "prev_close" | "stock_close" | "price"
) {
return self
.adjusted_close_series(symbol)
.map(|series| series.values(date, lookback, true))
.unwrap_or_default();
}
if matches!(field.as_str(), "volume" | "stock_volume") { if matches!(field.as_str(), "volume" | "stock_volume") {
return self return self
.market_series(symbol) .market_series(symbol)
@@ -3500,10 +3555,8 @@ mod tests {
.map(|(index, date)| { .map(|(index, date)| {
let mut extra_factors = BTreeMap::new(); let mut extra_factors = BTreeMap::new();
if let Some(values) = availability { if let Some(values) = availability {
extra_factors.insert( extra_factors
"source_daily_volume_available".to_string(), .insert("source_daily_volume_available".to_string(), values[index]);
values[index],
);
if values[index] >= 0.5 { if values[index] >= 0.5 {
extra_factors.insert("daily_volume".to_string(), volumes[index] as f64); extra_factors.insert("daily_volume".to_string(), volumes[index] as f64);
} }
@@ -3674,6 +3727,10 @@ mod tests {
data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3), data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3),
Some(5.5) Some(5.5)
); );
assert_eq!(
data.market_decision_numeric_moving_average(dates[2], "000001.SZ", "close", 2),
Some(10.5)
);
assert_ne!( assert_ne!(
data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3), data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3),
data.market_moving_average(dates[2], "000001.SZ", 3, PriceField::Close) data.market_moving_average(dates[2], "000001.SZ", 3, PriceField::Close)
+102 -205
View File
@@ -390,7 +390,6 @@ pub struct PlatformExprStrategyConfig {
pub matching_type: MatchingType, pub matching_type: MatchingType,
pub quote_quantity_limit: bool, pub quote_quantity_limit: bool,
pub current_day_precomputed_factors: bool, pub current_day_precomputed_factors: bool,
pub prefer_precomputed_rolling_factors: bool,
pub intraday_execution_time: Option<NaiveTime>, pub intraday_execution_time: Option<NaiveTime>,
pub delayed_limit_open_exit_enabled: bool, pub delayed_limit_open_exit_enabled: bool,
pub delayed_limit_open_exit_time: Option<NaiveTime>, pub delayed_limit_open_exit_time: Option<NaiveTime>,
@@ -468,7 +467,6 @@ fn band_low(index_close) {
matching_type: MatchingType::CurrentBarClose, matching_type: MatchingType::CurrentBarClose,
quote_quantity_limit: true, quote_quantity_limit: true,
current_day_precomputed_factors: false, current_day_precomputed_factors: false,
prefer_precomputed_rolling_factors: false,
intraday_execution_time: None, intraday_execution_time: None,
delayed_limit_open_exit_enabled: false, delayed_limit_open_exit_enabled: false,
delayed_limit_open_exit_time: None, delayed_limit_open_exit_time: None,
@@ -724,73 +722,27 @@ struct PositionExpressionState {
dividend_receivable: f64, dividend_receivable: f64,
} }
fn precomputed_stock_rolling_mean( fn framework_stock_rolling_factor_requirement(key: &str) -> Option<(&'static str, usize)> {
extra_factors: &BTreeMap<String, f64>, let key = key.trim().to_ascii_lowercase();
field: &str, let (field, raw_window) = if let Some(value) = key
lookback: usize, .strip_prefix("ma")
) -> Option<f64> { .and_then(|value| value.strip_suffix("_prev_close"))
if lookback == 0 { {
("close", value)
} else if let Some(value) = key.strip_prefix("ma") {
("close", value)
} else if let Some(value) = key.strip_prefix("vma") {
("volume", value)
} else if let Some(value) = key.strip_prefix("avg_volume") {
("volume", value)
} else {
return None; return None;
}
let value_for = |key: &str| {
extra_factors
.get(key)
.copied()
.filter(|value| value.is_finite())
}; };
match field.trim().to_ascii_lowercase().as_str() { raw_window
"close" | "prev_close" | "stock_close" | "price" => { .parse::<usize>()
let primary = format!("ma{lookback}_prev_close"); .ok()
let alias = format!("ma{lookback}"); .filter(|window| *window > 0)
value_for(&primary).or_else(|| value_for(&alias)) .map(|window| (field, window))
}
"volume" | "stock_volume" => {
let primary = format!("avg_volume{lookback}");
let alias = format!("vma{lookback}");
value_for(&primary).or_else(|| value_for(&alias))
}
_ => None,
}
}
fn precomputed_stock_current_rolling_mean(
extra_factors: &BTreeMap<String, f64>,
field: &str,
lookback: usize,
) -> Option<f64> {
if lookback == 0 {
return None;
}
let value_for = |key: &str| {
extra_factors
.get(key)
.copied()
.filter(|value| value.is_finite())
};
match field.trim().to_ascii_lowercase().as_str() {
"close" | "prev_close" | "stock_close" | "price" => {
// `rolling_mean_current("close", ...)` uses the framework's
// back-adjusted close series. Source Lake `maN_current_close` is
// calculated from raw close and is therefore not interchangeable.
value_for(&format!("ma{lookback}_current_back_adjusted_close"))
}
"volume" | "stock_volume" => value_for(&format!("avg_volume{lookback}_current")),
_ => None,
}
}
fn is_precomputed_stock_current_rolling_key(key: &str) -> bool {
fn has_numeric_window(key: &str, prefix: &str, suffix: &str) -> bool {
key.strip_prefix(prefix)
.and_then(|value| value.strip_suffix(suffix))
.is_some_and(|window| {
!window.is_empty() && window.bytes().all(|byte| byte.is_ascii_digit())
})
}
has_numeric_window(key, "ma", "_current_close")
|| has_numeric_window(key, "ma", "_current_back_adjusted_close")
|| has_numeric_window(key, "avg_volume", "_current")
} }
struct SelectiveExpressionScope<'a> { struct SelectiveExpressionScope<'a> {
@@ -3589,20 +3541,11 @@ impl PlatformExprStrategy {
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
date: NaiveDate, date: NaiveDate,
symbol: &str, symbol: &str,
extra_factors: &BTreeMap<String, f64>,
field: &str, field: &str,
lookback: usize, lookback: usize,
) -> Option<f64> { ) -> Option<f64> {
let precomputed = precomputed_stock_rolling_mean(extra_factors, field, lookback); ctx.data
let computed = || { .market_decision_numeric_moving_average(date, symbol, field, lookback)
ctx.data
.market_decision_numeric_moving_average(date, symbol, field, lookback)
};
if self.config.prefer_precomputed_rolling_factors {
precomputed.or_else(computed)
} else {
computed().or(precomputed)
}
} }
fn stock_current_rolling_mean( fn stock_current_rolling_mean(
@@ -3610,20 +3553,11 @@ impl PlatformExprStrategy {
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
date: NaiveDate, date: NaiveDate,
symbol: &str, symbol: &str,
extra_factors: &BTreeMap<String, f64>,
field: &str, field: &str,
lookback: usize, lookback: usize,
) -> Option<f64> { ) -> Option<f64> {
let precomputed = precomputed_stock_current_rolling_mean(extra_factors, field, lookback); ctx.data
let computed = || { .market_current_numeric_moving_average(date, symbol, field, lookback)
ctx.data
.market_current_numeric_moving_average(date, symbol, field, lookback)
};
if self.config.prefer_precomputed_rolling_factors {
precomputed.or_else(computed)
} else {
computed().or(precomputed)
}
} }
fn stock_state_at_time( fn stock_state_at_time(
@@ -3732,15 +3666,8 @@ impl PlatformExprStrategy {
if !self.stock_rolling_requirements.requires(field, lookback) { if !self.stock_rolling_requirements.requires(field, lookback) {
return f64::NAN; return f64::NAN;
} }
self.stock_decision_rolling_mean( self.stock_decision_rolling_mean(ctx, date, symbol, field, lookback)
ctx, .unwrap_or(f64::NAN)
date,
symbol,
&factor.extra_factors,
field,
lookback,
)
.unwrap_or(f64::NAN)
}; };
let stock_ma_short = rolling("close", self.config.stock_short_ma_days); let stock_ma_short = rolling("close", self.config.stock_short_ma_days);
let stock_ma_mid = rolling("close", self.config.stock_mid_ma_days); let stock_ma_mid = rolling("close", self.config.stock_mid_ma_days);
@@ -3858,20 +3785,7 @@ impl PlatformExprStrategy {
}; };
let extra_factors = if self.stock_extra_factors_required { let extra_factors = if self.stock_extra_factors_required {
let mut values = factor.extra_factors.clone(); factor.extra_factors.clone()
if date != factor_date {
values.retain(|key, _| !is_precomputed_stock_current_rolling_key(key));
if let Some(current_factor) = ctx.data.factor(date, symbol) {
values.extend(
current_factor
.extra_factors
.iter()
.filter(|(key, _)| is_precomputed_stock_current_rolling_key(key))
.map(|(key, value)| (key.clone(), *value)),
);
}
}
values
} else { } else {
BTreeMap::new() BTreeMap::new()
}; };
@@ -5202,6 +5116,26 @@ impl PlatformExprStrategy {
let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier( let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier(
args.first().map(String::as_str).unwrap_or_default(), args.first().map(String::as_str).unwrap_or_default(),
)?); )?);
if let Some((field, lookback)) = framework_stock_rolling_factor_requirement(&key) {
let stock = stock.ok_or_else(|| {
BacktestError::Execution(format!(
"factor(\"{key}\") requires stock context"
))
})?;
let value = self
.stock_decision_rolling_mean(ctx, day.date, &stock.symbol, field, lookback)
.ok_or_else(|| {
BacktestError::Execution(format!(
"missing framework rolling factor {key} for {} on {}",
stock.symbol, day.date
))
})?;
return Ok(Self::push_runtime_helper_value(
scope,
scope_name,
Dynamic::from(value),
));
}
Ok(format!("factors[{}]", Self::quote_rhai_string(&key))) Ok(format!("factors[{}]", Self::quote_rhai_string(&key)))
} }
"day_factor" => { "day_factor" => {
@@ -5846,14 +5780,7 @@ impl PlatformExprStrategy {
"rolling_mean(\"{other}\", {lookback}) requires stock context" "rolling_mean(\"{other}\", {lookback}) requires stock context"
)) ))
})?; })?;
self.stock_decision_rolling_mean( self.stock_decision_rolling_mean(ctx, day.date, &stock.symbol, other, lookback)
ctx,
day.date,
&stock.symbol,
&stock.extra_factors,
other,
lookback,
)
} }
}; };
value.ok_or_else(|| { value.ok_or_else(|| {
@@ -5895,14 +5822,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.symbol, other, lookback)
ctx,
day.date,
&stock.symbol,
&stock.extra_factors,
other,
lookback,
)
} }
}; };
value.ok_or_else(|| { value.ok_or_else(|| {
@@ -11078,7 +10998,7 @@ mod tests {
PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController, PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
PlatformTradeAction, PlatformUniverseActionKind, SelectionRiskDeferral, PlatformTradeAction, PlatformUniverseActionKind, SelectionRiskDeferral,
StockFilterQuoteUsage, precomputed_stock_rolling_mean, StockFilterQuoteUsage, framework_stock_rolling_factor_requirement,
}; };
use crate::{ use crate::{
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
@@ -11125,6 +11045,27 @@ mod tests {
); );
} }
#[test]
fn framework_rolling_factor_aliases_resolve_to_raw_market_series() {
assert_eq!(
framework_stock_rolling_factor_requirement("ma30"),
Some(("close", 30))
);
assert_eq!(
framework_stock_rolling_factor_requirement("ma30_prev_close"),
Some(("close", 30))
);
assert_eq!(
framework_stock_rolling_factor_requirement("vma100"),
Some(("volume", 100))
);
assert_eq!(
framework_stock_rolling_factor_requirement("avg_volume100"),
Some(("volume", 100))
);
assert_eq!(framework_stock_rolling_factor_requirement("alpha001"), None);
}
#[test] #[test]
fn platform_rebalance_keeps_unresolved_delisted_position_without_orders_or_replacement() { fn platform_rebalance_keeps_unresolved_delisted_position_without_orders_or_replacement() {
let previous_date = d(2025, 1, 2); let previous_date = d(2025, 1, 2);
@@ -17114,10 +17055,10 @@ mod tests {
} }
#[test] #[test]
fn platform_stock_expr_fast_path_handles_positive_volume_guard() { fn platform_stock_expr_handles_positive_volume_guard() {
let current = d(2023, 5, 4); let current = d(2023, 5, 4);
let symbol = "000153.SZ"; let symbol = "000153.SZ";
let build_data = |volume_ma5: f64, volume_ma100: f64| { let build_data = |volume: u64| {
DataSet::from_components( DataSet::from_components(
vec![Instrument { vec![Instrument {
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -17141,8 +17082,8 @@ mod tests {
bid1: 9.99, bid1: 9.99,
ask1: 10.01, ask1: 10.01,
prev_close: 9.9, prev_close: 9.9,
volume: 1_000, volume,
minute_volume: 1_000, minute_volume: volume,
bid1_volume: 2_000, bid1_volume: 2_000,
ask1_volume: 2_000, ask1_volume: 2_000,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
@@ -17159,13 +17100,7 @@ mod tests {
pe_ttm: 8.0, pe_ttm: 8.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::from([ extra_factors: BTreeMap::new(),
("ma5".to_string(), 11.0),
("ma10".to_string(), 10.0),
("ma30".to_string(), 9.0),
("avg_volume5".to_string(), volume_ma5),
("avg_volume100".to_string(), volume_ma100),
]),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date: current, date: current,
@@ -17193,12 +17128,10 @@ mod tests {
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.prefer_precomputed_rolling_factors = true; cfg.stock_filter_expr = "volume > 0".to_string();
cfg.prelude = "let ma_ratio = 1.00001; let max_volume_ratio = 1;".to_string();
cfg.stock_filter_expr = "rolling_mean(\"close\", 5) > rolling_mean(\"close\", 10) * ma_ratio && rolling_mean(\"close\", 10) > rolling_mean(\"close\", 30) * ma_ratio && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 100) * max_volume_ratio && rolling_mean(\"volume\", 5) > 0 && rolling_mean(\"volume\", 100) > 0".to_string();
for (volume_ma5, volume_ma100, expected) in [(50.0, 100.0, true), (0.0, 100.0, false)] { for (volume, expected) in [(50, true), (0, false)] {
let data = build_data(volume_ma5, volume_ma100); let data = build_data(volume);
let portfolio = PortfolioState::new(1_000_000.0); let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new(); let subscriptions = BTreeSet::new();
let ctx = StrategyContext { let ctx = StrategyContext {
@@ -17229,7 +17162,6 @@ mod tests {
.expect("stock expr"), .expect("stock expr"),
expected expected
); );
assert_eq!(strategy.ast_cache_misses(), 0);
} }
} }
@@ -21985,7 +21917,7 @@ mod tests {
end_time_expr: None, end_time_expr: None,
when_expr: Some( when_expr: Some(
concat!( concat!(
"ma(\"close\", 2) == 11.5", "ma(\"close\", 2) == 10.7",
" && vma(2) == 150.0", " && vma(2) == 150.0",
" && rolling_mean_current(\"close\", 2) == 11.7", " && rolling_mean_current(\"close\", 2) == 11.7",
" && rolling_mean_current(\"volume\", 2) == 250.0", " && rolling_mean_current(\"volume\", 2) == 250.0",
@@ -21993,8 +21925,8 @@ mod tests {
" && rolling_return_stddev_current(\"close\", 2) > 0.006", " && rolling_return_stddev_current(\"close\", 2) > 0.006",
" && rolling_return_stddev_current(\"close\", 2) < 0.007", " && rolling_return_stddev_current(\"close\", 2) < 0.007",
" && rolling_sum(\"volume\", 2) == 300.0", " && rolling_sum(\"volume\", 2) == 300.0",
" && rolling_min(\"close\", 2) == 11.0", " && rolling_min(\"close\", 2) == 10.2",
" && rolling_max(\"close\", 2) == 12.0", " && rolling_max(\"close\", 2) == 11.2",
" && stddev(\"close\", 2) > 0.49", " && stddev(\"close\", 2) > 0.49",
" && rolling_zscore(\"close\", 2) > 0.9", " && rolling_zscore(\"close\", 2) > 0.9",
" && pct_change(\"close\", 1) > 0.09", " && pct_change(\"close\", 1) > 0.09",
@@ -22054,7 +21986,7 @@ mod tests {
pe_ttm: 8.0, pe_ttm: 8.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::from([("adjustment_factor_backward1".to_string(), 1.0)]),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let candidate_rows = dates let candidate_rows = dates
@@ -28937,7 +28869,7 @@ mod tests {
} }
#[test] #[test]
fn platform_stock_state_can_prefer_precomputed_rolling_factors() { fn platform_stock_state_uses_framework_rolling_series() {
let dates = [ let dates = [
d(2025, 1, 2), d(2025, 1, 2),
d(2025, 1, 3), d(2025, 1, 3),
@@ -28949,12 +28881,6 @@ mod tests {
let date = dates[5]; let date = dates[5];
let symbol = "300001.SZ"; let symbol = "300001.SZ";
let mut extra_factors = BTreeMap::new(); let mut extra_factors = BTreeMap::new();
extra_factors.insert("ma5_prev_close".to_string(), 99.0);
extra_factors.insert("ma10_prev_close".to_string(), 98.0);
extra_factors.insert("ma30_prev_close".to_string(), 97.0);
extra_factors.insert("avg_volume5".to_string(), 88.0);
extra_factors.insert("avg_volume100".to_string(), 99.0);
extra_factors.insert("ma5_current_close".to_string(), 999.0);
extra_factors.insert("adjustment_factor_backward1".to_string(), 1.0); extra_factors.insert("adjustment_factor_backward1".to_string(), 1.0);
let data = DataSet::from_components( let data = DataSet::from_components(
vec![Instrument { vec![Instrument {
@@ -29052,36 +28978,36 @@ mod tests {
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.prefer_precomputed_rolling_factors = true; cfg.stock_filter_expr =
cfg.stock_filter_expr = "rolling_mean(\"close\", 5) > rolling_mean(\"close\", 10) && rolling_mean(\"close\", 10) > rolling_mean(\"close\", 30) && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 100)".to_string(); "rolling_mean(\"close\", 5) == 10.0 && rolling_mean(\"volume\", 5) == 1000.0"
.to_string();
let strategy = PlatformExprStrategy::new(cfg); let strategy = PlatformExprStrategy::new(cfg);
let stock = strategy let stock = strategy
.stock_state_with_factor_date(&ctx, date, date, symbol) .stock_state_with_factor_date(&ctx, date, date, symbol)
.expect("stock state"); .expect("stock state");
assert_eq!(stock.stock_ma5, 99.0); assert_eq!(stock.stock_ma5, 10.0);
assert_eq!(stock.stock_volume_ma5, 88.0); assert_eq!(stock.stock_volume_ma5, 1_000.0);
let day = strategy.day_state(&ctx, date).expect("day state"); let day = strategy.day_state(&ctx, date).expect("day state");
assert!( assert!(
strategy strategy
.stock_passes_expr(&ctx, &day, &stock) .stock_passes_expr(&ctx, &day, &stock)
.expect("precomputed decision rolling filter") .expect("framework decision rolling filter")
); );
assert_eq!( assert_eq!(
strategy strategy
.resolve_rolling_mean(&ctx, &day, Some(&stock), "close", 5) .resolve_rolling_mean(&ctx, &day, Some(&stock), "close", 5)
.expect("precomputed decision close rolling mean"), .expect("framework decision close rolling mean"),
99.0 10.0
); );
assert_eq!( assert_eq!(
strategy strategy
.resolve_rolling_mean(&ctx, &day, Some(&stock), "volume", 5) .resolve_rolling_mean(&ctx, &day, Some(&stock), "volume", 5)
.expect("precomputed decision volume rolling mean"), .expect("framework decision volume rolling mean"),
88.0 1_000.0
); );
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.prefer_precomputed_rolling_factors = true;
cfg.stock_filter_expr = "rolling_mean_current(\"close\", 5) == 10.0 && rolling_mean_current(\"volume\", 5) == 1000.0".to_string(); cfg.stock_filter_expr = "rolling_mean_current(\"close\", 5) == 10.0 && rolling_mean_current(\"volume\", 5) == 1000.0".to_string();
let strategy = PlatformExprStrategy::new(cfg); let strategy = PlatformExprStrategy::new(cfg);
let stock = strategy let stock = strategy
@@ -29221,8 +29147,7 @@ mod tests {
}; };
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.prefer_precomputed_rolling_factors = true; cfg.stock_filter_expr = "rolling_mean_current(\"close\", 1) == 10.0 && rolling_mean_current(\"volume\", 1) == 1000.0".to_string();
cfg.stock_filter_expr = "rolling_mean_current(\"close\", 5) == 20.0 && rolling_mean_current(\"volume\", 5) == 2000.0".to_string();
let strategy = PlatformExprStrategy::new(cfg); let strategy = PlatformExprStrategy::new(cfg);
let stock = strategy let stock = strategy
.stock_state_with_factor_date(&ctx, decision_date, factor_date, symbol) .stock_state_with_factor_date(&ctx, decision_date, factor_date, symbol)
@@ -29236,20 +29161,20 @@ mod tests {
); );
assert_eq!( assert_eq!(
strategy strategy
.resolve_current_rolling_mean(&ctx, &day, Some(&stock), "close", 5) .resolve_current_rolling_mean(&ctx, &day, Some(&stock), "close", 1)
.expect("current close rolling mean"), .expect("current close rolling mean"),
20.0 10.0
); );
assert_eq!( assert_eq!(
strategy strategy
.resolve_current_rolling_mean(&ctx, &day, Some(&stock), "volume", 5) .resolve_current_rolling_mean(&ctx, &day, Some(&stock), "volume", 1)
.expect("current volume rolling mean"), .expect("current volume rolling mean"),
2_000.0 1_000.0
); );
} }
#[test] #[test]
fn platform_stock_state_falls_back_when_precomputed_rolling_is_missing() { fn platform_stock_state_uses_market_series_when_factor_map_has_no_rolling() {
let current = d(2025, 5, 30); let current = d(2025, 5, 30);
let start = current - chrono::Duration::days(100); let start = current - chrono::Duration::days(100);
let symbol = "300022.SZ"; let symbol = "300022.SZ";
@@ -29350,7 +29275,6 @@ mod tests {
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = symbol.to_string(); cfg.signal_symbol = symbol.to_string();
cfg.prefer_precomputed_rolling_factors = true;
cfg.stock_filter_expr = "rolling_mean(\"volume\", 100) > 0".to_string(); cfg.stock_filter_expr = "rolling_mean(\"volume\", 100) > 0".to_string();
let strategy = PlatformExprStrategy::new(cfg.clone()); let strategy = PlatformExprStrategy::new(cfg.clone());
let stock = strategy let stock = strategy
@@ -29364,7 +29288,6 @@ mod tests {
.expect("stock expr") .expect("stock expr")
); );
cfg.prefer_precomputed_rolling_factors = false;
let strategy = PlatformExprStrategy::new(cfg); let strategy = PlatformExprStrategy::new(cfg);
let stock = strategy let stock = strategy
.stock_state_with_factor_date(&ctx, current, current, symbol) .stock_state_with_factor_date(&ctx, current, current, symbol)
@@ -29373,41 +29296,15 @@ mod tests {
} }
#[test] #[test]
fn precomputed_rolling_mean_ignores_strategy_specific_v104_labels() { fn strategy_specific_labels_are_not_framework_rolling_factors() {
let mut extra_factors = BTreeMap::new();
extra_factors.insert("sf_jq_v104_ma5".to_string(), 99.0);
extra_factors.insert("sf_jq_v104_v100".to_string(), 88.0);
assert_eq!( assert_eq!(
precomputed_stock_rolling_mean(&extra_factors, "close", 5), framework_stock_rolling_factor_requirement("sf_jq_v104_ma5"),
None None
); );
assert_eq!( assert_eq!(
precomputed_stock_rolling_mean(&extra_factors, "volume", 100), framework_stock_rolling_factor_requirement("sf_jq_v104_v100"),
None None
); );
extra_factors.insert("ma5_prev_close".to_string(), 10.5);
extra_factors.insert("ma40_prev_close".to_string(), 11.5);
extra_factors.insert("avg_volume100".to_string(), 120_000.0);
extra_factors.insert("avg_volume40".to_string(), 40_000.0);
assert_eq!(
precomputed_stock_rolling_mean(&extra_factors, "close", 5),
Some(10.5)
);
assert_eq!(
precomputed_stock_rolling_mean(&extra_factors, "close", 40),
Some(11.5)
);
assert_eq!(
precomputed_stock_rolling_mean(&extra_factors, "volume", 100),
Some(120_000.0)
);
assert_eq!(
precomputed_stock_rolling_mean(&extra_factors, "volume", 40),
Some(40_000.0)
);
} }
#[test] #[test]