diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 92c177f..6e74eec 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -6508,14 +6508,147 @@ impl PlatformExprStrategy { Ok((intents, diagnostics)) } + fn suppress_explicit_action_exit_conflicts( + intents: Vec, + exit_symbols: &BTreeSet, + ) -> (Vec, Vec) { + if exit_symbols.is_empty() { + return (intents, Vec::new()); + } + + let mut filtered = Vec::with_capacity(intents.len()); + let mut diagnostics = Vec::new(); + for intent in intents { + if let OrderIntent::TargetPortfolioSmart { + mut target_weights, + order_prices, + valuation_prices, + reason, + } = intent + { + let suppressed = target_weights + .keys() + .filter(|symbol| exit_symbols.contains(*symbol)) + .cloned() + .collect::>(); + for symbol in &suppressed { + target_weights.remove(symbol); + diagnostics.push(format!( + "explicit_action_exit_conflict_suppressed symbol={} action=target_portfolio_smart reason={}", + symbol, reason + )); + } + filtered.push(OrderIntent::TargetPortfolioSmart { + target_weights, + order_prices, + valuation_prices, + reason, + }); + continue; + } + + let conflict = match &intent { + OrderIntent::Shares { + symbol, quantity, .. + } + | OrderIntent::LimitShares { + symbol, quantity, .. + } => (*quantity > 0).then_some(symbol), + OrderIntent::Lots { symbol, lots, .. } + | OrderIntent::LimitLots { symbol, lots, .. } => (*lots > 0).then_some(symbol), + OrderIntent::TargetShares { + symbol, + target_quantity, + .. + } + | OrderIntent::LimitTargetShares { + symbol, + target_quantity, + .. + } => (*target_quantity > 0).then_some(symbol), + OrderIntent::TargetValue { + symbol, + target_value, + .. + } + | OrderIntent::TimedTargetValue { + symbol, + target_value, + .. + } + | OrderIntent::LimitTargetValue { + symbol, + target_value, + .. + } => (*target_value > 0.0).then_some(symbol), + OrderIntent::Value { symbol, value, .. } + | OrderIntent::LimitValue { symbol, value, .. } + | OrderIntent::AlgoValue { symbol, value, .. } => (*value > 0.0).then_some(symbol), + OrderIntent::Percent { + symbol, percent, .. + } + | OrderIntent::LimitPercent { + symbol, percent, .. + } + | OrderIntent::AlgoPercent { + symbol, percent, .. + } => (*percent > 0.0).then_some(symbol), + OrderIntent::TargetPercent { + symbol, + target_percent, + .. + } + | OrderIntent::LimitTargetPercent { + symbol, + target_percent, + .. + } => (*target_percent > 0.0).then_some(symbol), + _ => None, + }; + if let Some(symbol) = conflict.filter(|symbol| exit_symbols.contains(*symbol)) { + diagnostics.push(format!( + "explicit_action_exit_conflict_suppressed symbol={} action=positive_order", + symbol + )); + continue; + } + filtered.push(intent); + } + (filtered, diagnostics) + } + + fn current_stop_take_exit_symbols( + &self, + ctx: &StrategyContext<'_>, + signal_date: NaiveDate, + day: &DayExpressionState, + ) -> Result, BacktestError> { + let mut symbols = BTreeSet::new(); + for position in ctx.portfolio.positions().values() { + if position.quantity == 0 { + continue; + } + let (stop_hit, profit_hit) = + self.stop_take_action_for_position(ctx, signal_date, day, position)?; + if stop_hit || profit_hit { + symbols.insert(position.symbol.clone()); + } + } + Ok(symbols) + } + fn explicit_action_decision( &self, ctx: &StrategyContext<'_>, ) -> Result { let decision_date = ctx.decision_date; let day = self.day_state(ctx, decision_date)?; - let (order_intents, action_diagnostics) = + let exit_symbols = self.current_stop_take_exit_symbols(ctx, decision_date, &day)?; + let (order_intents, mut action_diagnostics) = self.explicit_action_intents(ctx, decision_date, &day)?; + let (order_intents, conflict_diagnostics) = + Self::suppress_explicit_action_exit_conflicts(order_intents, &exit_symbols); + action_diagnostics.extend(conflict_diagnostics); let mut diagnostics = vec![format!( "platform_expr signal={} last={:.2} explicit_actions={} stage={}", self.config.signal_symbol, @@ -8281,9 +8414,11 @@ impl Strategy for PlatformExprStrategy { let in_skip_window = self.config.in_skip_window(signal_date); 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 (selection_market_date, selection_universe_factor_date, selection_factor_date) = self.selection_dates(ctx); - let (explicit_action_intents, explicit_action_diagnostics) = if !in_skip_window + let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window && self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay && self.explicit_actions_active(ctx.data.calendar(), signal_date) { @@ -8291,6 +8426,12 @@ impl Strategy for PlatformExprStrategy { } else { (Vec::new(), Vec::new()) }; + let (explicit_action_intents, conflict_diagnostics) = + Self::suppress_explicit_action_exit_conflicts( + explicit_action_intents, + ¤t_stop_take_exit_symbols, + ); + explicit_action_diagnostics.extend(conflict_diagnostics); let mut selection_notes = Vec::new(); let trading_ratio = if self.config.rotation_enabled && !in_skip_window { self.trading_ratio(ctx, &day)? @@ -8734,37 +8875,7 @@ impl Strategy for PlatformExprStrategy { } } - let mut stop_take_exit_signal_symbols = BTreeSet::::new(); - if self.config.aiquant_transaction_cost - && self.config.rotation_enabled - && trading_ratio > 0.0 - && trading_ratio < 1.0 - && selection_limit > 0 - { - for position in ctx.portfolio.positions().values() { - if position.quantity == 0 - || delayed_sold_symbols.contains(&position.symbol) - || pending_full_close_symbols.contains(&position.symbol) - || self.pending_highlimit_holdings.contains(&position.symbol) - { - continue; - } - if self.regular_sell_should_wait_due_to_highlimit( - ctx, - projection_date, - &position.symbol, - self.intraday_execution_start_time(), - )? { - continue; - } - let (stop_hit, profit_hit) = - self.stop_take_action_for_position(ctx, signal_date, &day, position)?; - if !stop_hit && !profit_hit { - continue; - } - stop_take_exit_signal_symbols.insert(position.symbol.clone()); - } - } + let stop_take_exit_signal_symbols = current_stop_take_exit_symbols.clone(); if self.config.aiquant_transaction_cost && self.config.rotation_enabled @@ -27229,6 +27340,153 @@ mod tests { } } + #[test] + fn platform_strategy_suppresses_explicit_target_for_blocked_stop_loss_exit() { + let prev_date = d(2025, 1, 31); + let date = d(2025, 2, 3); + let symbols = ["000001.SZ", "000002.SZ"]; + let data = DataSet::from_components( + 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-03 09:30:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.01, + prev_close: 11.11, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("open_auction".to_string()), + paused: false, + upper_limit: 12.22, + lower_limit: 10.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: 10.0 + index as f64, + free_float_cap_bn: 10.0 + index as f64, + 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: 1000.0, + prev_close: 1000.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 10_000, 12.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 1, + 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.rotation_enabled = false; + cfg.aiquant_transaction_cost = true; + cfg.matching_type = MatchingType::NextBarOpen; + cfg.stop_loss_expr = "0.9".to_string(); + cfg.take_profit_expr.clear(); + cfg.explicit_actions = vec![PlatformTradeAction::TargetPortfolioSmart { + target_weights_expr: "{\"000001.SZ\": 0.50, \"000002.SZ\": 0.50}".to_string(), + order_prices_expr: None, + valuation_prices_expr: None, + when_expr: None, + reason: "fixed_signal_target".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!(decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol, + target_value, + reason, + } if symbol == "000001.SZ" && *target_value == 0.0 && reason == "stop_loss_exit" + ))); + let target_weights = decision + .order_intents + .iter() + .find_map(|intent| match intent { + OrderIntent::TargetPortfolioSmart { target_weights, .. } => Some(target_weights), + _ => None, + }) + .expect("target portfolio intent"); + assert!(!target_weights.contains_key("000001.SZ")); + assert_eq!(target_weights.get("000002.SZ").copied(), Some(0.5)); + assert!(decision.diagnostics.iter().any(|item| { + item.contains("explicit_action_exit_conflict_suppressed") + && item.contains("symbol=000001.SZ") + })); + } + #[test] fn target_portfolio_smart_respects_current_date_weight_conditions() { let date = d(2023, 1, 3);