diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 493b507..1433342 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -2362,6 +2362,18 @@ impl DataSet { } } + pub fn market_current_numeric_values( + &self, + date: NaiveDate, + symbol: &str, + field: &str, + lookback: usize, + ) -> Vec { + 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, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 458d584..d06ceee 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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, pending_full_close_symbols: BTreeSet, position_entry_dates: BTreeMap, + position_holding_days: BTreeMap, + position_holding_days_last_counted: BTreeMap, /// 已编译表达式 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 { + fn max_holding_days_exceeded(&self, symbol: &str) -> Option { 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::>(); + 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, 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 { + 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::>(); - 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::>(); + 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,16 +9390,29 @@ 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 - .config - .weak_market_shrink_overweight_threshold - .filter(|value| value.is_finite() && *value > 0.0) + 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) { if current_value <= target_value * threshold { continue; @@ -9255,10 +9442,11 @@ impl Strategy for PlatformExprStrategy { { continue; } - if self - .config - .weak_market_shrink_overweight_threshold - .is_some() + if !self.config.target_portfolio_daily_enabled + && self + .config + .weak_market_shrink_overweight_threshold + .is_some() && quantity_delta >= 0 { continue; @@ -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::() / values.len() as f64; + let variance = values + .iter() + .map(|value| { + let diff = value - mean; + diff * diff + }) + .sum::() + / (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"); diff --git a/crates/fidc-core/src/platform_runtime_schema.rs b/crates/fidc-core/src/platform_runtime_schema.rs index 9cc3428..272328d 100644 --- a/crates/fidc-core/src/platform_runtime_schema.rs +++ b/crates/fidc-core/src/platform_runtime_schema.rs @@ -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", diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 24c3607..a826baf 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -640,6 +640,8 @@ pub struct StrategyExpressionSelectionConfig { #[serde(default)] pub limit_expr: Option, #[serde(default)] + pub candidate_limit_expr: Option, + #[serde(default)] pub market_cap_field: Option, #[serde(default)] pub market_cap_lower_expr: Option, @@ -711,8 +713,12 @@ pub struct StrategyExpressionTradingConfig { #[serde(default)] pub daily_position_target_adjust: Option, #[serde(default)] + pub target_portfolio_daily: Option, + #[serde(default)] pub rebalance_existing_positions: Option, #[serde(default)] + pub hold_until_exit: Option, + #[serde(default)] pub selection_buffer_multiple: Option, #[serde(default)] pub daily_replacement_limit: Option, @@ -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!({