统一复权滚动因子计算口径
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user