统一复权滚动因子计算口径
This commit is contained in:
@@ -11,7 +11,6 @@ use crate::futures::FuturesTradingParameter;
|
||||
use crate::instrument::Instrument;
|
||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig};
|
||||
|
||||
|
||||
mod date_format {
|
||||
use chrono::NaiveDate;
|
||||
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> {
|
||||
let index = match self.dates.binary_search(&date) {
|
||||
Ok(index) => index,
|
||||
@@ -641,7 +698,10 @@ impl SymbolPriceSeries {
|
||||
+ if valid { *volume as f64 } else { 0.0 },
|
||||
);
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -781,40 +841,26 @@ impl SymbolPriceSeries {
|
||||
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> {
|
||||
let end = self.previous_completed_end_index(date)?;
|
||||
self.valid_volume_window(end, lookback)
|
||||
.map(|(start, end)| {
|
||||
normalize_rolling_factor(
|
||||
(self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start])
|
||||
/ lookback as f64,
|
||||
12,
|
||||
)
|
||||
})
|
||||
self.valid_volume_window(end, lookback).map(|(start, end)| {
|
||||
normalize_rolling_factor(
|
||||
(self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start])
|
||||
/ lookback as f64,
|
||||
12,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn current_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
||||
let end = self.end_index(date)?;
|
||||
self.valid_volume_window(end, lookback)
|
||||
.map(|(start, end)| {
|
||||
normalize_rolling_factor(
|
||||
(self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start])
|
||||
/ lookback as f64,
|
||||
12,
|
||||
)
|
||||
})
|
||||
self.valid_volume_window(end, lookback).map(|(start, end)| {
|
||||
normalize_rolling_factor(
|
||||
(self.valid_volume_sum_prefix[end] - self.valid_volume_sum_prefix[start])
|
||||
/ lookback as f64,
|
||||
12,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn decision_volume_values(&self, date: NaiveDate, lookback: usize) -> Option<Vec<f64>> {
|
||||
@@ -2409,8 +2455,8 @@ impl DataSet {
|
||||
let field = normalize_field(field);
|
||||
match field.as_str() {
|
||||
"close" | "prev_close" | "stock_close" | "price" => self
|
||||
.market_series(symbol)
|
||||
.and_then(|series| series.decision_close_moving_average(date, lookback)),
|
||||
.adjusted_close_series(symbol)
|
||||
.and_then(|series| series.decision_moving_average(date, lookback)),
|
||||
"volume" | "stock_volume" => {
|
||||
if !self.source_daily_volume_window_available(date, symbol, lookback, false) {
|
||||
None
|
||||
@@ -2482,8 +2528,8 @@ impl DataSet {
|
||||
let field = normalize_field(field);
|
||||
match field.as_str() {
|
||||
"close" | "prev_close" | "stock_close" | "price" => self
|
||||
.market_series(symbol)
|
||||
.and_then(|series| series.decision_prev_close_values(date, lookback))
|
||||
.adjusted_close_series(symbol)
|
||||
.map(|series| series.values(date, lookback, false))
|
||||
.unwrap_or_default(),
|
||||
"volume" | "stock_volume" => {
|
||||
if !self.source_daily_volume_window_available(date, symbol, lookback, false) {
|
||||
@@ -2523,6 +2569,15 @@ impl DataSet {
|
||||
{
|
||||
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") {
|
||||
return self
|
||||
.market_series(symbol)
|
||||
@@ -3500,10 +3555,8 @@ mod tests {
|
||||
.map(|(index, date)| {
|
||||
let mut extra_factors = BTreeMap::new();
|
||||
if let Some(values) = availability {
|
||||
extra_factors.insert(
|
||||
"source_daily_volume_available".to_string(),
|
||||
values[index],
|
||||
);
|
||||
extra_factors
|
||||
.insert("source_daily_volume_available".to_string(), values[index]);
|
||||
if values[index] >= 0.5 {
|
||||
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),
|
||||
Some(5.5)
|
||||
);
|
||||
assert_eq!(
|
||||
data.market_decision_numeric_moving_average(dates[2], "000001.SZ", "close", 2),
|
||||
Some(10.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)
|
||||
|
||||
@@ -390,7 +390,6 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub matching_type: MatchingType,
|
||||
pub quote_quantity_limit: bool,
|
||||
pub current_day_precomputed_factors: bool,
|
||||
pub prefer_precomputed_rolling_factors: bool,
|
||||
pub intraday_execution_time: Option<NaiveTime>,
|
||||
pub delayed_limit_open_exit_enabled: bool,
|
||||
pub delayed_limit_open_exit_time: Option<NaiveTime>,
|
||||
@@ -468,7 +467,6 @@ fn band_low(index_close) {
|
||||
matching_type: MatchingType::CurrentBarClose,
|
||||
quote_quantity_limit: true,
|
||||
current_day_precomputed_factors: false,
|
||||
prefer_precomputed_rolling_factors: false,
|
||||
intraday_execution_time: None,
|
||||
delayed_limit_open_exit_enabled: false,
|
||||
delayed_limit_open_exit_time: None,
|
||||
@@ -724,73 +722,27 @@ struct PositionExpressionState {
|
||||
dividend_receivable: f64,
|
||||
}
|
||||
|
||||
fn precomputed_stock_rolling_mean(
|
||||
extra_factors: &BTreeMap<String, f64>,
|
||||
field: &str,
|
||||
lookback: usize,
|
||||
) -> Option<f64> {
|
||||
if lookback == 0 {
|
||||
fn framework_stock_rolling_factor_requirement(key: &str) -> Option<(&'static str, usize)> {
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let (field, raw_window) = if let Some(value) = key
|
||||
.strip_prefix("ma")
|
||||
.and_then(|value| value.strip_suffix("_prev_close"))
|
||||
{
|
||||
("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;
|
||||
}
|
||||
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" => {
|
||||
let primary = format!("ma{lookback}_prev_close");
|
||||
let alias = format!("ma{lookback}");
|
||||
value_for(&primary).or_else(|| value_for(&alias))
|
||||
}
|
||||
"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")
|
||||
raw_window
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|window| *window > 0)
|
||||
.map(|window| (field, window))
|
||||
}
|
||||
|
||||
struct SelectiveExpressionScope<'a> {
|
||||
@@ -3589,20 +3541,11 @@ impl PlatformExprStrategy {
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
extra_factors: &BTreeMap<String, f64>,
|
||||
field: &str,
|
||||
lookback: usize,
|
||||
) -> Option<f64> {
|
||||
let precomputed = precomputed_stock_rolling_mean(extra_factors, field, lookback);
|
||||
let computed = || {
|
||||
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)
|
||||
}
|
||||
ctx.data
|
||||
.market_decision_numeric_moving_average(date, symbol, field, lookback)
|
||||
}
|
||||
|
||||
fn stock_current_rolling_mean(
|
||||
@@ -3610,20 +3553,11 @@ impl PlatformExprStrategy {
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
extra_factors: &BTreeMap<String, f64>,
|
||||
field: &str,
|
||||
lookback: usize,
|
||||
) -> Option<f64> {
|
||||
let precomputed = precomputed_stock_current_rolling_mean(extra_factors, field, lookback);
|
||||
let computed = || {
|
||||
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)
|
||||
}
|
||||
ctx.data
|
||||
.market_current_numeric_moving_average(date, symbol, field, lookback)
|
||||
}
|
||||
|
||||
fn stock_state_at_time(
|
||||
@@ -3732,15 +3666,8 @@ impl PlatformExprStrategy {
|
||||
if !self.stock_rolling_requirements.requires(field, lookback) {
|
||||
return f64::NAN;
|
||||
}
|
||||
self.stock_decision_rolling_mean(
|
||||
ctx,
|
||||
date,
|
||||
symbol,
|
||||
&factor.extra_factors,
|
||||
field,
|
||||
lookback,
|
||||
)
|
||||
.unwrap_or(f64::NAN)
|
||||
self.stock_decision_rolling_mean(ctx, date, symbol, field, lookback)
|
||||
.unwrap_or(f64::NAN)
|
||||
};
|
||||
let stock_ma_short = rolling("close", self.config.stock_short_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 mut values = 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
|
||||
factor.extra_factors.clone()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
@@ -5202,6 +5116,26 @@ impl PlatformExprStrategy {
|
||||
let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier(
|
||||
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)))
|
||||
}
|
||||
"day_factor" => {
|
||||
@@ -5846,14 +5780,7 @@ impl PlatformExprStrategy {
|
||||
"rolling_mean(\"{other}\", {lookback}) requires stock context"
|
||||
))
|
||||
})?;
|
||||
self.stock_decision_rolling_mean(
|
||||
ctx,
|
||||
day.date,
|
||||
&stock.symbol,
|
||||
&stock.extra_factors,
|
||||
other,
|
||||
lookback,
|
||||
)
|
||||
self.stock_decision_rolling_mean(ctx, day.date, &stock.symbol, other, lookback)
|
||||
}
|
||||
};
|
||||
value.ok_or_else(|| {
|
||||
@@ -5895,14 +5822,7 @@ impl PlatformExprStrategy {
|
||||
"rolling_mean_current(\"{other}\", {lookback}) requires stock context"
|
||||
))
|
||||
})?;
|
||||
self.stock_current_rolling_mean(
|
||||
ctx,
|
||||
day.date,
|
||||
&stock.symbol,
|
||||
&stock.extra_factors,
|
||||
other,
|
||||
lookback,
|
||||
)
|
||||
self.stock_current_rolling_mean(ctx, day.date, &stock.symbol, other, lookback)
|
||||
}
|
||||
};
|
||||
value.ok_or_else(|| {
|
||||
@@ -11078,7 +10998,7 @@ mod tests {
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController,
|
||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
|
||||
PlatformTradeAction, PlatformUniverseActionKind, SelectionRiskDeferral,
|
||||
StockFilterQuoteUsage, precomputed_stock_rolling_mean,
|
||||
StockFilterQuoteUsage, framework_stock_rolling_factor_requirement,
|
||||
};
|
||||
use crate::{
|
||||
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]
|
||||
fn platform_rebalance_keeps_unresolved_delisted_position_without_orders_or_replacement() {
|
||||
let previous_date = d(2025, 1, 2);
|
||||
@@ -17114,10 +17055,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 symbol = "000153.SZ";
|
||||
let build_data = |volume_ma5: f64, volume_ma100: f64| {
|
||||
let build_data = |volume: u64| {
|
||||
DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
@@ -17141,8 +17082,8 @@ mod tests {
|
||||
bid1: 9.99,
|
||||
ask1: 10.01,
|
||||
prev_close: 9.9,
|
||||
volume: 1_000,
|
||||
minute_volume: 1_000,
|
||||
volume,
|
||||
minute_volume: volume,
|
||||
bid1_volume: 2_000,
|
||||
ask1_volume: 2_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
@@ -17159,13 +17100,7 @@ mod tests {
|
||||
pe_ttm: 8.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::from([
|
||||
("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),
|
||||
]),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date: current,
|
||||
@@ -17193,12 +17128,10 @@ mod tests {
|
||||
};
|
||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
cfg.signal_symbol = symbol.to_string();
|
||||
cfg.prefer_precomputed_rolling_factors = true;
|
||||
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();
|
||||
cfg.stock_filter_expr = "volume > 0".to_string();
|
||||
|
||||
for (volume_ma5, volume_ma100, expected) in [(50.0, 100.0, true), (0.0, 100.0, false)] {
|
||||
let data = build_data(volume_ma5, volume_ma100);
|
||||
for (volume, expected) in [(50, true), (0, false)] {
|
||||
let data = build_data(volume);
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
@@ -17229,7 +17162,6 @@ mod tests {
|
||||
.expect("stock expr"),
|
||||
expected
|
||||
);
|
||||
assert_eq!(strategy.ast_cache_misses(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21985,7 +21917,7 @@ mod tests {
|
||||
end_time_expr: None,
|
||||
when_expr: Some(
|
||||
concat!(
|
||||
"ma(\"close\", 2) == 11.5",
|
||||
"ma(\"close\", 2) == 10.7",
|
||||
" && vma(2) == 150.0",
|
||||
" && rolling_mean_current(\"close\", 2) == 11.7",
|
||||
" && 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.007",
|
||||
" && rolling_sum(\"volume\", 2) == 300.0",
|
||||
" && rolling_min(\"close\", 2) == 11.0",
|
||||
" && rolling_max(\"close\", 2) == 12.0",
|
||||
" && rolling_min(\"close\", 2) == 10.2",
|
||||
" && rolling_max(\"close\", 2) == 11.2",
|
||||
" && stddev(\"close\", 2) > 0.49",
|
||||
" && rolling_zscore(\"close\", 2) > 0.9",
|
||||
" && pct_change(\"close\", 1) > 0.09",
|
||||
@@ -22054,7 +21986,7 @@ mod tests {
|
||||
pe_ttm: 8.0,
|
||||
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<_>>();
|
||||
let candidate_rows = dates
|
||||
@@ -28937,7 +28869,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_stock_state_can_prefer_precomputed_rolling_factors() {
|
||||
fn platform_stock_state_uses_framework_rolling_series() {
|
||||
let dates = [
|
||||
d(2025, 1, 2),
|
||||
d(2025, 1, 3),
|
||||
@@ -28949,12 +28881,6 @@ mod tests {
|
||||
let date = dates[5];
|
||||
let symbol = "300001.SZ";
|
||||
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);
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
@@ -29052,36 +28978,36 @@ mod tests {
|
||||
};
|
||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
cfg.signal_symbol = symbol.to_string();
|
||||
cfg.prefer_precomputed_rolling_factors = true;
|
||||
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();
|
||||
cfg.stock_filter_expr =
|
||||
"rolling_mean(\"close\", 5) == 10.0 && rolling_mean(\"volume\", 5) == 1000.0"
|
||||
.to_string();
|
||||
let strategy = PlatformExprStrategy::new(cfg);
|
||||
let stock = strategy
|
||||
.stock_state_with_factor_date(&ctx, date, date, symbol)
|
||||
.expect("stock state");
|
||||
assert_eq!(stock.stock_ma5, 99.0);
|
||||
assert_eq!(stock.stock_volume_ma5, 88.0);
|
||||
assert_eq!(stock.stock_ma5, 10.0);
|
||||
assert_eq!(stock.stock_volume_ma5, 1_000.0);
|
||||
let day = strategy.day_state(&ctx, date).expect("day state");
|
||||
assert!(
|
||||
strategy
|
||||
.stock_passes_expr(&ctx, &day, &stock)
|
||||
.expect("precomputed decision rolling filter")
|
||||
.expect("framework decision rolling filter")
|
||||
);
|
||||
assert_eq!(
|
||||
strategy
|
||||
.resolve_rolling_mean(&ctx, &day, Some(&stock), "close", 5)
|
||||
.expect("precomputed decision close rolling mean"),
|
||||
99.0
|
||||
.expect("framework decision close rolling mean"),
|
||||
10.0
|
||||
);
|
||||
assert_eq!(
|
||||
strategy
|
||||
.resolve_rolling_mean(&ctx, &day, Some(&stock), "volume", 5)
|
||||
.expect("precomputed decision volume rolling mean"),
|
||||
88.0
|
||||
.expect("framework decision volume rolling mean"),
|
||||
1_000.0
|
||||
);
|
||||
|
||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
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();
|
||||
let strategy = PlatformExprStrategy::new(cfg);
|
||||
let stock = strategy
|
||||
@@ -29221,8 +29147,7 @@ mod tests {
|
||||
};
|
||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
cfg.signal_symbol = symbol.to_string();
|
||||
cfg.prefer_precomputed_rolling_factors = true;
|
||||
cfg.stock_filter_expr = "rolling_mean_current(\"close\", 5) == 20.0 && rolling_mean_current(\"volume\", 5) == 2000.0".to_string();
|
||||
cfg.stock_filter_expr = "rolling_mean_current(\"close\", 1) == 10.0 && rolling_mean_current(\"volume\", 1) == 1000.0".to_string();
|
||||
let strategy = PlatformExprStrategy::new(cfg);
|
||||
let stock = strategy
|
||||
.stock_state_with_factor_date(&ctx, decision_date, factor_date, symbol)
|
||||
@@ -29236,20 +29161,20 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
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"),
|
||||
20.0
|
||||
10.0
|
||||
);
|
||||
assert_eq!(
|
||||
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"),
|
||||
2_000.0
|
||||
1_000.0
|
||||
);
|
||||
}
|
||||
|
||||
#[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 start = current - chrono::Duration::days(100);
|
||||
let symbol = "300022.SZ";
|
||||
@@ -29350,7 +29275,6 @@ mod tests {
|
||||
|
||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
cfg.signal_symbol = symbol.to_string();
|
||||
cfg.prefer_precomputed_rolling_factors = true;
|
||||
cfg.stock_filter_expr = "rolling_mean(\"volume\", 100) > 0".to_string();
|
||||
let strategy = PlatformExprStrategy::new(cfg.clone());
|
||||
let stock = strategy
|
||||
@@ -29364,7 +29288,6 @@ mod tests {
|
||||
.expect("stock expr")
|
||||
);
|
||||
|
||||
cfg.prefer_precomputed_rolling_factors = false;
|
||||
let strategy = PlatformExprStrategy::new(cfg);
|
||||
let stock = strategy
|
||||
.stock_state_with_factor_date(&ctx, current, current, symbol)
|
||||
@@ -29373,41 +29296,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precomputed_rolling_mean_ignores_strategy_specific_v104_labels() {
|
||||
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);
|
||||
|
||||
fn strategy_specific_labels_are_not_framework_rolling_factors() {
|
||||
assert_eq!(
|
||||
precomputed_stock_rolling_mean(&extra_factors, "close", 5),
|
||||
framework_stock_rolling_factor_requirement("sf_jq_v104_ma5"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
precomputed_stock_rolling_mean(&extra_factors, "volume", 100),
|
||||
framework_stock_rolling_factor_requirement("sf_jq_v104_v100"),
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user