Fix decision-time rolling factor semantics
This commit is contained in:
@@ -451,7 +451,6 @@ struct SymbolPriceSeries {
|
|||||||
closes: Vec<f64>,
|
closes: Vec<f64>,
|
||||||
prev_closes: Vec<f64>,
|
prev_closes: Vec<f64>,
|
||||||
last_prices: Vec<f64>,
|
last_prices: Vec<f64>,
|
||||||
volumes: Vec<f64>,
|
|
||||||
open_prefix: Vec<f64>,
|
open_prefix: Vec<f64>,
|
||||||
close_prefix: Vec<f64>,
|
close_prefix: Vec<f64>,
|
||||||
prev_close_prefix: Vec<f64>,
|
prev_close_prefix: Vec<f64>,
|
||||||
@@ -485,7 +484,6 @@ impl SymbolPriceSeries {
|
|||||||
closes,
|
closes,
|
||||||
prev_closes,
|
prev_closes,
|
||||||
last_prices,
|
last_prices,
|
||||||
volumes,
|
|
||||||
open_prefix,
|
open_prefix,
|
||||||
close_prefix,
|
close_prefix,
|
||||||
prev_close_prefix,
|
prev_close_prefix,
|
||||||
@@ -532,6 +530,14 @@ impl SymbolPriceSeries {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn previous_completed_end_index(&self, date: NaiveDate) -> Option<usize> {
|
||||||
|
match self.dates.binary_search(&date) {
|
||||||
|
Ok(idx) => Some(idx),
|
||||||
|
Err(0) => None,
|
||||||
|
Err(idx) => Some(idx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn decision_close_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
fn decision_close_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
||||||
if lookback == 0 {
|
if lookback == 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -545,28 +551,11 @@ impl SymbolPriceSeries {
|
|||||||
Some(sum / lookback as f64)
|
Some(sum / lookback as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decision_close_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
|
||||||
if lookback == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let end = self.decision_end_index(date)?;
|
|
||||||
if end == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let start = end.saturating_sub(lookback);
|
|
||||||
let count = end.saturating_sub(start);
|
|
||||||
if count == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let sum = self.prev_close_prefix[end] - self.prev_close_prefix[start];
|
|
||||||
Some(sum / count as f64)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
||||||
if lookback == 0 {
|
if lookback == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let end = self.decision_end_index(date)?;
|
let end = self.previous_completed_end_index(date)?;
|
||||||
if end < lookback {
|
if end < lookback {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -575,23 +564,6 @@ impl SymbolPriceSeries {
|
|||||||
Some(sum / lookback as f64)
|
Some(sum / lookback as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decision_volume_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
|
|
||||||
if lookback == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let end = self.decision_end_index(date)?;
|
|
||||||
if end == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let start = end.saturating_sub(lookback);
|
|
||||||
let count = end.saturating_sub(start);
|
|
||||||
if count == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let sum = self.volume_prefix[end] - self.volume_prefix[start];
|
|
||||||
Some(sum / count as f64)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn end_index(&self, date: NaiveDate) -> Option<usize> {
|
fn end_index(&self, date: NaiveDate) -> Option<usize> {
|
||||||
match self.dates.binary_search(&date) {
|
match self.dates.binary_search(&date) {
|
||||||
Ok(idx) => Some(idx + 1),
|
Ok(idx) => Some(idx + 1),
|
||||||
@@ -630,7 +602,6 @@ impl SymbolPriceSeries {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct BenchmarkPriceSeries {
|
struct BenchmarkPriceSeries {
|
||||||
dates: Vec<NaiveDate>,
|
dates: Vec<NaiveDate>,
|
||||||
opens: Vec<f64>,
|
|
||||||
closes: Vec<f64>,
|
closes: Vec<f64>,
|
||||||
open_prefix: Vec<f64>,
|
open_prefix: Vec<f64>,
|
||||||
close_prefix: Vec<f64>,
|
close_prefix: Vec<f64>,
|
||||||
@@ -647,7 +618,6 @@ impl BenchmarkPriceSeries {
|
|||||||
let close_prefix = prefix_sums(&closes);
|
let close_prefix = prefix_sums(&closes);
|
||||||
Self {
|
Self {
|
||||||
dates,
|
dates,
|
||||||
opens,
|
|
||||||
closes,
|
closes,
|
||||||
open_prefix,
|
open_prefix,
|
||||||
close_prefix,
|
close_prefix,
|
||||||
@@ -2014,11 +1984,11 @@ impl DataSet {
|
|||||||
"close" | "prev_close" | "stock_close" | "price" => self
|
"close" | "prev_close" | "stock_close" | "price" => self
|
||||||
.market_series_by_symbol
|
.market_series_by_symbol
|
||||||
.get(symbol)
|
.get(symbol)
|
||||||
.and_then(|series| series.decision_close_rolling_average(date, lookback)),
|
.and_then(|series| series.decision_close_moving_average(date, lookback)),
|
||||||
"volume" | "stock_volume" => self
|
"volume" | "stock_volume" => self
|
||||||
.market_series_by_symbol
|
.market_series_by_symbol
|
||||||
.get(symbol)
|
.get(symbol)
|
||||||
.and_then(|series| series.decision_volume_rolling_average(date, lookback)),
|
.and_then(|series| series.decision_volume_moving_average(date, lookback)),
|
||||||
"day_open" | "dayopen" => {
|
"day_open" | "dayopen" => {
|
||||||
self.market_moving_average(date, symbol, lookback, PriceField::DayOpen)
|
self.market_moving_average(date, symbol, lookback, PriceField::DayOpen)
|
||||||
}
|
}
|
||||||
@@ -3111,6 +3081,63 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("{}_{}_{}.csv", name, std::process::id(), nanos))
|
std::env::temp_dir().join(format!("{}_{}_{}.csv", name, std::process::id(), nanos))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot {
|
||||||
|
DailyMarketSnapshot {
|
||||||
|
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
timestamp: None,
|
||||||
|
day_open: prev_close,
|
||||||
|
open: prev_close,
|
||||||
|
high: prev_close,
|
||||||
|
low: prev_close,
|
||||||
|
close: prev_close,
|
||||||
|
last_price: prev_close,
|
||||||
|
bid1: prev_close,
|
||||||
|
ask1: prev_close,
|
||||||
|
prev_close,
|
||||||
|
volume,
|
||||||
|
tick_volume: 0,
|
||||||
|
bid1_volume: 0,
|
||||||
|
ask1_volume: 0,
|
||||||
|
trading_phase: None,
|
||||||
|
paused: false,
|
||||||
|
upper_limit: prev_close * 1.1,
|
||||||
|
lower_limit: prev_close * 0.9,
|
||||||
|
price_tick: 0.01,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decision_volume_average_uses_previous_completed_days_only() {
|
||||||
|
let series = SymbolPriceSeries::new(&[
|
||||||
|
market_row("2025-01-02", 10.0, 100),
|
||||||
|
market_row("2025-01-03", 11.0, 200),
|
||||||
|
market_row("2025-01-06", 12.0, 10_000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
series.decision_close_moving_average(
|
||||||
|
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
|
||||||
|
2
|
||||||
|
),
|
||||||
|
Some(11.5)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
series.decision_volume_moving_average(
|
||||||
|
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
|
||||||
|
2
|
||||||
|
),
|
||||||
|
Some(150.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
series.decision_volume_moving_average(
|
||||||
|
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
|
||||||
|
3
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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");
|
||||||
|
|||||||
@@ -1224,47 +1224,47 @@ impl PlatformExprStrategy {
|
|||||||
let stock_ma_short = ctx
|
let stock_ma_short = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days)
|
.market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days)
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_ma_mid = ctx
|
let stock_ma_mid = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days)
|
.market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days)
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_ma_long = ctx
|
let stock_ma_long = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days)
|
.market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days)
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_ma5 = ctx
|
let stock_ma5 = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_close_moving_average(date, symbol, 5)
|
.market_decision_close_moving_average(date, symbol, 5)
|
||||||
.unwrap_or(stock_ma_short);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_ma10 = ctx
|
let stock_ma10 = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_close_moving_average(date, symbol, 10)
|
.market_decision_close_moving_average(date, symbol, 10)
|
||||||
.unwrap_or(stock_ma_mid);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_ma20 = ctx
|
let stock_ma20 = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_close_moving_average(date, symbol, 20)
|
.market_decision_close_moving_average(date, symbol, 20)
|
||||||
.unwrap_or(stock_ma_long);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_ma30 = ctx
|
let stock_ma30 = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_close_moving_average(date, symbol, 30)
|
.market_decision_close_moving_average(date, symbol, 30)
|
||||||
.unwrap_or(stock_ma20);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_volume_ma5 = ctx
|
let stock_volume_ma5 = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_volume_moving_average(date, symbol, 5)
|
.market_decision_volume_moving_average(date, symbol, 5)
|
||||||
.unwrap_or(market.volume as f64);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_volume_ma10 = ctx
|
let stock_volume_ma10 = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_volume_moving_average(date, symbol, 10)
|
.market_decision_volume_moving_average(date, symbol, 10)
|
||||||
.unwrap_or(stock_volume_ma5);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_volume_ma20 = ctx
|
let stock_volume_ma20 = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_volume_moving_average(date, symbol, 20)
|
.market_decision_volume_moving_average(date, symbol, 20)
|
||||||
.unwrap_or(stock_volume_ma10);
|
.unwrap_or(f64::NAN);
|
||||||
let stock_volume_ma60 = ctx
|
let stock_volume_ma60 = ctx
|
||||||
.data
|
.data
|
||||||
.market_decision_volume_moving_average(date, symbol, 60)
|
.market_decision_volume_moving_average(date, symbol, 60)
|
||||||
.unwrap_or(stock_volume_ma20);
|
.unwrap_or(f64::NAN);
|
||||||
let touched_upper_limit = factor
|
let touched_upper_limit = factor
|
||||||
.extra_factors
|
.extra_factors
|
||||||
.get("touched_upper_limit")
|
.get("touched_upper_limit")
|
||||||
@@ -2623,6 +2623,14 @@ impl PlatformExprStrategy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_missing_rolling_mean_error(error: &BacktestError) -> bool {
|
||||||
|
matches!(
|
||||||
|
error,
|
||||||
|
BacktestError::Execution(message)
|
||||||
|
if message.starts_with("missing rolling mean for field ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn split_top_level_args(args: &str) -> Vec<String> {
|
fn split_top_level_args(args: &str) -> Vec<String> {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
let mut start = 0usize;
|
let mut start = 0usize;
|
||||||
@@ -2961,8 +2969,11 @@ impl PlatformExprStrategy {
|
|||||||
if self.config.buy_scale_expr.trim().is_empty() {
|
if self.config.buy_scale_expr.trim().is_empty() {
|
||||||
return Ok(1.0);
|
return Ok(1.0);
|
||||||
}
|
}
|
||||||
self.eval_float(ctx, &self.config.buy_scale_expr, day, Some(stock), None)
|
match self.eval_float(ctx, &self.config.buy_scale_expr, day, Some(stock), None) {
|
||||||
.map(|value| value.clamp(0.0, 1.0))
|
Ok(value) => Ok(value.clamp(0.0, 1.0)),
|
||||||
|
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(0.0),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_i32(
|
fn eval_i32(
|
||||||
@@ -3832,7 +3843,11 @@ impl PlatformExprStrategy {
|
|||||||
if self.config.stock_filter_expr.trim().is_empty() {
|
if self.config.stock_filter_expr.trim().is_empty() {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None)
|
match self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(false),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 {
|
fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 {
|
||||||
@@ -3919,7 +3934,11 @@ impl PlatformExprStrategy {
|
|||||||
stock: &StockExpressionState,
|
stock: &StockExpressionState,
|
||||||
) -> Result<f64, BacktestError> {
|
) -> Result<f64, BacktestError> {
|
||||||
if !self.config.rank_expr.trim().is_empty() {
|
if !self.config.rank_expr.trim().is_empty() {
|
||||||
return self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None);
|
return match self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None) {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(f64::NAN),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Ok(self
|
Ok(self
|
||||||
.stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str())
|
.stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str())
|
||||||
@@ -4003,10 +4022,28 @@ impl PlatformExprStrategy {
|
|||||||
for candidate in universe {
|
for candidate in universe {
|
||||||
let stock = self.stock_state(ctx, date, &candidate.symbol)?;
|
let stock = self.stock_state(ctx, date, &candidate.symbol)?;
|
||||||
let field_value = self.selection_field_value(&candidate, &stock);
|
let field_value = self.selection_field_value(&candidate, &stock);
|
||||||
|
if !field_value.is_finite() {
|
||||||
|
if diagnostics.len() < 12 {
|
||||||
|
diagnostics.push(format!(
|
||||||
|
"{} rejected by missing selection field",
|
||||||
|
candidate.symbol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if field_value < band_low || field_value > band_high {
|
if field_value < band_low || field_value > band_high {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let rank_value = self.rank_value(ctx, day, &candidate, &stock)?;
|
let rank_value = self.rank_value(ctx, day, &candidate, &stock)?;
|
||||||
|
if !rank_value.is_finite() {
|
||||||
|
if diagnostics.len() < 12 {
|
||||||
|
diagnostics.push(format!(
|
||||||
|
"{} rejected by missing rank field",
|
||||||
|
candidate.symbol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
candidates.push((candidate, stock, rank_value));
|
candidates.push((candidate, stock, rank_value));
|
||||||
}
|
}
|
||||||
candidates.sort_by(|lhs, rhs| {
|
candidates.sort_by(|lhs, rhs| {
|
||||||
@@ -5070,6 +5107,120 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_strategy_treats_missing_stock_rolling_window_as_filter_reject() {
|
||||||
|
let date = d(2025, 2, 3);
|
||||||
|
let data = DataSet::from_components(
|
||||||
|
vec![Instrument {
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
name: "Short History Stock".to_string(),
|
||||||
|
board: "SZ".to_string(),
|
||||||
|
round_lot: 100,
|
||||||
|
listed_at: Some(d(2025, 1, 20)),
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".to_string(),
|
||||||
|
}],
|
||||||
|
vec![DailyMarketSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
timestamp: Some("2025-02-03 10:18:00".to_string()),
|
||||||
|
day_open: 10.0,
|
||||||
|
open: 10.0,
|
||||||
|
high: 10.3,
|
||||||
|
low: 9.9,
|
||||||
|
close: 10.1,
|
||||||
|
last_price: 10.05,
|
||||||
|
bid1: 10.04,
|
||||||
|
ask1: 10.05,
|
||||||
|
prev_close: 9.95,
|
||||||
|
volume: 1_000_000,
|
||||||
|
tick_volume: 10_000,
|
||||||
|
bid1_volume: 2_000,
|
||||||
|
ask1_volume: 2_000,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
paused: false,
|
||||||
|
upper_limit: 10.95,
|
||||||
|
lower_limit: 8.95,
|
||||||
|
price_tick: 0.01,
|
||||||
|
}],
|
||||||
|
vec![DailyFactorSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
market_cap_bn: 12.0,
|
||||||
|
free_float_cap_bn: 10.0,
|
||||||
|
pe_ttm: 8.0,
|
||||||
|
turnover_ratio: Some(22.0),
|
||||||
|
effective_turnover_ratio: Some(18.0),
|
||||||
|
extra_factors: BTreeMap::new(),
|
||||||
|
}],
|
||||||
|
vec![CandidateEligibility {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".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: 0,
|
||||||
|
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.SZ".to_string();
|
||||||
|
cfg.refresh_rate = 1;
|
||||||
|
cfg.max_positions = 1;
|
||||||
|
cfg.benchmark_short_ma_days = 1;
|
||||||
|
cfg.benchmark_long_ma_days = 1;
|
||||||
|
cfg.market_cap_lower_expr = "0".to_string();
|
||||||
|
cfg.market_cap_upper_expr = "100".to_string();
|
||||||
|
cfg.selection_limit_expr = "1".to_string();
|
||||||
|
cfg.stock_filter_expr = "rolling_mean(\"volume\", 60) > 0".to_string();
|
||||||
|
let mut strategy = PlatformExprStrategy::new(cfg);
|
||||||
|
|
||||||
|
let decision = strategy.on_day(&ctx).expect("platform decision");
|
||||||
|
|
||||||
|
assert!(decision.order_intents.is_empty());
|
||||||
|
assert!(
|
||||||
|
decision
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.contains("selected=0"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
decision
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.contains("000001.SZ rejected by stock_expr"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn platform_strategy_emits_target_shares_explicit_action() {
|
fn platform_strategy_emits_target_shares_explicit_action() {
|
||||||
let date = d(2025, 2, 3);
|
let date = d(2025, 2, 3);
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".to_string() },
|
ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".to_string() },
|
||||||
ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() },
|
ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() },
|
||||||
ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() },
|
ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() },
|
||||||
ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名。只内建这几个窗口;15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() },
|
ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名,按当前交易日前 N 个已完成交易日的收盘价计算;历史窗口不足时为 NaN,比较条件会自然不通过;15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() },
|
||||||
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名。只内建这几个窗口;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
|
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名,按当前交易日前 N 个已完成交易日的成交量计算,不包含回测当天未来成交量;历史窗口不足时为 NaN,比较条件会自然不通过;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
|
||||||
ManualField { name: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日数据库因子。数值字段返回数字,字符串字段返回字符串;字符串字段名如果是合法标识符,也可直接写字段名,例如 concept == \"ai_chip\"。".to_string() },
|
ManualField { name: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日数据库因子。数值字段返回数字,字符串字段返回字符串;字符串字段名如果是合法标识符,也可直接写字段名,例如 concept == \"ai_chip\"。".to_string() },
|
||||||
ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() },
|
ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() },
|
||||||
],
|
],
|
||||||
@@ -229,7 +229,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() },
|
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() },
|
||||||
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
|
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
|
||||||
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() },
|
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() },
|
||||||
ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".to_string() },
|
ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。个股 volume 与 close 均按当前交易日前已完成交易日计算;单只股票历史窗口不足时,在选股过滤和买入仓位表达式中按不通过/0 仓处理,不会中断整次回测。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".to_string() },
|
||||||
ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。任意价格均线窗口推荐用它,比如 sma(\"close\", 15)。".to_string() },
|
ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。任意价格均线窗口推荐用它,比如 sma(\"close\", 15)。".to_string() },
|
||||||
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
|
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
|
||||||
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
|
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
|
||||||
|
|||||||
Reference in New Issue
Block a user