完善生命周期持仓与当前日滚动语义

This commit is contained in:
boris
2026-07-17 07:34:59 +08:00
parent 9d47d06064
commit 01b2ca02ff
4 changed files with 332 additions and 27 deletions
+12
View File
@@ -2362,6 +2362,18 @@ impl DataSet {
}
}
pub fn market_current_numeric_values(
&self,
date: NaiveDate,
symbol: &str,
field: &str,
lookback: usize,
) -> Vec<f64> {
self.market_series(symbol)
.map(|series| series.trailing_numeric_values(date, lookback, field, true))
.unwrap_or_default()
}
pub fn factor_numeric_values(
&self,
date: NaiveDate,
+261 -21
View File
@@ -339,6 +339,7 @@ pub struct PlatformExprStrategyConfig {
pub market_cap_lower_expr: String,
pub market_cap_upper_expr: String,
pub selection_limit_expr: String,
pub selection_candidate_limit_expr: String,
pub stock_filter_expr: String,
pub buy_scale_expr: String,
pub exposure_expr: String,
@@ -359,7 +360,9 @@ pub struct PlatformExprStrategyConfig {
pub rotation_enabled: bool,
pub daily_top_up_enabled: bool,
pub daily_position_target_adjust_enabled: bool,
pub target_portfolio_daily_enabled: bool,
pub rebalance_existing_positions: bool,
pub hold_until_exit_enabled: bool,
pub selection_buffer_multiple: f64,
pub daily_replacement_limit: usize,
pub retry_empty_rebalance: bool,
@@ -412,6 +415,7 @@ fn band_low(index_close) {
market_cap_lower_expr: "band_low(signal_close)".to_string(),
market_cap_upper_expr: "band_low(signal_close) + 10".to_string(),
selection_limit_expr: "stocknum".to_string(),
selection_candidate_limit_expr: String::new(),
stock_filter_expr:
"stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long"
.to_string(),
@@ -434,7 +438,9 @@ fn band_low(index_close) {
rotation_enabled: true,
daily_top_up_enabled: false,
daily_position_target_adjust_enabled: true,
target_portfolio_daily_enabled: false,
rebalance_existing_positions: false,
hold_until_exit_enabled: false,
selection_buffer_multiple: 1.0,
daily_replacement_limit: 0,
retry_empty_rebalance: false,
@@ -750,6 +756,8 @@ pub struct PlatformExprStrategy {
pending_highlimit_holdings: BTreeSet<String>,
pending_full_close_symbols: BTreeSet<String>,
position_entry_dates: BTreeMap<String, NaiveDate>,
position_holding_days: BTreeMap<String, i64>,
position_holding_days_last_counted: BTreeMap<String, NaiveDate>,
/// 已编译表达式 AST 缓存。
/// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本,
/// Value 是 Rhai 编译产物。命中后 eval 走 eval_ast_with_scope,避免重复
@@ -1032,6 +1040,8 @@ impl PlatformExprStrategy {
pending_highlimit_holdings: BTreeSet::new(),
pending_full_close_symbols: BTreeSet::new(),
position_entry_dates: BTreeMap::new(),
position_holding_days: BTreeMap::new(),
position_holding_days_last_counted: BTreeMap::new(),
compiled_cache: RefCell::new(HashMap::new()),
cache_hits: RefCell::new(0),
cache_misses: RefCell::new(0),
@@ -1319,6 +1329,8 @@ impl PlatformExprStrategy {
| "day_factor"
| "rolling_mean"
| "rolling_mean_current"
| "rolling_max_current"
| "rolling_return_stddev_current"
| "ma"
| "sma"
| "vma"
@@ -1398,38 +1410,73 @@ impl PlatformExprStrategy {
}
fn sync_position_entry_dates(&mut self, portfolio: &PortfolioState, signal_date: NaiveDate) {
self.position_entry_dates.retain(|symbol, _| {
let is_held = |symbol: &str| {
portfolio
.position(symbol)
.map(|position| position.quantity > 0)
.unwrap_or(false)
});
};
self.position_entry_dates
.retain(|symbol, _| is_held(symbol));
self.position_holding_days
.retain(|symbol, _| is_held(symbol));
self.position_holding_days_last_counted
.retain(|symbol, _| is_held(symbol));
for position in portfolio.positions().values() {
if position.quantity == 0 {
continue;
}
let symbol = position.symbol.clone();
self.position_entry_dates
.entry(position.symbol.clone())
.entry(symbol.clone())
.or_insert(signal_date);
let holding_days = self
.position_holding_days
.entry(symbol.clone())
.or_insert(0);
match self
.position_holding_days_last_counted
.get(&symbol)
.copied()
{
Some(last_counted) if last_counted < signal_date => {
*holding_days = holding_days.saturating_add(1);
self.position_holding_days_last_counted
.insert(symbol, signal_date);
}
None => {
self.position_holding_days_last_counted
.insert(symbol, signal_date);
}
_ => {}
}
}
}
fn remember_position_entry_date(&mut self, symbol: &str, signal_date: NaiveDate) {
if !symbol.trim().is_empty() {
let symbol = symbol.to_string();
self.position_entry_dates
.entry(symbol.to_string())
.entry(symbol.clone())
.or_insert(signal_date);
self.position_holding_days
.entry(symbol.clone())
.or_insert(0);
self.position_holding_days_last_counted
.entry(symbol)
.or_insert(signal_date);
}
}
fn forget_position_entry_date(&mut self, symbol: &str) {
self.position_entry_dates.remove(symbol);
self.position_holding_days.remove(symbol);
self.position_holding_days_last_counted.remove(symbol);
}
fn max_holding_days_exceeded(&self, signal_date: NaiveDate, symbol: &str) -> Option<i64> {
fn max_holding_days_exceeded(&self, symbol: &str) -> Option<i64> {
let max_days = self.config.max_holding_days.filter(|value| *value > 0)?;
let entry_date = *self.position_entry_dates.get(symbol)?;
let holding_days = signal_date.signed_duration_since(entry_date).num_days();
let holding_days = *self.position_holding_days.get(symbol)?;
(holding_days >= max_days).then_some(holding_days)
}
@@ -4694,6 +4741,45 @@ impl PlatformExprStrategy {
Dynamic::from(value),
))
}
"rolling_max_current" => {
if args.len() != 2 {
return Err(BacktestError::Execution(
"rolling_max_current expects field and lookback".to_string(),
));
}
let field = Self::parse_string_or_identifier(&args[0])?;
let lookback = Self::parse_positive_usize(&args[1])?;
let values =
self.resolve_current_rolling_values(ctx, day, stock, &field, lookback)?;
let value = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Ok(Self::format_rhai_float(value))
}
"rolling_return_stddev_current" => {
if args.len() != 2 {
return Err(BacktestError::Execution(
"rolling_return_stddev_current expects field and return count".to_string(),
));
}
let field = Self::parse_string_or_identifier(&args[0])?;
let return_count = Self::parse_positive_usize(&args[1])?;
let values = self.resolve_current_rolling_values(
ctx,
day,
stock,
&field,
return_count.saturating_add(1),
)?;
let returns = values
.windows(2)
.map(|pair| pair[1] / pair[0] - 1.0)
.collect::<Vec<_>>();
if returns.iter().any(|value| !value.is_finite()) {
return Err(BacktestError::Execution(format!(
"invalid current rolling return for field {field} with count {return_count}"
)));
}
Ok(Self::format_rhai_float(rolling_sample_stddev(&returns)))
}
"vma" => {
if args.len() != 1 {
return Err(BacktestError::Execution(
@@ -5381,12 +5467,69 @@ impl PlatformExprStrategy {
Ok(values)
}
fn resolve_current_rolling_values(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
field: &str,
lookback: usize,
) -> Result<Vec<f64>, BacktestError> {
if lookback == 0 {
return Err(BacktestError::Execution(
"current rolling lookback must be positive".to_string(),
));
}
let values = match field {
"benchmark_open" => ctx
.data
.benchmark_numeric_values(day.date, "open", lookback),
"benchmark_close" => ctx
.data
.benchmark_numeric_values(day.date, "close", lookback),
"signal_open" => ctx.data.market_current_numeric_values(
day.date,
&self.config.signal_symbol,
"open",
lookback,
),
"signal_close" => ctx.data.market_current_numeric_values(
day.date,
&self.config.signal_symbol,
"close",
lookback,
),
"signal_volume" => ctx.data.market_current_numeric_values(
day.date,
&self.config.signal_symbol,
"volume",
lookback,
),
other => {
let stock = stock.ok_or_else(|| {
BacktestError::Execution(format!(
"current rolling helper for {other} requires stock context"
))
})?;
ctx.data
.market_current_numeric_values(day.date, &stock.symbol, other, lookback)
}
};
if values.len() < lookback {
return Err(BacktestError::Execution(format!(
"missing current rolling values for field {field} with lookback {lookback}"
)));
}
Ok(values)
}
fn is_missing_rolling_mean_error(error: &BacktestError) -> bool {
matches!(
error,
BacktestError::Execution(message)
if message.starts_with("missing rolling mean for field ")
|| message.starts_with("missing current rolling mean for field ")
|| message.starts_with("missing current rolling values for field ")
)
}
@@ -5843,6 +5986,25 @@ impl PlatformExprStrategy {
Ok(value.round().max(1.0) as usize)
}
fn selection_candidate_limit(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
selection_limit: usize,
) -> Result<usize, BacktestError> {
let expr = self.config.selection_candidate_limit_expr.trim();
if expr.is_empty() {
return Ok(self.selection_buffer_rank(selection_limit));
}
let value = self.eval_float(ctx, expr, day, None, None)?;
if !value.is_finite() {
return Err(BacktestError::Execution(format!(
"platform selection_candidate_limit_expr did not produce a finite number: {expr}"
)));
}
Ok(value.round().max(selection_limit as f64) as usize)
}
fn effective_refresh_rate(
&self,
ctx: &StrategyContext<'_>,
@@ -8437,7 +8599,7 @@ impl PlatformExprStrategy {
let selection_limit = self
.selection_limit(ctx, &day)?
.min(self.config.max_positions.max(1));
let quote_selection_limit = self.selection_buffer_rank(selection_limit);
let quote_selection_limit = self.selection_candidate_limit(ctx, &day, selection_limit)?;
let (candidate_symbols, order_symbols, processed_scope, diagnostics) = self
.select_quote_plan_symbols(
ctx,
@@ -8754,7 +8916,8 @@ impl Strategy for PlatformExprStrategy {
};
let mut risk_decisions = Vec::new();
let stock_list = if self.config.rotation_enabled && !in_skip_window {
let selection_buffer_rank = self.selection_buffer_rank(selection_limit);
let selection_buffer_rank =
self.selection_candidate_limit(ctx, &day, selection_limit)?;
let ranked_selection_limit = if self.config.daily_replacement_limit > 0 {
usize::MAX
} else {
@@ -8778,13 +8941,25 @@ impl Strategy for PlatformExprStrategy {
.keys()
.cloned()
.collect::<BTreeSet<_>>();
Self::buffered_selection(
let selected = Self::buffered_selection(
&ranked_stock_list,
&held_symbols,
selection_limit,
selection_buffer_rank,
self.config.daily_replacement_limit,
)
);
if self.config.hold_until_exit_enabled {
let mut candidates = selected;
let mut seen = candidates.iter().cloned().collect::<BTreeSet<_>>();
for symbol in ranked_stock_list.iter().take(selection_buffer_rank) {
if seen.insert(symbol.clone()) {
candidates.push(symbol.clone());
}
}
candidates
} else {
selected
}
} else {
Vec::new()
};
@@ -8792,7 +8967,9 @@ impl Strategy for PlatformExprStrategy {
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 && !in_skip_window {
if !self.config.signal_rebalance_dates.is_empty() {
if self.config.hold_until_exit_enabled {
self.last_rebalance_date.is_none() || empty_rebalance_retry
} else if !self.config.signal_rebalance_dates.is_empty() {
self.config.signal_rebalance_dates.contains(&decision_date) || empty_rebalance_retry
} else if let Some(schedule) = &self.config.rebalance_schedule {
schedule.matches(
@@ -9059,9 +9236,7 @@ impl Strategy for PlatformExprStrategy {
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
continue;
}
let Some(holding_days) =
self.max_holding_days_exceeded(signal_date, &position.symbol)
else {
let Some(holding_days) = self.max_holding_days_exceeded(&position.symbol) else {
continue;
};
pending_full_close_symbols.insert(position.symbol.clone());
@@ -9193,12 +9368,11 @@ impl Strategy for PlatformExprStrategy {
&& self.config.rotation_enabled
&& self.config.daily_position_target_adjust_enabled
&& trading_ratio > 0.0
&& trading_ratio < 1.0
&& (self.config.target_portfolio_daily_enabled || trading_ratio < 1.0)
&& selection_limit > 0
&& !ctx.portfolio.positions().is_empty()
{
let target_value = aiquant_total_value * trading_ratio / selection_limit as f64;
if target_value.is_finite() && target_value > 0.0 {
if aiquant_total_value.is_finite() && aiquant_total_value > 0.0 {
for position in ctx.portfolio.positions().values() {
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
continue;
@@ -9216,13 +9390,26 @@ impl Strategy for PlatformExprStrategy {
if pending_full_close_symbols.contains(&position.symbol) {
continue;
}
let decision_stock = self.stock_state_with_factor_date(
ctx,
decision_date,
selection_factor_date,
&position.symbol,
)?;
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
let target_value =
aiquant_total_value * trading_ratio / selection_limit as f64 * stock_scale;
if !target_value.is_finite() || target_value <= 0.0 {
continue;
}
let current_value = self.projected_position_value_at_execution_price(
ctx,
&projected,
projection_date,
&position.symbol,
);
if let Some(threshold) = self
if !self.config.target_portfolio_daily_enabled
&& let Some(threshold) = self
.config
.weak_market_shrink_overweight_threshold
.filter(|value| value.is_finite() && *value > 0.0)
@@ -9255,7 +9442,8 @@ impl Strategy for PlatformExprStrategy {
{
continue;
}
if self
if !self.config.target_portfolio_daily_enabled
&& self
.config
.weak_market_shrink_overweight_threshold
.is_some()
@@ -10010,6 +10198,9 @@ impl Strategy for PlatformExprStrategy {
}
}
}
if self.config.hold_until_exit_enabled && periodic_rebalance {
self.last_rebalance_date = Some(signal_date);
}
if self.config.rotation_enabled && self.config.rebalance_schedule.is_none() {
if self.config.calendar_rebalance_interval {
if periodic_rebalance {
@@ -10136,6 +10327,22 @@ fn rolling_stddev(values: &[f64]) -> f64 {
variance.sqrt()
}
fn rolling_sample_stddev(values: &[f64]) -> f64 {
if values.len() < 2 {
return 0.0;
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values
.iter()
.map(|value| {
let diff = value - mean;
diff * diff
})
.sum::<f64>()
/ (values.len() - 1) as f64;
variance.sqrt()
}
fn rolling_zscore(values: &[f64]) -> f64 {
let Some(latest) = values.last().copied() else {
return 0.0;
@@ -20279,7 +20486,7 @@ mod tests {
cfg.market_cap_upper_expr = "100".to_string();
cfg.selection_limit_expr = "1".to_string();
cfg.stock_filter_expr = concat!(
"rolling_mean(\"volume\", 60) > 0",
"rolling_max_current(\"volume\", 60) > 0",
" && pct_change(\"close\", 1) > 0.0"
)
.to_string();
@@ -20765,6 +20972,9 @@ mod tests {
" && vma(2) == 150.0",
" && rolling_mean_current(\"close\", 2) == 11.7",
" && rolling_mean_current(\"volume\", 2) == 250.0",
" && rolling_max_current(\"close\", 2) == 12.2",
" && rolling_return_stddev_current(\"close\", 2) > 0.006",
" && rolling_return_stddev_current(\"close\", 2) < 0.007",
" && rolling_sum(\"volume\", 2) == 300.0",
" && rolling_min(\"close\", 2) == 11.0",
" && rolling_max(\"close\", 2) == 12.0",
@@ -23694,6 +23904,30 @@ mod tests {
assert!(top_up_symbols.is_empty(), "{top_up_symbols:?}");
}
#[test]
fn platform_max_holding_days_counts_decision_trading_days_not_calendar_days() {
let symbol = "000001.SZ";
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.max_holding_days = Some(3);
let mut strategy = PlatformExprStrategy::new(cfg);
let entry_signal_date = d(2025, 1, 2);
strategy.remember_position_entry_date(symbol, entry_signal_date);
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.position_mut(symbol)
.buy(entry_signal_date, 100, 10.0);
strategy.sync_position_entry_dates(&portfolio, d(2025, 1, 3));
strategy.sync_position_entry_dates(&portfolio, d(2025, 1, 3));
strategy.sync_position_entry_dates(&portfolio, d(2025, 1, 6));
assert_eq!(strategy.position_holding_days.get(symbol), Some(&2));
assert_eq!(strategy.max_holding_days_exceeded(symbol), None);
strategy.sync_position_entry_dates(&portfolio, d(2025, 1, 7));
assert_eq!(strategy.max_holding_days_exceeded(symbol), Some(3));
}
#[test]
fn platform_max_holding_days_exit_preempts_take_profit_exit() {
let date = d(2025, 2, 26);
@@ -23829,6 +24063,12 @@ mod tests {
strategy
.position_entry_dates
.insert(symbol.to_string(), entry_date);
strategy
.position_holding_days
.insert(symbol.to_string(), 90);
strategy
.position_holding_days_last_counted
.insert(symbol.to_string(), date);
let decision = strategy.on_day(&ctx).expect("platform decision");
@@ -228,6 +228,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
"day_factor",
"rolling_mean",
"rolling_mean_current",
"rolling_max_current",
"rolling_return_stddev_current",
"ma",
"sma",
"vma",
@@ -640,6 +640,8 @@ pub struct StrategyExpressionSelectionConfig {
#[serde(default)]
pub limit_expr: Option<String>,
#[serde(default)]
pub candidate_limit_expr: Option<String>,
#[serde(default)]
pub market_cap_field: Option<String>,
#[serde(default)]
pub market_cap_lower_expr: Option<String>,
@@ -711,8 +713,12 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)]
pub daily_position_target_adjust: Option<bool>,
#[serde(default)]
pub target_portfolio_daily: Option<bool>,
#[serde(default)]
pub rebalance_existing_positions: Option<bool>,
#[serde(default)]
pub hold_until_exit: Option<bool>,
#[serde(default)]
pub selection_buffer_multiple: Option<f64>,
#[serde(default)]
pub daily_replacement_limit: Option<usize>,
@@ -1549,6 +1555,13 @@ pub fn platform_expr_config_from_spec(
{
cfg.selection_limit_expr = expr.clone();
}
if let Some(expr) = selection
.candidate_limit_expr
.as_ref()
.filter(|value| !value.trim().is_empty())
{
cfg.selection_candidate_limit_expr = expr.clone();
}
if let Some(field) = selection
.market_cap_field
.as_ref()
@@ -1695,9 +1708,18 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = trading.daily_position_target_adjust {
cfg.daily_position_target_adjust_enabled = enabled;
}
if let Some(enabled) = trading.target_portfolio_daily {
cfg.target_portfolio_daily_enabled = enabled;
if enabled {
cfg.daily_position_target_adjust_enabled = true;
}
}
if let Some(enabled) = trading.rebalance_existing_positions {
cfg.rebalance_existing_positions = enabled;
}
if let Some(enabled) = trading.hold_until_exit {
cfg.hold_until_exit_enabled = enabled;
}
if let Some(multiple) = trading
.selection_buffer_multiple
.filter(|value| value.is_finite() && *value >= 1.0)
@@ -2479,6 +2501,35 @@ mod tests {
assert_eq!(cfg.max_holding_days, Some(90));
}
#[test]
fn parses_lifecycle_target_portfolio_contract() {
let spec = serde_json::json!({
"runtimeExpressions": {
"selection": {
"limitExpr": "30",
"candidateLimitExpr": "50"
},
"trading": {
"dailyPositionTargetAdjust": true,
"targetPortfolioDaily": true,
"rebalanceExistingPositions": true,
"holdUntilExit": true,
"releaseSlotOnExitSignal": true
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.selection_limit_expr, "30");
assert_eq!(cfg.selection_candidate_limit_expr, "50");
assert!(cfg.daily_position_target_adjust_enabled);
assert!(cfg.target_portfolio_daily_enabled);
assert!(cfg.rebalance_existing_positions);
assert!(cfg.hold_until_exit_enabled);
assert!(cfg.release_slot_on_exit_signal);
}
#[test]
fn parses_signal_dates_rebalance_into_platform_config() {
let spec = serde_json::json!({