From 1683d875a05186ee6ad6cdde15732e7b4b7717db Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 17 Jun 2026 09:04:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=B9=B3=E5=8F=B0=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=BB=B6=E8=BF=9F=E5=8D=96=E5=87=BA=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 343 +++++++++++++++++- 1 file changed, 327 insertions(+), 16 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 65acdde..085e260 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -930,9 +930,14 @@ impl PlatformExprStrategy { model } - fn marked_total_value(&self, ctx: &StrategyContext<'_>, date: NaiveDate) -> f64 { - let mut total = ctx.portfolio.cash(); - for position in ctx.portfolio.positions().values() { + fn marked_total_value_for_portfolio( + &self, + ctx: &StrategyContext<'_>, + portfolio: &PortfolioState, + date: NaiveDate, + ) -> f64 { + let mut total = portfolio.cash(); + for position in portfolio.positions().values() { if position.quantity == 0 { continue; } @@ -967,6 +972,10 @@ impl PlatformExprStrategy { total } + fn marked_total_value(&self, ctx: &StrategyContext<'_>, date: NaiveDate) -> f64 { + self.marked_total_value_for_portfolio(ctx, ctx.portfolio, date) + } + fn round_lot_quantity( &self, quantity: u32, @@ -1537,16 +1546,11 @@ impl PlatformExprStrategy { })?; let gross_amount = fill.price * fill.quantity as f64; let sell_cost = self.sell_cost(date, gross_amount); - let cash_delta = if self.config.aiquant_transaction_cost { - -sell_cost - } else { - gross_amount - sell_cost - }; projected .position_mut(symbol) .sell(fill.quantity, fill.price) .ok()?; - projected.apply_cash_delta(cash_delta); + projected.apply_cash_delta(gross_amount - sell_cost); *execution_state .intraday_turnover .entry(symbol.to_string()) @@ -2116,6 +2120,25 @@ impl PlatformExprStrategy { self.stock_state_with_factor_date_and_time(ctx, date, date, symbol, execution_time) } + fn stock_is_at_upper_limit_at_time( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + execution_time: NaiveTime, + ) -> Result, BacktestError> { + if self + .aiquant_scheduled_quote_at_time(ctx, date, symbol, Some(execution_time)) + .is_none() + { + return Ok(None); + } + let stock = self.stock_state_at_time(ctx, date, symbol, Some(execution_time))?; + Ok(Some( + stock.upper_limit > 0.0 && stock.last >= stock.upper_limit, + )) + } + fn stock_state_with_factor_date_and_time( &self, ctx: &StrategyContext<'_>, @@ -6363,7 +6386,8 @@ impl Strategy for PlatformExprStrategy { let weak_market_shrink_due = trading_ratio.is_finite() && trading_ratio < previous_trading_ratio - 1e-9; let marked_total_value = self.marked_total_value(ctx, execution_date); - let aiquant_total_value = if marked_total_value.is_finite() && marked_total_value > 0.0 { + let mut aiquant_total_value = if marked_total_value.is_finite() && marked_total_value > 0.0 + { marked_total_value } else if day.total_value.is_finite() && day.total_value > 0.0 { day.total_value @@ -6434,13 +6458,33 @@ impl Strategy for PlatformExprStrategy { .config .delayed_limit_open_exit_time .unwrap_or_else(|| self.intraday_execution_start_time()); + if !self.config.delayed_limit_open_exit_enabled { + self.pending_highlimit_holdings.clear(); + } else { + for position in ctx.portfolio.positions().values() { + if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) { + continue; + } + match self.stock_is_at_upper_limit_at_time( + ctx, + execution_date, + &position.symbol, + delayed_limit_exit_time, + )? { + Some(true) => { + self.pending_highlimit_holdings + .insert(position.symbol.clone()); + } + Some(false) | None => {} + } + } + } let pending_symbols = if self.config.delayed_limit_open_exit_enabled { self.pending_highlimit_holdings .iter() .cloned() .collect::>() } else { - self.pending_highlimit_holdings.clear(); Vec::new() }; for symbol in pending_symbols { @@ -6448,6 +6492,17 @@ impl Strategy for PlatformExprStrategy { self.pending_highlimit_holdings.remove(&symbol); continue; } + if self + .aiquant_scheduled_quote_at_time( + ctx, + execution_date, + &symbol, + Some(delayed_limit_exit_time), + ) + .is_none() + { + continue; + } let stock = match self.stock_state_at_time( ctx, execution_date, @@ -6504,6 +6559,13 @@ impl Strategy for PlatformExprStrategy { self.pending_highlimit_holdings.remove(&symbol); } } + if !delayed_sold_symbols.is_empty() { + let projected_total = + self.marked_total_value_for_portfolio(ctx, &projected, execution_date); + if projected_total.is_finite() && projected_total > 0.0 { + aiquant_total_value = projected_total; + } + } let mut aiquant_available_cash = if delayed_sold_symbols.is_empty() { ctx.portfolio.cash() @@ -6520,7 +6582,10 @@ impl Strategy for PlatformExprStrategy { let mut pending_full_close_symbols = BTreeSet::::new(); if self.config.aiquant_transaction_cost { for position in ctx.portfolio.positions().values() { - if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) { + if position.quantity == 0 + || delayed_sold_symbols.contains(&position.symbol) + || self.pending_highlimit_holdings.contains(&position.symbol) + { continue; } let (stop_hit, profit_hit) = @@ -6726,7 +6791,6 @@ impl Strategy for PlatformExprStrategy { } } } - if self.config.daily_top_up_enabled && self.config.rotation_enabled && !periodic_rebalance @@ -6872,7 +6936,7 @@ impl Strategy for PlatformExprStrategy { slot_working_symbols.remove(symbol); } - if !self.config.aiquant_transaction_cost { + if !self.config.aiquant_transaction_cost || !same_day_sold_symbols.is_empty() { aiquant_available_cash = projected.cash(); } let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; @@ -8388,6 +8452,253 @@ mod tests { assert!(strategy.pending_highlimit_holdings.is_empty()); } + #[test] + fn platform_aiquant_marks_highlimit_before_cptrade_time() { + let prev_date = d(2024, 3, 1); + let first_date = d(2024, 3, 7); + let second_date = d(2024, 3, 8); + let symbol = "301261.SZ"; + let data = DataSet::from_components_with_actions_and_quotes( + vec![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(), + }], + vec![ + DailyMarketSnapshot { + date: first_date, + symbol: symbol.to_string(), + timestamp: Some("2024-03-07 10:18:00".to_string()), + day_open: 47.04, + open: 47.04, + high: 56.45, + low: 47.04, + close: 47.41, + last_price: 47.41, + bid1: 47.41, + ask1: 47.42, + prev_close: 47.04, + volume: 200_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 56.45, + lower_limit: 37.63, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: second_date, + symbol: symbol.to_string(), + timestamp: Some("2024-03-08 09:31:00".to_string()), + day_open: 47.41, + open: 47.41, + high: 48.28, + low: 46.30, + close: 46.74, + last_price: 46.74, + bid1: 46.74, + ask1: 46.75, + prev_close: 47.41, + volume: 200_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 56.89, + lower_limit: 37.93, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: first_date, + symbol: symbol.to_string(), + market_cap_bn: 24.90, + free_float_cap_bn: 6.10, + pe_ttm: 8.0, + turnover_ratio: Some(20.0), + effective_turnover_ratio: Some(20.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: second_date, + symbol: symbol.to_string(), + market_cap_bn: 24.20, + free_float_cap_bn: 5.90, + pe_ttm: 8.0, + turnover_ratio: Some(20.0), + effective_turnover_ratio: Some(20.0), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date: first_date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: second_date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![ + BenchmarkSnapshot { + date: first_date, + benchmark: "932000.CSI".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: second_date, + benchmark: "932000.CSI".to_string(), + open: 1003.0, + close: 1004.0, + prev_close: 1002.0, + volume: 1_000_000, + }, + ], + Vec::new(), + vec![ + IntradayExecutionQuote { + date: first_date, + symbol: symbol.to_string(), + timestamp: first_date.and_hms_opt(9, 31, 0).expect("valid timestamp"), + last_price: 56.45, + bid1: 56.45, + ask1: 0.0, + bid1_volume: 1_000, + ask1_volume: 0, + volume_delta: 1_000, + amount_delta: 56_450.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date: first_date, + symbol: symbol.to_string(), + timestamp: first_date.and_hms_opt(10, 18, 0).expect("valid timestamp"), + last_price: 49.30, + bid1: 49.16, + ask1: 49.29, + bid1_volume: 1_000, + ask1_volume: 1_000, + volume_delta: 1_000, + amount_delta: 49_300.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date: second_date, + symbol: symbol.to_string(), + timestamp: second_date.and_hms_opt(9, 31, 0).expect("valid timestamp"), + last_price: 48.28, + bid1: 48.11, + ask1: 48.28, + bid1_volume: 1_000, + ask1_volume: 1_000, + volume_delta: 1_000, + amount_delta: 48_280.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio.position_mut(symbol).buy(prev_date, 2_200, 45.15); + let subscriptions = BTreeSet::new(); + let first_ctx = StrategyContext { + execution_date: first_date, + decision_date: first_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.rotation_enabled = false; + cfg.aiquant_transaction_cost = true; + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()); + cfg.delayed_limit_open_exit_enabled = true; + cfg.delayed_limit_open_exit_time = Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap()); + cfg.signal_symbol = symbol.to_string(); + cfg.benchmark_symbol = "932000.CSI".to_string(); + cfg.stop_loss_expr.clear(); + cfg.take_profit_expr = "1.16".to_string(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let first_decision = strategy.on_day(&first_ctx).expect("first decision"); + + assert!( + first_decision.order_intents.is_empty(), + "{:?}", + first_decision + ); + assert!(strategy.pending_highlimit_holdings.contains(symbol)); + + let second_ctx = StrategyContext { + execution_date: second_date, + decision_date: second_date, + decision_index: 21, + 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 second_decision = strategy.on_day(&second_ctx).expect("second decision"); + + assert!( + second_decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::AlgoValue { + symbol: intent_symbol, + reason, + start_time, + .. + } if intent_symbol == symbol + && reason == "delayed_limit_open_sell" + && *start_time == Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap()) + )), + "{:?}", + second_decision.order_intents + ); + } + #[test] fn platform_take_profit_uses_strategy_entry_price_not_fee_cost_basis() { let prev_date = d(2025, 3, 13); @@ -13276,7 +13587,7 @@ mod tests { } #[test] - fn platform_daily_top_up_does_not_use_same_day_sell_cash() { + fn platform_daily_top_up_uses_same_day_sell_cash() { let prev_date = d(2025, 2, 25); let date = d(2025, 2, 26); let symbols = ["000001.SZ", "000002.SZ"]; @@ -14484,7 +14795,7 @@ mod tests { assert_eq!(filled, Some(100)); assert!( - (projected.cash() - 94.5).abs() < 1e-6, + (projected.cash() - 1094.5).abs() < 1e-6, "{}", projected.cash() );