From 5918a03456d5c91bb57ec800ee586f3ab87d1bd0 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 17 Jul 2026 13:26:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=9C=AA=E6=88=90=E4=BA=A4?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=8C=81=E4=BB=93=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + .../fidc-core/src/platform_expr_strategy.rs | 291 +++++++++++++++++- 2 files changed, 284 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 99562cc..d1fffef 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与真实 `volume=0` 含义不同。依赖成交量的 rolling 窗口只要包含源空值就返回缺失,不得把空值补成 0;停牌日明确提供的 0 成交量仍是合法观测。该合同随 runner 快照版本冻结,旧快照不能跨版本复用。 +`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。 + ## 内置微盘策略 `OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线: diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 386a258..17ec636 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1417,19 +1417,28 @@ impl PlatformExprStrategy { self.intraday_execution_start_time() } + fn uses_persistent_model_lifecycle(&self) -> bool { + self.config.hold_until_exit_enabled + && self.config.stop_take_reference_price_mode + == PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose + } + fn sync_position_entry_dates(&mut self, portfolio: &PortfolioState, signal_date: NaiveDate) { + let persistent_model_lifecycle = self.uses_persistent_model_lifecycle(); 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)); + if !persistent_model_lifecycle { + 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; @@ -1438,6 +1447,28 @@ impl PlatformExprStrategy { self.position_entry_dates .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); + } + + let lifecycle_symbols = if persistent_model_lifecycle { + self.position_entry_dates + .keys() + .cloned() + .collect::>() + } else { + portfolio + .positions() + .values() + .filter(|position| position.quantity > 0) + .map(|position| position.symbol.clone()) + .collect::>() + }; + for symbol in lifecycle_symbols { let holding_days = self .position_holding_days .entry(symbol.clone()) @@ -7125,6 +7156,34 @@ impl PlatformExprStrategy { symbols.insert(position.symbol.clone()); } } + if self.uses_persistent_model_lifecycle() { + for (symbol, entry_date) in &self.position_entry_dates { + if ctx + .portfolio + .position(symbol) + .is_some_and(|position| position.quantity > 0) + { + continue; + } + let entry_price = ctx + .data + .market_latest_back_adjusted_close(*entry_date, symbol) + .ok_or_else(|| { + BacktestError::Data(crate::data::DataSetError::MissingSnapshot { + kind: "model admission post-adjusted close", + date: *entry_date, + symbol: symbol.clone(), + }) + })?; + let mut model_position = crate::portfolio::Position::new(symbol.clone()); + model_position.buy(*entry_date, 1, entry_price); + let (stop_hit, profit_hit) = + self.stop_take_action_for_position(ctx, signal_date, day, &model_position)?; + if stop_hit || profit_hit { + symbols.insert(symbol.clone()); + } + } + } Ok(symbols) } @@ -8937,6 +8996,32 @@ impl Strategy for PlatformExprStrategy { let day = self.day_state(ctx, decision_date)?; let current_stop_take_exit_symbols = self.current_stop_take_exit_symbols(ctx, signal_date, &day)?; + let mut model_only_lifecycle_exit_symbols = current_stop_take_exit_symbols + .iter() + .filter(|symbol| { + !ctx.portfolio + .position(symbol) + .is_some_and(|position| position.quantity > 0) + }) + .cloned() + .collect::>(); + if self.uses_persistent_model_lifecycle() { + for symbol in self.position_entry_dates.keys() { + if ctx + .portfolio + .position(symbol) + .is_some_and(|position| position.quantity > 0) + { + continue; + } + if self.max_holding_days_exceeded(symbol).is_some() { + model_only_lifecycle_exit_symbols.insert(symbol.clone()); + } + } + for symbol in &model_only_lifecycle_exit_symbols { + self.forget_position_entry_date(symbol); + } + } let (selection_market_date, selection_universe_factor_date, selection_factor_date) = self.selection_dates(ctx); let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window @@ -9001,12 +9086,15 @@ impl Strategy for PlatformExprStrategy { )?; selection_notes = notes; risk_decisions = selection_risk_decisions; - let held_symbols = ctx + let mut held_symbols = ctx .portfolio .positions() .keys() .cloned() .collect::>(); + if self.uses_persistent_model_lifecycle() { + held_symbols.extend(self.position_entry_dates.keys().cloned()); + } let selected = Self::buffered_selection( &ranked_stock_list, &held_symbols, @@ -9069,7 +9157,7 @@ impl Strategy for PlatformExprStrategy { .unwrap_or(false); let mut daily_top_up_debug_notes = Vec::::new(); let mut projection_debug_notes = Vec::::new(); - let mut exit_symbols = BTreeSet::new(); + let mut exit_symbols = model_only_lifecycle_exit_symbols; let mut same_day_sold_symbols = BTreeSet::::new(); let mut intraday_attempted_buys = BTreeSet::::new(); let mut same_bar_buy_symbols = BTreeSet::::new(); @@ -9234,6 +9322,16 @@ impl Strategy for PlatformExprStrategy { }); self.forget_position_entry_date(symbol); } + if self.uses_persistent_model_lifecycle() { + let model_symbols = self + .position_entry_dates + .keys() + .cloned() + .collect::>(); + for symbol in model_symbols { + self.forget_position_entry_date(&symbol); + } + } let mut notes = vec![format!("seasonal stop window on {}", signal_date)]; if !delayed_sold_symbols.is_empty() { notes.push(format!( @@ -9273,6 +9371,9 @@ impl Strategy for PlatformExprStrategy { .filter(|symbol| !delayed_sold_symbols.contains(*symbol)) .cloned() .collect::>(); + if self.uses_persistent_model_lifecycle() { + slot_working_symbols.extend(self.position_entry_dates.keys().cloned()); + } let daily_top_up_active = self.config.daily_top_up_enabled && self.config.rotation_enabled && !periodic_rebalance @@ -9441,7 +9542,9 @@ impl Strategy for PlatformExprStrategy { && trading_ratio > 0.0 && (self.config.target_portfolio_daily_enabled || trading_ratio < 1.0) && selection_limit > 0 - && !ctx.portfolio.positions().is_empty() + && (!ctx.portfolio.positions().is_empty() + || (self.uses_persistent_model_lifecycle() + && !self.position_entry_dates.is_empty())) { if aiquant_total_value.is_finite() && aiquant_total_value > 0.0 { for position in ctx.portfolio.positions().values() { @@ -9548,6 +9651,63 @@ impl Strategy for PlatformExprStrategy { } } } + if self.uses_persistent_model_lifecycle() + && self.config.target_portfolio_daily_enabled + { + let model_only_symbols = self + .position_entry_dates + .keys() + .filter(|symbol| { + !ctx.portfolio + .position(symbol) + .is_some_and(|position| position.quantity > 0) + }) + .filter(|symbol| !exit_symbols.contains(*symbol)) + .cloned() + .collect::>(); + for symbol in model_only_symbols { + let decision_stock = self.stock_state_with_factor_date( + ctx, + decision_date, + selection_factor_date, + &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 before_qty = projected + .position(&symbol) + .map(|position| position.quantity) + .unwrap_or(0); + self.project_target_value( + ctx, + &mut projected, + projection_date, + &symbol, + target_value, + &mut projected_execution_state, + ); + let after_qty = projected + .position(&symbol) + .map(|position| position.quantity) + .unwrap_or(0); + order_intents.push(OrderIntent::TargetValue { + symbol: symbol.clone(), + target_value, + reason: "model_position_target_retry".to_string(), + }); + if defer_execution_risk { + deferred_daily_target_values.insert(symbol.clone(), target_value); + } + if after_qty > before_qty { + same_bar_buy_symbols.insert(symbol); + } + } + } aiquant_available_cash = projected.cash(); } } @@ -24149,6 +24309,119 @@ mod tests { assert_eq!(strategy.max_holding_days_exceeded(symbol), Some(3)); } + #[test] + fn platform_signal_price_model_lifecycle_survives_unfilled_buy_and_exits() { + let entry_date = d(2024, 1, 18); + let signal_date = d(2024, 1, 22); + let symbol = "600156.SH"; + let market = |date, close, prev_close| DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{} 15:00:00", date)), + day_open: close, + open: close, + high: close, + low: close, + close, + last_price: close, + bid1: close, + ask1: close, + prev_close, + volume: 1_000_000, + minute_volume: 0, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("close".to_string()), + paused: false, + upper_limit: close * 1.1, + lower_limit: close * 0.9, + price_tick: 0.01, + }; + let factor = |date| DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 20.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::from([("adjustment_factor_backward1".to_string(), 1.0)]), + }; + let data = DataSet::from_components( + 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![ + market(entry_date, 10.0, 9.8), + market(signal_date, 9.1, 10.0), + ], + vec![factor(entry_date), factor(signal_date)], + vec![CandidateEligibility { + date: signal_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, + }], + vec![BenchmarkSnapshot { + date: signal_date, + benchmark: symbol.to_string(), + open: 9.1, + close: 9.1, + prev_close: 10.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(10_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: signal_date, + decision_date: signal_date, + decision_index: 2, + 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.rotation_enabled = false; + cfg.hold_until_exit_enabled = true; + cfg.signal_symbol = symbol.to_string(); + cfg.benchmark_symbol = symbol.to_string(); + cfg.stop_loss_expr = "0.08".to_string(); + cfg.take_profit_expr.clear(); + cfg.stop_take_reference_price_mode = + PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.remember_position_entry_date(symbol, entry_date); + + let decision = strategy.on_day(&ctx).expect("decision"); + + assert!(decision.exit_symbols.contains(symbol), "{decision:?}"); + assert!(!strategy.position_entry_dates.contains_key(symbol)); + assert_eq!(strategy.position_holding_days.get(symbol), None); + } + #[test] fn platform_max_holding_days_exit_preempts_take_profit_exit() { let date = d(2025, 2, 26);