diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 5629d0a..0f138ee 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -231,6 +231,7 @@ pub struct PlatformExprStrategyConfig { pub daily_top_up_enabled: bool, pub retry_empty_rebalance: bool, pub calendar_rebalance_interval: bool, + pub max_holding_days: Option, pub weak_market_shrink_overweight_threshold: Option, pub aiquant_transaction_cost: bool, pub commission_rate: Option, @@ -298,6 +299,7 @@ fn band_low(index_close) { daily_top_up_enabled: false, retry_empty_rebalance: false, calendar_rebalance_interval: false, + max_holding_days: None, weak_market_shrink_overweight_threshold: None, aiquant_transaction_cost: false, commission_rate: None, @@ -560,6 +562,7 @@ pub struct PlatformExprStrategy { last_rebalance_date: Option, last_trading_ratio: Option, pending_highlimit_holdings: BTreeSet, + position_entry_dates: BTreeMap, /// 已编译表达式 AST 缓存。 /// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本, /// Value 是 Rhai 编译产物。命中后 eval 走 eval_ast_with_scope,避免重复 @@ -738,6 +741,7 @@ impl PlatformExprStrategy { last_rebalance_date: None, last_trading_ratio: None, pending_highlimit_holdings: BTreeSet::new(), + position_entry_dates: BTreeMap::new(), compiled_cache: RefCell::new(HashMap::new()), cache_hits: RefCell::new(0), cache_misses: RefCell::new(0), @@ -1072,6 +1076,42 @@ impl PlatformExprStrategy { self.intraday_execution_start_time() } + fn sync_position_entry_dates(&mut self, portfolio: &PortfolioState, signal_date: NaiveDate) { + self.position_entry_dates.retain(|symbol, _| { + portfolio + .position(symbol) + .map(|position| position.quantity > 0) + .unwrap_or(false) + }); + for position in portfolio.positions().values() { + if position.quantity == 0 { + continue; + } + self.position_entry_dates + .entry(position.symbol.clone()) + .or_insert(signal_date); + } + } + + fn remember_position_entry_date(&mut self, symbol: &str, signal_date: NaiveDate) { + if !symbol.trim().is_empty() { + self.position_entry_dates + .entry(symbol.to_string()) + .or_insert(signal_date); + } + } + + fn forget_position_entry_date(&mut self, symbol: &str) { + self.position_entry_dates.remove(symbol); + } + + fn max_holding_days_exceeded(&self, signal_date: NaiveDate, 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(); + (holding_days >= max_days).then_some(holding_days) + } + fn buy_commission(&self, gross_amount: f64) -> f64 { self.cost_model().commission_for(gross_amount) } @@ -7706,6 +7746,7 @@ impl Strategy for PlatformExprStrategy { } else { execution_date }; + self.sync_position_entry_dates(ctx.portfolio, signal_date); let in_skip_window = self.config.in_skip_window(signal_date); let day = self.day_state(ctx, decision_date)?; @@ -7891,6 +7932,7 @@ impl Strategy for PlatformExprStrategy { end_time: Some(delayed_limit_exit_time), reason: "delayed_limit_open_sell".to_string(), }); + self.forget_position_entry_date(&symbol); let projected_sold = if ctx .data .execution_quotes_on(projection_date, &symbol) @@ -7941,6 +7983,7 @@ impl Strategy for PlatformExprStrategy { target_value: 0.0, reason: "seasonal_stop_window".to_string(), }); + self.forget_position_entry_date(symbol); } let mut notes = vec![format!("seasonal stop window on {}", signal_date)]; if !delayed_sold_symbols.is_empty() { @@ -7983,6 +8026,51 @@ impl Strategy for PlatformExprStrategy { .collect::>(); let mut pending_full_close_symbols = BTreeSet::::new(); let risk_level_forced_exit_time = self.risk_level_forced_exit_time(); + if self.config.rotation_enabled + && let Some(max_holding_days) = self.config.max_holding_days.filter(|value| *value > 0) + { + for position in ctx.portfolio.positions().values() { + 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 { + continue; + }; + pending_full_close_symbols.insert(position.symbol.clone()); + exit_symbols.insert(position.symbol.clone()); + order_intents.push(OrderIntent::TargetValue { + symbol: position.symbol.clone(), + target_value: 0.0, + reason: "max_holding_days_exit".to_string(), + }); + self.forget_position_entry_date(&position.symbol); + let can_sell = defer_execution_risk + || self.can_sell_position(ctx, execution_date, &position.symbol); + if can_sell + && self + .project_target_zero( + ctx, + &mut projected, + projection_date, + &position.symbol, + &mut projected_execution_state, + ) + .is_some() + && Self::projected_position_is_flat(&projected, &position.symbol) + { + same_day_sold_symbols.insert(position.symbol.clone()); + slot_working_symbols.remove(&position.symbol); + } + if debug_projection { + projection_debug_notes.push(format!( + "max_holding_days_exit symbol={} holding_days={} max_holding_days={}", + position.symbol, holding_days, max_holding_days + )); + } + } + } if self.config.aiquant_transaction_cost { for position in ctx.portfolio.positions().values() { if position.quantity == 0 @@ -8058,6 +8146,7 @@ impl Strategy for PlatformExprStrategy { end_time: Some(risk_level_forced_exit_time), reason: "risk_forced_exit".to_string(), }); + self.forget_position_entry_date(&symbol); if self .project_target_zero_at_time( ctx, @@ -8157,7 +8246,9 @@ impl Strategy for PlatformExprStrategy { } for position in ctx.portfolio.positions().values() { - if delayed_sold_symbols.contains(&position.symbol) { + if delayed_sold_symbols.contains(&position.symbol) + || pending_full_close_symbols.contains(&position.symbol) + { continue; } let projected_position = projected.position(&position.symbol).unwrap_or(position); @@ -8187,6 +8278,7 @@ impl Strategy for PlatformExprStrategy { target_value: 0.0, reason: "stop_loss_exit".to_string(), }); + self.forget_position_entry_date(&position.symbol); if can_sell { if self .project_target_zero( @@ -8215,6 +8307,7 @@ impl Strategy for PlatformExprStrategy { target_value: 0.0, reason: "take_profit_exit".to_string(), }); + self.forget_position_entry_date(&position.symbol); if can_sell && self .project_target_zero( @@ -8343,6 +8436,7 @@ impl Strategy for PlatformExprStrategy { value: buy_cash, reason: "daily_top_up_buy".to_string(), }); + self.remember_position_entry_date(symbol, signal_date); } if filled_qty > 0 { let spent = (cash_before_buy - projected.cash()).max(0.0); @@ -8397,6 +8491,7 @@ impl Strategy for PlatformExprStrategy { target_value: 0.0, reason: "periodic_rebalance_sell".to_string(), }); + self.forget_position_entry_date(symbol); if self .project_target_zero( ctx, @@ -8488,6 +8583,7 @@ impl Strategy for PlatformExprStrategy { value: buy_cash, reason: "periodic_rebalance_buy".to_string(), }); + self.remember_position_entry_date(symbol, signal_date); let spent = (cash_before_buy - projected.cash()).max(0.0); aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); intraday_attempted_buys.insert(symbol.clone()); @@ -8581,6 +8677,7 @@ impl Strategy for PlatformExprStrategy { value: buy_cash, reason: "periodic_rebalance_buy".to_string(), }); + self.remember_position_entry_date(symbol, signal_date); let spent = (cash_before_buy - projected.cash()).max(0.0); aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); rebalance_working_symbols.insert(symbol.clone()); @@ -20133,6 +20230,170 @@ mod tests { ); } + #[test] + fn platform_max_holding_days_exit_preempts_take_profit_exit() { + let date = d(2025, 2, 26); + let symbol = "000001.SZ"; + let symbols = [symbol]; + let data = DataSet::from_components_with_actions_and_quotes( + symbols + .iter() + .map(|symbol| 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(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-02-26 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: 8.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + symbols + .iter() + .map(|symbol| CandidateEligibility { + date, + symbol: (*symbol).to_string(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }) + .collect(), + 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, 18, 0).expect("valid timestamp"), + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 100_000.0, + trading_phase: Some("continuous".to_string()), + }], + ) + .expect("dataset"); + + let entry_date = date - chrono::Duration::days(90); + let mut portfolio = PortfolioState::new(10_000.0); + portfolio.position_mut(symbol).buy(entry_date, 100, 8.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 20, + 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 = symbol.to_string(); + cfg.refresh_rate = 99; + 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 = "close > 0".to_string(); + cfg.take_profit_expr = "1.1".to_string(); + cfg.max_holding_days = Some(90); + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()); + let mut strategy = PlatformExprStrategy::new(cfg); + strategy + .position_entry_dates + .insert(symbol.to_string(), entry_date); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol: intent_symbol, + target_value, + reason, + } if intent_symbol == symbol && *target_value == 0.0 && reason == "max_holding_days_exit" + )), + "{:?}", + decision.order_intents + ); + assert!( + !decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol: intent_symbol, + reason, + .. + } if intent_symbol == symbol && reason == "take_profit_exit" + )), + "{:?}", + decision.order_intents + ); + } + #[test] fn platform_delayed_open_exit_uses_delayed_time_for_slot_projection() { let prev_date = d(2025, 2, 25); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index b0bfe15..dbb3f49 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -177,6 +177,14 @@ pub struct StrategyEngineConfig { pub dividend_reinvestment: Option, #[serde(default)] pub weak_market_shrink_overweight_threshold: Option, + #[serde( + default, + alias = "max_hold_days", + alias = "max_holding_days", + alias = "maxHoldDays", + alias = "maxHoldingDays" + )] + pub max_holding_days: Option, #[serde(default)] pub rebalance_schedule: Option, #[serde(default)] @@ -676,6 +684,14 @@ pub struct StrategyExpressionTradingConfig { pub retry_empty_rebalance: Option, #[serde(default)] pub weak_market_shrink_overweight_threshold: Option, + #[serde( + default, + alias = "max_hold_days", + alias = "max_holding_days", + alias = "maxHoldDays", + alias = "maxHoldingDays" + )] + pub max_holding_days: Option, #[serde(default)] pub delayed_limit_open_exit: Option, #[serde(default)] @@ -1233,6 +1249,9 @@ pub fn platform_expr_config_from_spec( { cfg.weak_market_shrink_overweight_threshold = Some(threshold); } + if let Some(days) = engine.max_holding_days.filter(|value| *value > 0) { + cfg.max_holding_days = Some(days); + } if let Some(schedule) = engine .rebalance_schedule .as_ref() @@ -1520,6 +1539,9 @@ pub fn platform_expr_config_from_spec( { cfg.weak_market_shrink_overweight_threshold = Some(threshold); } + if let Some(days) = trading.max_holding_days.filter(|value| *value > 0) { + cfg.max_holding_days = Some(days); + } if let Some(enabled) = trading.release_slot_on_exit_signal { cfg.release_slot_on_exit_signal = enabled; } @@ -2248,6 +2270,24 @@ mod tests { assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.2)); } + #[test] + fn parses_max_holding_days_from_engine_and_runtime_trading() { + let spec = serde_json::json!({ + "engineConfig": { + "maxHoldDays": 120 + }, + "runtimeExpressions": { + "trading": { + "max_holding_days": 90 + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!(cfg.max_holding_days, Some(90)); + } + #[test] fn parses_signal_dates_rebalance_into_platform_config() { let spec = serde_json::json!({