diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c40174a..909527a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -216,6 +216,8 @@ pub struct PlatformExprStrategyConfig { pub quote_quantity_limit: bool, pub current_day_precomputed_factors: bool, pub intraday_execution_time: Option, + pub delayed_limit_open_exit_enabled: bool, + pub delayed_limit_open_exit_time: Option, pub explicit_action_stage: PlatformExplicitActionStage, pub explicit_action_schedule: Option, pub subscription_guard_required: bool, @@ -279,6 +281,8 @@ fn band_low(index_close) { quote_quantity_limit: true, current_day_precomputed_factors: false, intraday_execution_time: None, + delayed_limit_open_exit_enabled: false, + delayed_limit_open_exit_time: None, explicit_action_stage: PlatformExplicitActionStage::OnDay, explicit_action_schedule: None, subscription_guard_required: false, @@ -1056,20 +1060,45 @@ impl PlatformExprStrategy { date.and_time(self.intraday_execution_start_time()) } + fn projected_execution_start_cursor_at_time( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + execution_state: &ProjectedExecutionState, + execution_time: Option, + ) -> NaiveDateTime { + execution_time.map_or_else( + || self.projected_execution_start_cursor(ctx, date, symbol, execution_state), + |time| date.and_time(time), + ) + } + fn aiquant_scheduled_quote<'a>( &self, ctx: &'a StrategyContext<'_>, date: NaiveDate, symbol: &str, + ) -> Option<&'a crate::data::IntradayExecutionQuote> { + self.aiquant_scheduled_quote_at_time(ctx, date, symbol, None) + } + + fn aiquant_scheduled_quote_at_time<'a>( + &self, + ctx: &'a StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + execution_time: Option, ) -> Option<&'a crate::data::IntradayExecutionQuote> { if !self.config.aiquant_transaction_cost { return None; } - let start_cursor = self.projected_execution_start_cursor( + let start_cursor = self.projected_execution_start_cursor_at_time( ctx, date, symbol, &ProjectedExecutionState::default(), + execution_time, ); ctx.data .execution_quotes_on(date, symbol) @@ -1113,13 +1142,52 @@ impl PlatformExprStrategy { cash_limit: Option, gross_limit: Option, execution_state: &ProjectedExecutionState, + ) -> Option { + self.projected_select_execution_fill_at_time( + ctx, + date, + symbol, + side, + requested_qty, + round_lot, + minimum_order_quantity, + order_step_size, + allow_odd_lot_sell, + cash_limit, + gross_limit, + execution_state, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + fn projected_select_execution_fill_at_time( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + side: OrderSide, + requested_qty: u32, + round_lot: u32, + minimum_order_quantity: u32, + order_step_size: u32, + allow_odd_lot_sell: bool, + cash_limit: Option, + gross_limit: Option, + execution_state: &ProjectedExecutionState, + execution_time: Option, ) -> Option { if requested_qty == 0 { return None; } - let start_cursor = - self.projected_execution_start_cursor(ctx, date, symbol, execution_state); + let start_cursor = self.projected_execution_start_cursor_at_time( + ctx, + date, + symbol, + execution_state, + execution_time, + ); let quotes = ctx.data.execution_quotes_on(date, symbol); let selected_quotes = quotes .iter() @@ -1212,8 +1280,24 @@ impl PlatformExprStrategy { symbol: &str, execution_state: &ProjectedExecutionState, ) -> bool { - let start_cursor = - self.projected_execution_start_cursor(ctx, date, symbol, execution_state); + self.has_execution_quote_at_or_before_at_time(ctx, date, symbol, execution_state, None) + } + + fn has_execution_quote_at_or_before_at_time( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + execution_state: &ProjectedExecutionState, + execution_time: Option, + ) -> bool { + let start_cursor = self.projected_execution_start_cursor_at_time( + ctx, + date, + symbol, + execution_state, + execution_time, + ); ctx.data .execution_quotes_on(date, symbol) .iter() @@ -1227,6 +1311,18 @@ impl PlatformExprStrategy { date: NaiveDate, symbol: &str, execution_state: &mut ProjectedExecutionState, + ) -> Option { + self.project_target_zero_at_time(ctx, projected, date, symbol, execution_state, None) + } + + fn project_target_zero_at_time( + &self, + ctx: &StrategyContext<'_>, + projected: &mut PortfolioState, + date: NaiveDate, + symbol: &str, + execution_state: &mut ProjectedExecutionState, + execution_time: Option, ) -> Option { let quantity = projected.position(symbol)?.quantity; if quantity == 0 { @@ -1240,7 +1336,7 @@ impl PlatformExprStrategy { let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol); let fill = self - .projected_select_execution_fill( + .projected_select_execution_fill_at_time( ctx, date, symbol, @@ -1253,14 +1349,22 @@ impl PlatformExprStrategy { None, None, execution_state, + execution_time, ) .or_else(|| { - if !self.has_execution_quote_at_or_before(ctx, date, symbol, execution_state) { + if !self.has_execution_quote_at_or_before_at_time( + ctx, + date, + symbol, + execution_state, + execution_time, + ) { Some(ProjectedExecutionFill { price: self.projected_execution_price(market, OrderSide::Sell), quantity, - next_cursor: date.and_time(self.intraday_execution_start_time()) - + Duration::seconds(1), + next_cursor: date.and_time( + execution_time.unwrap_or_else(|| self.intraday_execution_start_time()), + ) + Duration::seconds(1), }) } else { None @@ -1761,11 +1865,33 @@ impl PlatformExprStrategy { date: NaiveDate, factor_date: NaiveDate, symbol: &str, + ) -> Result { + self.stock_state_with_factor_date_and_time(ctx, date, factor_date, symbol, None) + } + + fn stock_state_at_time( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + execution_time: Option, + ) -> Result { + self.stock_state_with_factor_date_and_time(ctx, date, date, symbol, execution_time) + } + + fn stock_state_with_factor_date_and_time( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + factor_date: NaiveDate, + symbol: &str, + execution_time: Option, ) -> Result { let market = ctx.data.require_market(date, symbol)?; let feature_market = ctx.data.market(factor_date, symbol).unwrap_or(market); let intraday_same_day_factor = self.config.aiquant_transaction_cost && factor_date == date; - let decision_quote = self.aiquant_scheduled_quote(ctx, date, symbol); + let decision_quote = + self.aiquant_scheduled_quote_at_time(ctx, date, symbol, execution_time); let factor = ctx.data.require_factor(factor_date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; let instrument = ctx.data.instrument(symbol); @@ -5884,77 +6010,30 @@ impl Strategy for PlatformExprStrategy { let mut intraday_attempted_buys = BTreeSet::::new(); let mut delayed_sold_symbols = BTreeSet::::new(); let mut unresolved_stop_loss_symbols = BTreeSet::::new(); - let take_profit_multiplier = self + let delayed_limit_exit_time = self .config - .take_profit_expr - .trim() - .parse::() - .ok() - .filter(|value| value.is_finite() && *value > 0.0); - let mut pending_symbols = if take_profit_multiplier.is_some() { + .delayed_limit_open_exit_time + .unwrap_or_else(|| self.intraday_execution_start_time()); + let pending_symbols = if self.config.delayed_limit_open_exit_enabled { self.pending_highlimit_holdings .iter() .cloned() - .collect::>() + .collect::>() } else { self.pending_highlimit_holdings.clear(); - BTreeSet::new() + Vec::new() }; - if let Some(multiplier) = take_profit_multiplier { - for position in ctx.portfolio.positions().values() { - let avg_price = if self.config.aiquant_transaction_cost - && position.average_cost.is_finite() - && position.average_cost > 0.0 - { - position.average_cost - } else { - position - .average_entry_price() - .filter(|value| value.is_finite() && *value > 0.0) - .unwrap_or(position.average_cost) - }; - if position.quantity == 0 - || avg_price <= 0.0 - || pending_symbols.contains(&position.symbol) - { - continue; - } - let Some(previous) = ctx.data.market_before(execution_date, &position.symbol) - else { - continue; - }; - let tick = previous.effective_price_tick().abs().max(1e-6); - let closed_at_upper_limit = - previous.upper_limit > 0.0 && previous.close >= previous.upper_limit - tick; - let closed_at_day_high = - previous.high > 0.0 && (previous.high - previous.close).abs() <= tick; - let mut recent_pause_before_previous = false; - let mut cursor = ctx.data.market_before(previous.date, &position.symbol); - for _ in 0..3 { - let Some(snapshot) = cursor else { - break; - }; - if snapshot.paused { - recent_pause_before_previous = true; - break; - } - cursor = ctx.data.market_before(snapshot.date, &position.symbol); - } - if (closed_at_upper_limit || closed_at_day_high) - && previous.close / avg_price > multiplier - && recent_pause_before_previous - { - pending_symbols.insert(position.symbol.clone()); - } - } - } - let pending_symbols = pending_symbols.into_iter().collect::>(); for symbol in pending_symbols { if !ctx.portfolio.positions().contains_key(&symbol) { self.pending_highlimit_holdings.remove(&symbol); continue; } - let stock = match self.stock_state(ctx, execution_date, &symbol) { + let stock = match self.stock_state_at_time( + ctx, + execution_date, + &symbol, + Some(delayed_limit_exit_time), + ) { Ok(stock) => stock, Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => { continue; @@ -5975,10 +6054,10 @@ impl Strategy for PlatformExprStrategy { } order_intents.push(OrderIntent::AlgoValue { symbol: symbol.clone(), - value: -(quantity * stock.last.max(stock.upper_limit).max(0.01) * 2.0), + value: -(quantity * stock.last.max(0.01) * 2.0), style: AlgoOrderStyle::Twap, - start_time: Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), - end_time: Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), + start_time: Some(delayed_limit_exit_time), + end_time: Some(delayed_limit_exit_time), reason: "delayed_limit_open_sell".to_string(), }); let projected_sold = if ctx @@ -5988,12 +6067,13 @@ impl Strategy for PlatformExprStrategy { { false } else { - self.project_target_zero( + self.project_target_zero_at_time( ctx, &mut projected, execution_date, &symbol, &mut projected_execution_state, + Some(delayed_limit_exit_time), ) .is_some() }; @@ -6062,17 +6142,12 @@ impl Strategy for PlatformExprStrategy { let (stop_hit, profit_hit) = self.stop_take_action(ctx, decision_date, execution_date, &day, &position.symbol)?; let can_sell = self.can_sell_position(ctx, execution_date, &position.symbol); - if stop_hit || profit_hit { - let sell_reason = if stop_hit { - "stop_loss_exit" - } else { - "take_profit_exit" - }; + if stop_hit { exit_symbols.insert(position.symbol.clone()); order_intents.push(OrderIntent::TargetValue { symbol: position.symbol.clone(), target_value: 0.0, - reason: sell_reason.to_string(), + reason: "stop_loss_exit".to_string(), }); if can_sell { if self @@ -6087,10 +6162,13 @@ impl Strategy for PlatformExprStrategy { { same_day_sold_symbols.insert(position.symbol.clone()); } - } else if stop_hit { + } else { unresolved_stop_loss_symbols.insert(position.symbol.clone()); } - } else if take_profit_multiplier.is_some() { + continue; + } + + if self.config.delayed_limit_open_exit_enabled { let stock = match self.stock_state(ctx, execution_date, &position.symbol) { Ok(stock) => stock, Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { @@ -6101,6 +6179,29 @@ impl Strategy for PlatformExprStrategy { if stock.upper_limit > 0.0 && stock.last >= stock.upper_limit { self.pending_highlimit_holdings .insert(position.symbol.clone()); + continue; + } + } + + if profit_hit { + exit_symbols.insert(position.symbol.clone()); + order_intents.push(OrderIntent::TargetValue { + symbol: position.symbol.clone(), + target_value: 0.0, + reason: "take_profit_exit".to_string(), + }); + if can_sell + && self + .project_target_zero( + ctx, + &mut projected, + execution_date, + &position.symbol, + &mut projected_execution_state, + ) + .is_some() + { + same_day_sold_symbols.insert(position.symbol.clone()); } } } @@ -7061,6 +7162,243 @@ mod tests { ); } + #[test] + fn platform_aiquant_delays_upper_limit_take_profit_to_configured_open_time() { + let prev_date = d(2025, 2, 6); + let first_date = d(2025, 2, 7); + let second_date = d(2025, 2, 10); + let symbol = "001368.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("2025-02-07 10:40:00".to_string()), + day_open: 23.99, + open: 23.99, + high: 23.99, + low: 23.99, + close: 23.99, + last_price: 23.99, + bid1: 23.99, + ask1: 23.99, + prev_close: 21.81, + volume: 100_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 23.99, + lower_limit: 19.63, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: second_date, + symbol: symbol.to_string(), + timestamp: Some("2025-02-10 10:31:00".to_string()), + day_open: 23.20, + open: 23.20, + high: 23.55, + low: 23.10, + close: 23.30, + last_price: 23.20, + bid1: 23.20, + ask1: 23.21, + prev_close: 23.99, + volume: 200_000, + tick_volume: 2_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 26.39, + lower_limit: 21.59, + 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(35.77), + effective_turnover_ratio: Some(35.77), + 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: false, + 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: false, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![ + BenchmarkSnapshot { + date: first_date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: second_date, + benchmark: "000852.SH".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(10, 40, 0).expect("valid timestamp"), + last_price: 23.99, + bid1: 23.99, + ask1: 23.99, + bid1_volume: 1_000, + ask1_volume: 1_000, + volume_delta: 1_000, + amount_delta: 23_990.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date: second_date, + symbol: symbol.to_string(), + timestamp: second_date.and_hms_opt(10, 31, 0).expect("valid timestamp"), + last_price: 23.20, + bid1: 23.20, + ask1: 23.21, + bid1_volume: 1_000, + ask1_volume: 1_000, + volume_delta: 1_000, + amount_delta: 23_200.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, 6_200, 19.86); + 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, 40, 0).unwrap()); + cfg.delayed_limit_open_exit_enabled = true; + cfg.delayed_limit_open_exit_time = Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap()); + cfg.signal_symbol = symbol.to_string(); + cfg.stop_loss_expr.clear(); + cfg.take_profit_expr = "1.07".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, + start_time, + end_time, + reason, + .. + } if intent_symbol == symbol + && reason == "delayed_limit_open_sell" + && *start_time == Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap()) + && *end_time == Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap()) + )), + "{:?}", + second_decision.order_intents + ); + assert!(strategy.pending_highlimit_holdings.is_empty()); + } + #[test] fn platform_take_profit_uses_strategy_entry_price_not_fee_cost_basis() { let prev_date = d(2025, 3, 13); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 12889ff..77f021a 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -22,6 +22,10 @@ pub struct StrategyRuntimeSpec { #[serde(default)] pub universe: Option, #[serde(default)] + pub rebalance: Option, + #[serde(default)] + pub trade_times: Vec, + #[serde(default)] pub signal_symbol: Option, #[serde(default)] pub execution: Option, @@ -49,6 +53,13 @@ pub struct StrategyUniverseSpec { pub exclude: Vec, } +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyRebalanceSpec { + #[serde(default)] + pub trade_times: Vec, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExecutionSpec { @@ -930,6 +941,22 @@ pub fn platform_expr_config_from_spec( cfg.retry_empty_rebalance = true; } } + let trade_times = spec_trade_times(spec); + if let Some(main_trade_time) = trade_times.last().copied() { + cfg.intraday_execution_time = Some(main_trade_time); + } + if aiquant_compat && trade_times.len() > 1 { + let delayed_time = trade_times[0]; + if trade_times + .last() + .copied() + .map(|main_time| main_time != delayed_time) + .unwrap_or(true) + { + cfg.delayed_limit_open_exit_enabled = true; + cfg.delayed_limit_open_exit_time = Some(delayed_time); + } + } if let Some(execution) = spec.execution.as_ref() { apply_cost_overrides( &mut cfg, @@ -1015,6 +1042,24 @@ fn parse_schedule_clock_time(raw: Option<&str>) -> Option { .or_else(|| NaiveTime::parse_from_str(value, "%H:%M").ok()) } +fn parse_trade_times(raw: &[String]) -> Vec { + raw.iter() + .filter_map(|item| parse_schedule_clock_time(Some(item.as_str()))) + .collect() +} + +fn spec_trade_times(spec: &StrategyRuntimeSpec) -> Vec { + let rebalance_times = spec + .rebalance + .as_ref() + .map(|rebalance| parse_trade_times(&rebalance.trade_times)) + .unwrap_or_default(); + if !rebalance_times.is_empty() { + return rebalance_times; + } + parse_trade_times(&spec.trade_times) +} + fn parse_platform_trade_action( action: &StrategyExpressionActionConfig, ) -> Option { @@ -1497,4 +1542,27 @@ mod tests { assert!(!cfg.calendar_rebalance_interval); assert!(cfg.aiquant_transaction_cost); } + + #[test] + fn parses_aiquant_rebalance_trade_times_for_delayed_limit_exit() { + let spec = serde_json::json!({ + "execution": { "compatibilityProfile": "aiquant_rqalpha" }, + "rebalance": { "tradeTimes": ["10:31", "10:40"] }, + "runtimeExpressions": { + "schedule": { "frequency": "daily", "time": "10:40" } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!( + cfg.intraday_execution_time, + Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap()) + ); + assert!(cfg.delayed_limit_open_exit_enabled); + assert_eq!( + cfg.delayed_limit_open_exit_time, + Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap()) + ); + } }