diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 5c31abf..b72d1d5 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -18101,6 +18101,209 @@ mod tests { ); } + #[test] + fn platform_aiquant_periodic_rebalance_does_not_reuse_same_callback_sell_cash() { + let prev_date = d(2025, 5, 13); + let date = d(2025, 5, 14); + let retained_symbols = (1..=17) + .map(|index| format!("000{index:03}.SZ")) + .collect::>(); + let new_symbols = (18..=40) + .map(|index| format!("000{index:03}.SZ")) + .collect::>(); + let old_symbols = (1..=23) + .map(|index| format!("600{index:03}.SH")) + .collect::>(); + let symbols = retained_symbols + .iter() + .chain(new_symbols.iter()) + .chain(old_symbols.iter()) + .cloned() + .collect::>(); + let data = DataSet::from_components_with_actions_and_quotes( + symbols + .iter() + .map(|symbol| Instrument { + symbol: symbol.clone(), + name: symbol.clone(), + board: symbol + .rsplit_once('.') + .map(|(_, suffix)| suffix.to_string()) + .unwrap_or_else(|| "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.clone(), + timestamp: Some("2025-05-14 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, + tick_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() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: symbol.clone(), + market_cap_bn: if old_symbols.contains(symbol) { + 100.0 + index as f64 + } else { + 1.0 + index as f64 + }, + 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.clone(), + is_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(), + symbols + .iter() + .map(|symbol| IntradayExecutionQuote { + date, + symbol: symbol.clone(), + 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()), + }) + .collect(), + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(260_000.0); + for symbol in retained_symbols.iter().chain(old_symbols.iter()) { + portfolio.position_mut(symbol).buy(prev_date, 12_000, 10.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 = "000001.SZ".to_string(); + cfg.refresh_rate = 20; + cfg.max_positions = 40; + 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 = "1000".to_string(); + cfg.selection_limit_expr = "40".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.take_profit_expr.clear(); + cfg.stop_loss_expr.clear(); + cfg.daily_top_up_enabled = true; + cfg.release_slot_on_exit_signal = true; + cfg.aiquant_transaction_cost = true; + cfg.strict_value_budget = true; + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()); + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 20; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + let periodic_sells = decision + .order_intents + .iter() + .filter(|intent| { + matches!( + intent, + OrderIntent::TargetValue { reason, .. } if reason == "periodic_rebalance_sell" + ) + }) + .count(); + let periodic_buys = decision + .order_intents + .iter() + .filter(|intent| { + matches!( + intent, + OrderIntent::Value { reason, .. } if reason == "periodic_rebalance_buy" + ) + }) + .count(); + + assert_eq!( + periodic_sells, + old_symbols.len(), + "{:?}", + decision.order_intents + ); + assert_eq!(periodic_buys, 2, "{:?}", decision.order_intents); + assert!( + !decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Value { symbol, reason, .. } + if symbol == &new_symbols[2] && reason == "periodic_rebalance_buy" + )), + "{:?}", + decision.order_intents + ); + } + #[test] fn platform_periodic_rebalance_caps_empty_slot_to_equal_weight_target() { let prev_date = d(2025, 5, 13); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 3d32cf7..75d00c8 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -96,6 +96,10 @@ pub struct StrategyEngineConfig { #[serde(default)] pub template_id: Option, #[serde(default)] + pub compatibility_profile: Option, + #[serde(default)] + pub profile_name: Option, + #[serde(default)] pub benchmark_symbol: Option, #[serde(default)] pub signal_symbol: Option, @@ -389,6 +393,12 @@ fn valid_non_negative(value: Option) -> Option { value.filter(|item| item.is_finite() && *item >= 0.0) } +fn is_aiquant_compatibility_profile(value: Option<&str>) -> bool { + value + .map(|item| item.trim().to_ascii_lowercase().replace('-', "_")) + .is_some_and(|item| item == "aiquant_rqalpha" || item == "aiquant") +} + fn apply_cost_overrides( cfg: &mut PlatformExprStrategyConfig, commission_rate: Option, @@ -1101,12 +1111,19 @@ pub fn platform_expr_config_from_spec( if !cfg.benchmark_symbol.trim().is_empty() { cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None); } - let aiquant_compat = spec - .execution - .as_ref() - .and_then(|execution| execution.compatibility_profile.as_deref()) - .map(|value| value.trim().to_ascii_lowercase()) - .is_some_and(|value| value == "aiquant_rqalpha" || value == "aiquant"); + let aiquant_compat = is_aiquant_compatibility_profile( + spec.execution + .as_ref() + .and_then(|execution| execution.compatibility_profile.as_deref()), + ) || is_aiquant_compatibility_profile( + spec.engine_config + .as_ref() + .and_then(|engine| engine.compatibility_profile.as_deref()), + ) || is_aiquant_compatibility_profile( + spec.engine_config + .as_ref() + .and_then(|engine| engine.profile_name.as_deref()), + ); if aiquant_compat { cfg.aiquant_transaction_cost = true; let trading = spec @@ -1756,6 +1773,31 @@ mod tests { assert!(!cfg.retry_empty_rebalance); } + #[test] + fn engine_config_aiquant_profile_enables_aiquant_semantics() { + let spec = serde_json::json!({ + "engineConfig": { + "compatibilityProfile": "aiquant_rqalpha" + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!(cfg.aiquant_transaction_cost); + assert!(cfg.daily_top_up_enabled); + assert!(cfg.retry_empty_rebalance); + + let spec = serde_json::json!({ + "engineConfig": { + "profileName": "aiquant-rqalpha" + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!(cfg.aiquant_transaction_cost); + } + #[test] fn runtime_expressions_infer_ma_windows_from_literal_strategy_logic() { let spec = serde_json::json!({