diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index ddc1202..65acdde 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -470,22 +470,14 @@ fn precomputed_stock_rolling_mean( lookback: usize, ) -> Option { let keys: &[&str] = match (field.trim().to_ascii_lowercase().as_str(), lookback) { - ("close" | "prev_close" | "stock_close" | "price", 5) => { - &["ma5_prev_close", "sf_jq_v104_ma5", "ma5"] - } - ("close" | "prev_close" | "stock_close" | "price", 10) => { - &["ma10_prev_close", "sf_jq_v104_ma10", "ma10"] - } - ("close" | "prev_close" | "stock_close" | "price", 20) => { - &["ma20_prev_close", "sf_jq_v104_ma20", "ma20"] - } - ("close" | "prev_close" | "stock_close" | "price", 30) => { - &["ma30_prev_close", "sf_jq_v104_ma30", "ma30"] - } - ("volume" | "stock_volume", 5) => &["avg_volume5", "sf_jq_v104_v5", "vma5"], - ("volume" | "stock_volume", 20) => &["avg_volume20", "sf_jq_v104_v20", "vma20"], - ("volume" | "stock_volume", 60) => &["avg_volume60", "sf_jq_v104_v60", "vma60"], - ("volume" | "stock_volume", 100) => &["avg_volume100", "sf_jq_v104_v100", "vma100"], + ("close" | "prev_close" | "stock_close" | "price", 5) => &["ma5_prev_close", "ma5"], + ("close" | "prev_close" | "stock_close" | "price", 10) => &["ma10_prev_close", "ma10"], + ("close" | "prev_close" | "stock_close" | "price", 20) => &["ma20_prev_close", "ma20"], + ("close" | "prev_close" | "stock_close" | "price", 30) => &["ma30_prev_close", "ma30"], + ("volume" | "stock_volume", 5) => &["avg_volume5", "vma5"], + ("volume" | "stock_volume", 20) => &["avg_volume20", "vma20"], + ("volume" | "stock_volume", 60) => &["avg_volume60", "vma60"], + ("volume" | "stock_volume", 100) => &["avg_volume100", "vma100"], _ => return None, }; keys.iter() @@ -498,6 +490,7 @@ pub struct PlatformExprStrategy { engine: Engine, rebalance_day_counter: usize, last_rebalance_date: Option, + last_trading_ratio: Option, pending_highlimit_holdings: BTreeSet, /// 已编译表达式 AST 缓存。 /// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本, @@ -591,6 +584,7 @@ impl PlatformExprStrategy { engine, rebalance_day_counter: 0, last_rebalance_date: None, + last_trading_ratio: None, pending_highlimit_holdings: BTreeSet::new(), compiled_cache: RefCell::new(HashMap::new()), cache_hits: RefCell::new(0), @@ -5792,8 +5786,24 @@ impl PlatformExprStrategy { Err(error) => Err(error), }; } + let rank_by = self.config.rank_by.as_str(); + match rank_by { + "market_cap" => { + return Ok(Self::market_cap_storage_to_strategy_unit( + candidate.market_cap_bn, + )); + } + "market_cap_bn" => return Ok(candidate.market_cap_bn), + "free_float_cap" | "free_float_market_cap" => { + return Ok(Self::market_cap_storage_to_strategy_unit( + candidate.free_float_cap_bn, + )); + } + "free_float_cap_bn" => return Ok(candidate.free_float_cap_bn), + _ => {} + } Ok(self - .stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str()) + .stock_numeric_field_value(candidate, stock, rank_by) .unwrap_or_else(|| self.field_value(candidate))) } @@ -6349,6 +6359,9 @@ impl Strategy for PlatformExprStrategy { } else { 0.0 }; + let previous_trading_ratio = self.last_trading_ratio.unwrap_or(1.0); + 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 { marked_total_value @@ -6563,6 +6576,7 @@ impl Strategy for PlatformExprStrategy { && self.config.rotation_enabled && trading_ratio > 0.0 && trading_ratio < 1.0 + && weak_market_shrink_due && selection_limit > 0 && !ctx.portfolio.positions().is_empty() { @@ -7021,6 +7035,9 @@ impl Strategy for PlatformExprStrategy { self.rebalance_day_counter = self.rebalance_day_counter.saturating_add(1); } } + if self.config.rotation_enabled && trading_ratio.is_finite() { + self.last_trading_ratio = Some(trading_ratio); + } if !explicit_action_intents.is_empty() { order_intents.extend(explicit_action_intents); } @@ -7133,7 +7150,7 @@ mod tests { PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, - PlatformUniverseActionKind, + PlatformUniverseActionKind, precomputed_stock_rolling_mean, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, @@ -9304,6 +9321,161 @@ mod tests { ))); } + #[test] + fn platform_aiquant_weak_market_does_not_retarget_same_ratio_every_day() { + let first_date = d(2023, 5, 4); + let second_date = d(2023, 5, 5); + let symbol = "300621.SZ"; + let data = DataSet::from_components( + 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(), + }], + [first_date, second_date] + .iter() + .map(|date| DailyMarketSnapshot { + date: *date, + symbol: symbol.to_string(), + timestamp: Some(format!("{date} 10:40:00")), + day_open: 10.0, + open: 10.0, + high: 11.0, + low: 9.8, + close: 10.8, + last_price: 10.8, + bid1: 10.79, + ask1: 10.81, + prev_close: 10.5, + volume: 200_000, + tick_volume: 1_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.55, + lower_limit: 9.45, + price_tick: 0.01, + }) + .collect(), + [first_date, second_date] + .iter() + .map(|date| DailyFactorSnapshot { + date: *date, + symbol: symbol.to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 20.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + [first_date, second_date] + .iter() + .map(|date| CandidateEligibility { + date: *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, + }) + .collect(), + [first_date, second_date] + .iter() + .map(|date| BenchmarkSnapshot { + date: *date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 999.0, + volume: 1_000_000, + }) + .collect(), + ) + .expect("dataset"); + let subscriptions = BTreeSet::new(); + let empty_portfolio = PortfolioState::new(1_000_000.0); + let first_ctx = StrategyContext { + execution_date: first_date, + decision_date: first_date, + decision_index: 1, + data: &data, + portfolio: &empty_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 held_portfolio = PortfolioState::new(1_000_000.0); + held_portfolio + .position_mut(symbol) + .buy(first_date, 4_000, 10.52); + let second_ctx = StrategyContext { + execution_date: second_date, + decision_date: second_date, + decision_index: 2, + data: &data, + portfolio: &held_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.aiquant_transaction_cost = true; + cfg.signal_symbol = symbol.to_string(); + cfg.exposure_expr = "0.5".to_string(); + cfg.selection_limit_expr = "40".to_string(); + cfg.stock_filter_expr = "false".to_string(); + cfg.stop_loss_expr.clear(); + cfg.take_profit_expr.clear(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let first_decision = strategy + .on_day(&first_ctx) + .expect("first platform decision"); + assert!( + !first_decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Shares { reason, .. } if reason == "daily_position_target_adjust" + )), + "{:?}", + first_decision.order_intents + ); + + let second_decision = strategy + .on_day(&second_ctx) + .expect("second platform decision"); + + assert!( + !second_decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Shares { reason, .. } if reason == "daily_position_target_adjust" + )), + "{:?}", + second_decision.order_intents + ); + } + #[test] fn platform_aiquant_skips_positive_target_adjust_when_position_will_close() { let prev_date = d(2023, 5, 11); @@ -14700,6 +14872,181 @@ mod tests { )); } + #[test] + fn platform_selection_ranks_by_candidate_market_cap_with_current_precomputed_factors() { + let prev = d(2025, 1, 2); + let curr = d(2025, 1, 3); + let symbols = ["300001.SZ", "300002.SZ", "000001.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(), + [prev, curr] + .into_iter() + .flat_map(|date| { + symbols.iter().map(move |symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-01-03 09:33:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + 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(), + vec![ + DailyFactorSnapshot { + date: prev, + symbol: "300001.SZ".to_string(), + market_cap_bn: 30.0, + free_float_cap_bn: 30.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: prev, + symbol: "300002.SZ".to_string(), + market_cap_bn: 10.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::new(), + }, + DailyFactorSnapshot { + date: curr, + symbol: "300001.SZ".to_string(), + market_cap_bn: 10.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::new(), + }, + DailyFactorSnapshot { + date: curr, + symbol: "300002.SZ".to_string(), + market_cap_bn: 30.0, + free_float_cap_bn: 30.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + [prev, curr] + .into_iter() + .flat_map(|date| { + ["300001.SZ", "300002.SZ"] + .into_iter() + .map(move |symbol| CandidateEligibility { + 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, + }) + }) + .collect(), + [prev, curr] + .into_iter() + .map(|date| BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }) + .collect(), + ) + .expect("dataset"); + let portfolio = PortfolioState::new(30_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: curr, + decision_date: curr, + 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.refresh_rate = 99; + cfg.max_positions = 1; + 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 = "100".to_string(); + cfg.selection_limit_expr = "1".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.current_day_precomputed_factors = true; + cfg.retry_empty_rebalance = true; + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!( + decision + .diagnostics + .iter() + .any(|item| item.contains("selection_universe_factor_date=2025-01-02")), + "{:?}", + decision.diagnostics + ); + assert!( + decision + .diagnostics + .iter() + .any(|item| item.contains("selection_factor_date=2025-01-03")), + "{:?}", + decision.diagnostics + ); + assert!(matches!( + decision.order_intents.first(), + Some(crate::strategy::OrderIntent::Value { symbol, .. }) if symbol == "300002.SZ" + )); + } + #[test] fn platform_stock_state_can_prefer_precomputed_rolling_factors() { let dates = [ @@ -14817,6 +15164,34 @@ mod tests { assert_eq!(stock.stock_volume_ma5, 1_000.0); } + #[test] + fn precomputed_rolling_mean_ignores_strategy_specific_v104_labels() { + let mut extra_factors = BTreeMap::new(); + extra_factors.insert("sf_jq_v104_ma5".to_string(), 99.0); + extra_factors.insert("sf_jq_v104_v100".to_string(), 88.0); + + assert_eq!( + precomputed_stock_rolling_mean(&extra_factors, "close", 5), + None + ); + assert_eq!( + precomputed_stock_rolling_mean(&extra_factors, "volume", 100), + None + ); + + extra_factors.insert("ma5_prev_close".to_string(), 10.5); + extra_factors.insert("avg_volume100".to_string(), 120_000.0); + + assert_eq!( + precomputed_stock_rolling_mean(&extra_factors, "close", 5), + Some(10.5) + ); + assert_eq!( + precomputed_stock_rolling_mean(&extra_factors, "volume", 100), + Some(120_000.0) + ); + } + #[test] fn platform_strategy_emits_target_shares_explicit_action() { let date = d(2025, 2, 3);