完善策略调度执行价校验

This commit is contained in:
boris
2026-06-13 15:26:56 +08:00
parent 4cf90d83a3
commit 0dca8e0eff
5 changed files with 744 additions and 51 deletions
+229 -16
View File
@@ -445,6 +445,38 @@ pub struct EligibleUniverseSnapshot {
pub free_float_cap_bn: f64, pub free_float_cap_bn: f64,
} }
pub fn decision_adjusted_cap_bn(
factor_date: NaiveDate,
raw_cap_bn: f64,
market: &DailyMarketSnapshot,
) -> f64 {
if !raw_cap_bn.is_finite() || raw_cap_bn <= 0.0 {
return f64::NAN;
}
if factor_date != market.date {
return raw_cap_bn;
}
if !market.close.is_finite()
|| market.close <= 0.0
|| !market.prev_close.is_finite()
|| market.prev_close <= 0.0
{
return f64::NAN;
}
raw_cap_bn * market.prev_close / market.close
}
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot, market: &DailyMarketSnapshot) -> f64 {
decision_adjusted_cap_bn(factor.date, factor.market_cap_bn, market)
}
pub fn decision_free_float_cap_bn(
factor: &DailyFactorSnapshot,
market: &DailyMarketSnapshot,
) -> f64 {
decision_adjusted_cap_bn(factor.date, factor.free_float_cap_bn, market)
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct SymbolPriceSeries { struct SymbolPriceSeries {
snapshots: Vec<DailyMarketSnapshot>, snapshots: Vec<DailyMarketSnapshot>,
@@ -597,11 +629,16 @@ impl SymbolPriceSeries {
} }
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 values = self.decision_volume_values(date, lookback)?; if lookback == 0 {
if values.len() < lookback {
return None; return None;
} }
let sum = values.iter().sum::<f64>(); let end = self.previous_completed_end_index(date)?;
let end_count = *self.unpaused_count_prefix.get(end)?;
if end_count < lookback {
return None;
}
let start_count = end_count - lookback;
let sum = self.unpaused_volume_prefix[end_count] - self.unpaused_volume_prefix[start_count];
Some(sum / lookback as f64) Some(sum / lookback as f64)
} }
@@ -691,6 +728,7 @@ struct BenchmarkPriceSeries {
dates: Vec<NaiveDate>, dates: Vec<NaiveDate>,
opens: Vec<f64>, opens: Vec<f64>,
closes: Vec<f64>, closes: Vec<f64>,
prev_closes: Vec<f64>,
open_prefix: Vec<f64>, open_prefix: Vec<f64>,
close_prefix: Vec<f64>, close_prefix: Vec<f64>,
} }
@@ -702,12 +740,14 @@ impl BenchmarkPriceSeries {
let dates = sorted.iter().map(|row| row.date).collect::<Vec<_>>(); let dates = sorted.iter().map(|row| row.date).collect::<Vec<_>>();
let opens = sorted.iter().map(|row| row.open).collect::<Vec<_>>(); let opens = sorted.iter().map(|row| row.open).collect::<Vec<_>>();
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>(); let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
let open_prefix = prefix_sums(&opens); let open_prefix = prefix_sums(&opens);
let close_prefix = prefix_sums(&closes); let close_prefix = prefix_sums(&closes);
Self { Self {
dates, dates,
opens, opens,
closes, closes,
prev_closes,
open_prefix, open_prefix,
close_prefix, close_prefix,
} }
@@ -717,6 +757,24 @@ impl BenchmarkPriceSeries {
self.moving_average_for(date, lookback, PriceField::Close) self.moving_average_for(date, lookback, PriceField::Close)
} }
fn decision_close(&self, date: NaiveDate) -> Option<f64> {
match self.dates.binary_search(&date) {
Ok(idx) => self
.prev_closes
.get(idx)
.copied()
.filter(|value| value.is_finite() && *value > 0.0)
.or_else(|| {
idx.checked_sub(1)
.and_then(|prev| self.closes.get(prev).copied())
}),
Err(0) => None,
Err(idx) => idx
.checked_sub(1)
.and_then(|prev| self.closes.get(prev).copied()),
}
}
fn decision_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> { fn decision_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
if lookback == 0 { if lookback == 0 {
return None; return None;
@@ -734,12 +792,7 @@ impl BenchmarkPriceSeries {
Some(sum / lookback as f64) Some(sum / lookback as f64)
} }
fn decision_values_for( fn decision_values_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Vec<f64> {
&self,
date: NaiveDate,
lookback: usize,
field: PriceField,
) -> Vec<f64> {
if lookback == 0 { if lookback == 0 {
return Vec::new(); return Vec::new();
} }
@@ -2278,6 +2331,10 @@ impl DataSet {
self.benchmark_series_cache.moving_average(date, lookback) self.benchmark_series_cache.moving_average(date, lookback)
} }
pub fn benchmark_decision_close(&self, date: NaiveDate) -> Option<f64> {
self.benchmark_series_cache.decision_close(date)
}
pub fn benchmark_decision_moving_average( pub fn benchmark_decision_moving_average(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -2318,11 +2375,9 @@ impl DataSet {
"open" | "day_open" | "dayopen" | "benchmark_open" => self "open" | "day_open" | "dayopen" | "benchmark_open" => self
.benchmark_series_cache .benchmark_series_cache
.trailing_values_for(date, lookback, PriceField::Open), .trailing_values_for(date, lookback, PriceField::Open),
_ => self.benchmark_series_cache.decision_values_for( _ => self
date, .benchmark_series_cache
lookback, .decision_values_for(date, lookback, PriceField::Close),
PriceField::Close,
),
} }
} }
@@ -3409,10 +3464,15 @@ fn build_eligible_universe(
{ {
continue; continue;
} }
let market_cap_bn = decision_market_cap_bn(factor, market);
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
continue;
}
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
rows.push(EligibleUniverseSnapshot { rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(), symbol: factor.symbol.clone(),
market_cap_bn: factor.market_cap_bn, market_cap_bn,
free_float_cap_bn: factor.free_float_cap_bn, free_float_cap_bn,
}); });
} }
rows.sort_by(|left, right| { rows.sort_by(|left, right| {
@@ -3471,6 +3531,17 @@ mod tests {
} }
} }
fn benchmark_row(date: &str, close: f64) -> BenchmarkSnapshot {
BenchmarkSnapshot {
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
benchmark: "000852.SH".to_string(),
open: close,
close,
prev_close: close - 1.0,
volume: 1_000_000,
}
}
#[test] #[test]
fn baseline_selection_uses_structured_instrument_dates_and_status_only() { fn baseline_selection_uses_structured_instrument_dates_and_status_only() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
@@ -3504,6 +3575,14 @@ mod tests {
Some(&instrument("正常名称", "delisted", None)), Some(&instrument("正常名称", "delisted", None)),
date date
)); ));
assert!(instrument_passes_baseline_selection(
Some(&instrument(
"正常名称",
"delisted",
Some(NaiveDate::parse_from_str("2025-04-30", "%Y-%m-%d").unwrap()),
)),
date
));
assert!(!instrument_passes_baseline_selection( assert!(!instrument_passes_baseline_selection(
Some(&instrument( Some(&instrument(
"正常名称", "正常名称",
@@ -3545,6 +3624,28 @@ mod tests {
); );
} }
#[test]
fn decision_close_average_ignores_current_day_close() {
let mut current = market_row("2025-01-06", 12.0, 10_000);
current.close = 9_999.0;
current.last_price = 9_999.0;
let series = SymbolPriceSeries::new(&[
market_row("2025-01-02", 10.0, 100),
market_row("2025-01-03", 11.0, 200),
current,
]);
let decision_date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
assert_eq!(
series.decision_close_moving_average(decision_date, 2),
Some(11.5)
);
assert_eq!(
series.moving_average(decision_date, 2, PriceField::Close),
Some((11.0 + 9_999.0) / 2.0)
);
}
#[test] #[test]
fn decision_volume_average_skips_paused_days_before_counting_window() { fn decision_volume_average_skips_paused_days_before_counting_window() {
let mut paused = market_row("2025-01-03", 11.0, 0); let mut paused = market_row("2025-01-03", 11.0, 0);
@@ -3572,6 +3673,118 @@ mod tests {
); );
} }
#[test]
fn eligible_universe_uses_decision_market_cap_same_date() {
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
let instrument = |symbol: &str| Instrument {
symbol: symbol.to_string(),
name: symbol.to_string(),
board: if symbol.ends_with(".SH") { "SH" } else { "SZ" }.to_string(),
round_lot: 100,
listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()),
delisted_at: None,
status: "active".to_string(),
};
let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: prev_close,
open: prev_close,
high: close.max(prev_close),
low: close.min(prev_close),
close,
last_price: prev_close,
bid1: prev_close,
ask1: prev_close,
prev_close,
volume: 100_000,
tick_volume: 1_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: prev_close * 1.1,
lower_limit: prev_close * 0.9,
price_tick: 0.01,
};
let factor =
|symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn,
free_float_cap_bn,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
};
let candidate = |symbol: &str| CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
};
let data = DataSet::from_components(
vec![instrument("000001.SZ"), instrument("000002.SZ")],
vec![
market("000001.SZ", 10.0, 20.0),
market("000002.SZ", 10.0, 10.0),
],
vec![
factor("000001.SZ", 12.0, 4.0),
factor("000002.SZ", 10.0, 5.0),
],
vec![candidate("000001.SZ"), candidate("000002.SZ")],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 100.0,
close: 101.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let rows = data.eligible_universe_on(date);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].symbol, "000001.SZ");
assert!((rows[0].market_cap_bn - 6.0).abs() < 1e-9);
assert!((rows[0].free_float_cap_bn - 2.0).abs() < 1e-9);
assert_eq!(rows[1].symbol, "000002.SZ");
assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9);
}
#[test]
fn benchmark_decision_close_windows_exclude_current_close() {
let series = BenchmarkPriceSeries::new(&[
benchmark_row("2025-01-02", 100.0),
benchmark_row("2025-01-03", 200.0),
benchmark_row("2025-01-06", 9_999.0),
]);
let decision_date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
assert_eq!(series.decision_close(decision_date), Some(9_998.0));
assert_eq!(
series.decision_moving_average(decision_date, 2),
Some(150.0)
);
assert_eq!(
series.decision_values_for(decision_date, 2, PriceField::Close),
vec![100.0, 200.0]
);
assert_eq!(
series.moving_average(decision_date, 2),
Some((200.0 + 9_999.0) / 2.0)
);
}
#[test] #[test]
fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() { fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() {
let path = temp_csv_path("mixed_factor_maps"); let path = temp_csv_path("mixed_factor_maps");
+1 -1
View File
@@ -43,7 +43,7 @@ impl Instrument {
pub fn is_active_on(&self, date: NaiveDate) -> bool { pub fn is_active_on(&self, date: NaiveDate) -> bool {
self.listed_at.is_none_or(|listed_at| listed_at <= date) self.listed_at.is_none_or(|listed_at| listed_at <= date)
&& !self.is_delisted_before(date) && !self.is_delisted_before(date)
&& !self.status.eq_ignore_ascii_case("inactive") && !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
} }
} }
+500 -31
View File
@@ -5,7 +5,10 @@ use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
use rhai::{AST, Dynamic, Engine, Map, Scope}; use rhai::{AST, Dynamic, Engine, Map, Scope};
use crate::cost::ChinaAShareCostModel; use crate::cost::ChinaAShareCostModel;
use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; use crate::data::{
DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField, decision_free_float_cap_bn,
decision_market_cap_bn,
};
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::events::OrderSide; use crate::events::OrderSide;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
@@ -177,6 +180,7 @@ pub struct PlatformExprStrategyConfig {
pub benchmark_symbol: String, pub benchmark_symbol: String,
pub signal_symbol: String, pub signal_symbol: String,
pub refresh_rate: usize, pub refresh_rate: usize,
pub refresh_rate_expr: String,
pub max_positions: usize, pub max_positions: usize,
pub prelude: String, pub prelude: String,
pub universe_exclude: Vec<String>, pub universe_exclude: Vec<String>,
@@ -221,6 +225,7 @@ impl PlatformExprStrategyConfig {
benchmark_symbol: "000852.SH".to_string(), benchmark_symbol: "000852.SH".to_string(),
signal_symbol: "000001.SH".to_string(), signal_symbol: "000001.SH".to_string(),
refresh_rate: 15, refresh_rate: 15,
refresh_rate_expr: String::new(),
max_positions: 40, max_positions: 40,
prelude: r#"let stocknum = 40; prelude: r#"let stocknum = 40;
let ma_ratio = 1.0001; let ma_ratio = 1.0001;
@@ -358,7 +363,7 @@ struct StockExpressionState {
market_cap: f64, market_cap: f64,
free_float_cap: f64, free_float_cap: f64,
pe_ttm: f64, pe_ttm: f64,
volume: i64, volume: f64,
tick_volume: i64, tick_volume: i64,
bid1_volume: i64, bid1_volume: i64,
ask1_volume: i64, ask1_volume: i64,
@@ -1474,42 +1479,37 @@ impl PlatformExprStrategy {
.benchmark(date) .benchmark(date)
.ok_or(BacktestError::MissingBenchmark { date })?; .ok_or(BacktestError::MissingBenchmark { date })?;
let benchmark_open = benchmark.open; let benchmark_open = benchmark.open;
let benchmark_close = benchmark.close; let benchmark_close = ctx
.data
.benchmark_decision_close(date)
.or_else(|| {
(benchmark.prev_close.is_finite() && benchmark.prev_close > 0.0)
.then_some(benchmark.prev_close)
})
.unwrap_or(benchmark_open);
let benchmark_ma_short = ctx let benchmark_ma_short = ctx
.data .data
.benchmark_decision_moving_average(date, self.config.benchmark_short_ma_days) .benchmark_decision_moving_average(date, self.config.benchmark_short_ma_days)
.or_else(|| {
ctx.data
.benchmark_moving_average(date, self.config.benchmark_short_ma_days)
})
.unwrap_or(benchmark_close); .unwrap_or(benchmark_close);
let benchmark_ma_long = ctx let benchmark_ma_long = ctx
.data .data
.benchmark_decision_moving_average(date, self.config.benchmark_long_ma_days) .benchmark_decision_moving_average(date, self.config.benchmark_long_ma_days)
.or_else(|| {
ctx.data
.benchmark_moving_average(date, self.config.benchmark_long_ma_days)
})
.unwrap_or(benchmark_ma_short); .unwrap_or(benchmark_ma_short);
let benchmark_ma5 = ctx let benchmark_ma5 = ctx
.data .data
.benchmark_decision_moving_average(date, 5) .benchmark_decision_moving_average(date, 5)
.or_else(|| ctx.data.benchmark_moving_average(date, 5))
.unwrap_or(benchmark_ma_short); .unwrap_or(benchmark_ma_short);
let benchmark_ma10 = ctx let benchmark_ma10 = ctx
.data .data
.benchmark_decision_moving_average(date, 10) .benchmark_decision_moving_average(date, 10)
.or_else(|| ctx.data.benchmark_moving_average(date, 10))
.unwrap_or(benchmark_ma_long); .unwrap_or(benchmark_ma_long);
let benchmark_ma20 = ctx let benchmark_ma20 = ctx
.data .data
.benchmark_decision_moving_average(date, 20) .benchmark_decision_moving_average(date, 20)
.or_else(|| ctx.data.benchmark_moving_average(date, 20))
.unwrap_or(benchmark_ma10); .unwrap_or(benchmark_ma10);
let benchmark_ma30 = ctx let benchmark_ma30 = ctx
.data .data
.benchmark_decision_moving_average(date, 30) .benchmark_decision_moving_average(date, 30)
.or_else(|| ctx.data.benchmark_moving_average(date, 30))
.unwrap_or(benchmark_ma20); .unwrap_or(benchmark_ma20);
let signal_ma5 = ctx let signal_ma5 = ctx
.data .data
@@ -1620,6 +1620,7 @@ impl PlatformExprStrategy {
) -> Result<StockExpressionState, BacktestError> { ) -> Result<StockExpressionState, BacktestError> {
let market = ctx.data.require_market(date, symbol)?; let market = ctx.data.require_market(date, symbol)?;
let feature_market = ctx.data.market(factor_date, symbol).unwrap_or(market); let feature_market = ctx.data.market(factor_date, symbol).unwrap_or(market);
let intraday_same_day_factor = self.config.aiquant_transaction_cost && factor_date == date;
let decision_quote = self.aiquant_scheduled_quote(ctx, date, symbol); let decision_quote = self.aiquant_scheduled_quote(ctx, date, symbol);
let factor = ctx.data.require_factor(factor_date, symbol)?; let factor = ctx.data.require_factor(factor_date, symbol)?;
let candidate = ctx.data.require_candidate(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?;
@@ -1719,23 +1720,52 @@ impl PlatformExprStrategy {
&& (market.is_at_lower_limit_price(market.close) && (market.is_at_lower_limit_price(market.close)
|| market.is_at_lower_limit_price(market.open) || market.is_at_lower_limit_price(market.open)
|| market.is_at_lower_limit_price(market.day_open)); || market.is_at_lower_limit_price(market.day_open));
let amount = factor.extra_factors.get("amount").copied().unwrap_or(0.0); let amount = if intraday_same_day_factor {
f64::NAN
} else {
factor.extra_factors.get("amount").copied().unwrap_or(0.0)
};
let market_cap = decision_market_cap_bn(factor, market);
let free_float_cap = decision_free_float_cap_bn(factor, market);
let expression_high = if intraday_same_day_factor {
f64::NAN
} else {
feature_market.high
};
let expression_low = if intraday_same_day_factor {
f64::NAN
} else {
feature_market.low
};
let expression_close = if intraday_same_day_factor
&& feature_market.prev_close.is_finite()
&& feature_market.prev_close > 0.0
{
feature_market.prev_close
} else {
feature_market.close
};
let expression_volume = if intraday_same_day_factor {
f64::NAN
} else {
feature_market.volume as f64
};
Ok(StockExpressionState { Ok(StockExpressionState {
symbol: symbol.to_string(), symbol: symbol.to_string(),
market_cap: factor.market_cap_bn, market_cap,
free_float_cap: factor.free_float_cap_bn, free_float_cap,
pe_ttm: factor.pe_ttm, pe_ttm: factor.pe_ttm,
volume: feature_market.volume as i64, volume: expression_volume,
tick_volume: market.tick_volume as i64, tick_volume: market.tick_volume as i64,
bid1_volume: market.bid1_volume as i64, bid1_volume: market.bid1_volume as i64,
ask1_volume: market.ask1_volume as i64, ask1_volume: market.ask1_volume as i64,
turnover_ratio: factor.turnover_ratio.unwrap_or(0.0), turnover_ratio: factor.turnover_ratio.unwrap_or(0.0),
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0), effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
open: feature_market.day_open, open: feature_market.day_open,
high: feature_market.high, high: expression_high,
low: feature_market.low, low: expression_low,
close: feature_market.close, close: expression_close,
last: decision_quote last: decision_quote
.and_then(|quote| { .and_then(|quote| {
(quote.last_price.is_finite() && quote.last_price > 0.0) (quote.last_price.is_finite() && quote.last_price > 0.0)
@@ -3958,6 +3988,25 @@ impl PlatformExprStrategy {
Ok(value.round().max(1.0) as usize) Ok(value.round().max(1.0) as usize)
} }
fn effective_refresh_rate(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
) -> Result<usize, BacktestError> {
let expr = self.config.refresh_rate_expr.trim();
if expr.is_empty() {
return Ok(self.config.refresh_rate.max(1));
}
let value = self.eval_float(ctx, expr, day, None, None)?;
if !value.is_finite() {
return Err(BacktestError::Execution(format!(
"platform refresh_rate_expr did not produce a finite number: {}",
expr
)));
}
Ok(value.round().max(1.0) as usize)
}
fn selection_dates(&self, ctx: &StrategyContext<'_>) -> (NaiveDate, NaiveDate) { fn selection_dates(&self, ctx: &StrategyContext<'_>) -> (NaiveDate, NaiveDate) {
let decision_date = ctx.decision_date; let decision_date = ctx.decision_date;
let factor_date = if self.config.aiquant_transaction_cost { let factor_date = if self.config.aiquant_transaction_cost {
@@ -4956,10 +5005,15 @@ impl PlatformExprStrategy {
if !self.stock_passes_universe_exclude(candidate, market) { if !self.stock_passes_universe_exclude(candidate, market) {
continue; continue;
} }
let market_cap_bn = decision_market_cap_bn(factor, market);
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
continue;
}
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
rows.push(EligibleUniverseSnapshot { rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(), symbol: factor.symbol.clone(),
market_cap_bn: factor.market_cap_bn, market_cap_bn,
free_float_cap_bn: factor.free_float_cap_bn, free_float_cap_bn,
}); });
} }
rows.sort_by(|left, right| { rows.sort_by(|left, right| {
@@ -5026,7 +5080,7 @@ impl PlatformExprStrategy {
"market_cap" => Some(stock.market_cap), "market_cap" => Some(stock.market_cap),
"free_float_cap" | "free_float_market_cap" => Some(stock.free_float_cap), "free_float_cap" | "free_float_market_cap" => Some(stock.free_float_cap),
"pe_ttm" => Some(stock.pe_ttm), "pe_ttm" => Some(stock.pe_ttm),
"volume" => Some(stock.volume as f64), "volume" => Some(stock.volume),
"tick_volume" => Some(stock.tick_volume as f64), "tick_volume" => Some(stock.tick_volume as f64),
"bid1_volume" => Some(stock.bid1_volume as f64), "bid1_volume" => Some(stock.bid1_volume as f64),
"ask1_volume" => Some(stock.ask1_volume as f64), "ask1_volume" => Some(stock.ask1_volume as f64),
@@ -5279,8 +5333,6 @@ impl PlatformExprStrategy {
| "touched_lower_limit" | "touched_lower_limit"
| "hit_upper_limit" | "hit_upper_limit"
| "hit_lower_limit" | "hit_lower_limit"
| "at_upper_limit"
| "at_lower_limit"
) )
}) })
} }
@@ -5657,6 +5709,7 @@ impl Strategy for PlatformExprStrategy {
}; };
let empty_rebalance_retry = let empty_rebalance_retry =
self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty(); self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty();
let effective_refresh_rate = self.effective_refresh_rate(ctx, &day)?;
let periodic_rebalance = if self.config.rotation_enabled { let periodic_rebalance = if self.config.rotation_enabled {
if let Some(schedule) = &self.config.rebalance_schedule { if let Some(schedule) = &self.config.rebalance_schedule {
schedule.matches( schedule.matches(
@@ -5669,12 +5722,12 @@ impl Strategy for PlatformExprStrategy {
self.last_rebalance_date self.last_rebalance_date
.map(|last| { .map(|last| {
execution_date.signed_duration_since(last).num_days() execution_date.signed_duration_since(last).num_days()
>= self.config.refresh_rate as i64 >= effective_refresh_rate as i64
}) })
.unwrap_or(true) .unwrap_or(true)
|| empty_rebalance_retry || empty_rebalance_retry
} else { } else {
self.rebalance_day_counter % self.config.refresh_rate == 0 || empty_rebalance_retry self.rebalance_day_counter % effective_refresh_rate == 0 || empty_rebalance_retry
} }
} else { } else {
false false
@@ -6309,6 +6362,124 @@ mod tests {
assert!(!rewritten.contains('?')); assert!(!rewritten.contains('?'));
} }
#[test]
fn platform_day_state_uses_decision_benchmark_close() {
let dates = [d(2025, 1, 2), d(2025, 1, 3)];
let market_rows = vec![
DailyMarketSnapshot {
date: dates[0],
symbol: "000001.SH".to_string(),
timestamp: None,
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 9.0,
volume: 1_000_000,
tick_volume: 0,
bid1_volume: 0,
ask1_volume: 0,
trading_phase: None,
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: dates[1],
symbol: "000001.SH".to_string(),
timestamp: None,
day_open: 1_000.0,
open: 1_000.0,
high: 1_000.0,
low: 1_000.0,
close: 1_000.0,
last_price: 1_000.0,
bid1: 1_000.0,
ask1: 1_000.0,
prev_close: 10.0,
volume: 1_000_000,
tick_volume: 0,
bid1_volume: 0,
ask1_volume: 0,
trading_phase: None,
paused: false,
upper_limit: 1_100.0,
lower_limit: 900.0,
price_tick: 0.01,
},
];
let benchmark_rows = vec![
BenchmarkSnapshot {
date: dates[0],
benchmark: "000852.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
},
BenchmarkSnapshot {
date: dates[1],
benchmark: "000852.SH".to_string(),
open: 150.0,
close: 9_999.0,
prev_close: 100.0,
volume: 1_000_000,
},
];
let data = DataSet::from_components(
vec![Instrument {
symbol: "000001.SH".to_string(),
name: "SSE Composite".to_string(),
board: "SH".to_string(),
round_lot: 100,
listed_at: Some(d(1990, 12, 19)),
delisted_at: None,
status: "active".to_string(),
}],
market_rows,
Vec::new(),
Vec::new(),
benchmark_rows,
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: dates[1],
decision_date: dates[1],
decision_index: 1,
data: &data,
portfolio: &portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SH".to_string();
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
let strategy = PlatformExprStrategy::new(cfg);
let day = strategy.day_state(&ctx, dates[1]).expect("day state");
assert_eq!(day.signal_close, 10.0);
assert_eq!(day.benchmark_open, 150.0);
assert_eq!(day.benchmark_close, 100.0);
assert_eq!(day.benchmark_ma_short, 100.0);
assert_eq!(day.benchmark_ma_long, 100.0);
}
#[test] #[test]
fn platform_expr_code_number_extracts_stock_digits() { fn platform_expr_code_number_extracts_stock_digits() {
assert_eq!(super::code_number_value("002113.SZ"), 2113); assert_eq!(super::code_number_value("002113.SZ"), 2113);
@@ -7116,12 +7287,128 @@ mod tests {
assert_eq!(stock.close, 9.9); assert_eq!(stock.close, 9.9);
assert_eq!(stock.prev_close, 9.7); assert_eq!(stock.prev_close, 9.7);
assert_eq!(stock.volume, 12_300); assert_eq!(stock.volume, 12_300.0);
assert_eq!(stock.last, 19.06); assert_eq!(stock.last, 19.06);
assert_eq!(stock.market_cap, 8.0); assert_eq!(stock.market_cap, 8.0);
assert!(!stock.touched_upper_limit); assert!(!stock.touched_upper_limit);
} }
#[test]
fn platform_aiquant_selection_uses_decision_market_cap() {
let date = d(2025, 4, 8);
let symbol = "003008.SZ";
let data = DataSet::from_components(
vec![Instrument {
symbol: symbol.to_string(),
name: symbol.to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some("2025-04-08 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 20.0,
low: 10.0,
close: 20.0,
last_price: 12.0,
bid1: 12.0,
ask1: 12.0,
prev_close: 10.0,
volume: 120_000,
tick_volume: 1_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 22.0,
lower_limit: 8.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 18.0,
free_float_cap_bn: 9.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1002.0,
prev_close: 998.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 40,
data: &data,
portfolio: &portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.aiquant_transaction_cost = true;
cfg.signal_symbol = symbol.to_string();
cfg.stock_filter_expr = "true".to_string();
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
let strategy = PlatformExprStrategy::new(cfg);
let universe = strategy.selectable_universe_on(&ctx, date, date);
assert_eq!(universe.len(), 1);
assert!((universe[0].market_cap_bn - 9.0).abs() < 1e-9);
assert!((universe[0].free_float_cap_bn - 4.5).abs() < 1e-9);
let stock = strategy
.stock_state_with_factor_date(&ctx, date, date, symbol)
.expect("stock state");
assert!((stock.market_cap - 9.0).abs() < 1e-9);
assert!((stock.free_float_cap - 4.5).abs() < 1e-9);
assert_eq!(stock.close, 10.0);
assert!(stock.high.is_nan());
assert!(stock.low.is_nan());
assert!(stock.volume.is_nan());
let day = strategy.day_state(&ctx, date).expect("day state");
let (selected, _) = strategy
.select_symbols(&ctx, date, date, &day, 10.0, 20.0, 1)
.expect("selection");
assert!(selected.is_empty());
}
#[test] #[test]
fn platform_aiquant_stock_expr_uses_scheduled_tick_price_for_limit_check() { fn platform_aiquant_stock_expr_uses_scheduled_tick_price_for_limit_check() {
let date = d(2024, 1, 30); let date = d(2024, 1, 30);
@@ -7241,6 +7528,142 @@ mod tests {
); );
} }
#[test]
fn platform_aiquant_scheduled_quote_uses_configured_intraday_time() {
let date = d(2025, 1, 6);
let symbol = "603779.SH";
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: symbol.to_string(),
name: symbol.to_string(),
board: "SH".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some("2025-01-06 10:40:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 22.0,
low: 9.8,
close: 21.5,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 10.0,
volume: 1_000_000,
tick_volume: 1_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 22.0,
lower_limit: 8.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1002.0,
prev_close: 998.0,
volume: 1_000_000,
}],
Vec::new(),
vec![
IntradayExecutionQuote {
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 40, 0).expect("valid timestamp"),
last_price: 10.0,
bid1: 9.99,
ask1: 10.0,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 100,
amount_delta: 1_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(14, 59, 2).expect("valid timestamp"),
last_price: 20.0,
bid1: 19.99,
ask1: 20.0,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 100,
amount_delta: 2_000.0,
trading_phase: Some("continuous".to_string()),
},
],
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 1,
data: &data,
portfolio: &portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.aiquant_transaction_cost = true;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap());
let strategy = PlatformExprStrategy::new(cfg.clone());
assert_eq!(
strategy
.aiquant_scheduled_quote(&ctx, date, symbol)
.map(|quote| quote.last_price),
Some(10.0)
);
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(14, 59, 0).unwrap());
let strategy = PlatformExprStrategy::new(cfg);
let quote = strategy
.aiquant_scheduled_quote(&ctx, date, symbol)
.expect("quote at or after 14:59");
assert_eq!(quote.timestamp, date.and_hms_opt(14, 59, 2).unwrap());
assert_eq!(quote.last_price, 20.0);
}
#[test] #[test]
fn platform_aiquant_intraday_selection_filters_limits_on_execution_date() { fn platform_aiquant_intraday_selection_filters_limits_on_execution_date() {
let factor_date = d(2023, 11, 10); let factor_date = d(2023, 11, 10);
@@ -9541,6 +9964,31 @@ mod tests {
third.diagnostics third.diagnostics
); );
let mut dynamic_cfg = PlatformExprStrategyConfig::microcap_rotation();
dynamic_cfg.signal_symbol = "000001.SZ".to_string();
dynamic_cfg.refresh_rate = 20;
dynamic_cfg.refresh_rate_expr = "year >= 2024 ? 5 : 20".to_string();
dynamic_cfg.max_positions = 2;
dynamic_cfg.benchmark_short_ma_days = 1;
dynamic_cfg.benchmark_long_ma_days = 1;
dynamic_cfg.market_cap_lower_expr = "0".to_string();
dynamic_cfg.market_cap_upper_expr = "100".to_string();
dynamic_cfg.selection_limit_expr = "2".to_string();
dynamic_cfg.stock_filter_expr = "close > 0".to_string();
let mut dynamic_strategy = PlatformExprStrategy::new(dynamic_cfg);
dynamic_strategy.rebalance_day_counter = 5;
let dynamic_decision = dynamic_strategy
.on_day(&third_ctx)
.expect("dynamic refresh decision");
assert!(
dynamic_decision
.diagnostics
.iter()
.any(|item| item.contains("periodic_rebalance=true")),
"{:?}",
dynamic_decision.diagnostics
);
let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation();
no_retry_cfg.signal_symbol = "000001.SZ".to_string(); no_retry_cfg.signal_symbol = "000001.SZ".to_string();
no_retry_cfg.refresh_rate = 15; no_retry_cfg.refresh_rate = 15;
@@ -11579,7 +12027,7 @@ mod tests {
valuation_prices valuation_prices
.as_ref() .as_ref()
.and_then(|map| map.get("000002.SZ").copied()), .and_then(|map| map.get("000002.SZ").copied()),
Some(10.01) Some(9.99)
); );
} }
other => panic!("unexpected explicit target portfolio intent: {other:?}"), other => panic!("unexpected explicit target portfolio intent: {other:?}"),
@@ -12910,4 +13358,25 @@ mod tests {
"rolling helper values must not change cached script identity across dates" "rolling helper values must not change cached script identity across dates"
); );
} }
#[test]
fn daily_limit_state_does_not_require_intraday_selection_quotes() {
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.stock_filter_expr = "close > 1.0 && !at_upper_limit && !at_lower_limit".to_string();
let strategy = PlatformExprStrategy::new(cfg);
assert!(
!strategy.stock_filter_uses_intraday_quote_fields(),
"daily limit-state aliases are derived from daily close/limit fields"
);
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.stock_filter_expr = "last_price > 1.0 && ask1_volume > 0".to_string();
let strategy = PlatformExprStrategy::new(cfg);
assert!(
strategy.stock_filter_uses_intraday_quote_fields(),
"actual tick/quote fields still require intraday selection quotes"
);
}
} }
@@ -258,6 +258,8 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)] #[serde(default)]
pub stage: Option<String>, pub stage: Option<String>,
#[serde(default)] #[serde(default)]
pub refresh_rate_expr: Option<String>,
#[serde(default)]
pub schedule: Option<StrategyExpressionScheduleConfig>, pub schedule: Option<StrategyExpressionScheduleConfig>,
#[serde(default)] #[serde(default)]
pub rotation_enabled: Option<bool>, pub rotation_enabled: Option<bool>,
@@ -619,6 +621,13 @@ pub fn platform_expr_config_from_spec(
} }
} }
if let Some(trading) = runtime_expr.trading.as_ref() { if let Some(trading) = runtime_expr.trading.as_ref() {
if let Some(expr) = trading
.refresh_rate_expr
.as_ref()
.filter(|value| !value.trim().is_empty())
{
cfg.refresh_rate_expr = expr.clone();
}
if let Some(enabled) = trading.rotation_enabled { if let Some(enabled) = trading.rotation_enabled {
cfg.rotation_enabled = enabled; cfg.rotation_enabled = enabled;
} }
@@ -1124,6 +1133,7 @@ mod tests {
"stockFilterExpr": "stock_ma5 > stock_ma10" "stockFilterExpr": "stock_ma5 > stock_ma10"
}, },
"trading": { "trading": {
"refreshRateExpr": "year >= 2024 ? 5 : 20",
"rotationEnabled": false, "rotationEnabled": false,
"dailyTopUp": true, "dailyTopUp": true,
"retryEmptyRebalance": true, "retryEmptyRebalance": true,
@@ -1145,6 +1155,7 @@ mod tests {
assert_eq!(cfg.strategy_name, "runtime_spec_test"); assert_eq!(cfg.strategy_name, "runtime_spec_test");
assert_eq!(cfg.signal_symbol, "000852.SH"); assert_eq!(cfg.signal_symbol, "000852.SH");
assert_eq!(cfg.selection_limit_expr, "stocknum"); assert_eq!(cfg.selection_limit_expr, "stocknum");
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]); assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
assert!(!cfg.rotation_enabled); assert!(!cfg.rotation_enabled);
assert!(cfg.daily_top_up_enabled); assert!(cfg.daily_top_up_enabled);
+3 -3
View File
@@ -26,11 +26,11 @@ impl ChinaAShareRiskControl {
return Some("inactive_or_delisted"); return Some("inactive_or_delisted");
} }
let status = instrument.status.trim().to_ascii_lowercase(); let status = instrument.status.trim().to_ascii_lowercase();
if matches!( let terminal_status = matches!(
status.as_str(), status.as_str(),
"inactive" | "delisted" | "terminated" | "expired" "inactive" | "delisted" | "terminated" | "expired"
) || status.contains("delist") ) || status.contains("delist");
{ if terminal_status && instrument.delisted_at.is_none() {
return Some("inactive_or_delisted"); return Some("inactive_or_delisted");
} }
None None