From eeaf0619323c688cb7ebd7f97fa29a0a4b8b4c7b Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 1 Jul 2026 08:42:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BF=A1=E5=8F=B7=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E8=B0=83=E4=BB=93=E6=89=A7=E8=A1=8C=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 41 ++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 51 ++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 9688a6d..35df9d7 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -204,6 +204,7 @@ pub struct PlatformExprStrategyConfig { pub stock_long_ma_days: usize, pub skip_month_day_ranges: Vec<(Option, u32, u32, u32)>, pub rebalance_schedule: Option, + pub signal_rebalance_dates: BTreeSet, pub rotation_enabled: bool, pub daily_top_up_enabled: bool, pub retry_empty_rebalance: bool, @@ -274,6 +275,7 @@ fn band_low(index_close) { stock_long_ma_days: 20, skip_month_day_ranges: Vec::new(), rebalance_schedule: None, + signal_rebalance_dates: BTreeSet::new(), rotation_enabled: true, daily_top_up_enabled: false, retry_empty_rebalance: false, @@ -7324,7 +7326,9 @@ impl Strategy for PlatformExprStrategy { self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty(); let effective_refresh_rate = self.effective_refresh_rate(ctx, &day)?; let periodic_rebalance = if self.config.rotation_enabled && !in_skip_window { - if let Some(schedule) = &self.config.rebalance_schedule { + if !self.config.signal_rebalance_dates.is_empty() { + self.config.signal_rebalance_dates.contains(&decision_date) || empty_rebalance_retry + } else if let Some(schedule) = &self.config.rebalance_schedule { schedule.matches( ctx.data.calendar(), execution_date, @@ -16852,6 +16856,41 @@ mod tests { dynamic_decision.diagnostics ); + let mut signal_dates_cfg = PlatformExprStrategyConfig::microcap_rotation(); + signal_dates_cfg.signal_symbol = "000001.SZ".to_string(); + signal_dates_cfg.refresh_rate = 20; + signal_dates_cfg.max_positions = 2; + signal_dates_cfg.benchmark_short_ma_days = 1; + signal_dates_cfg.benchmark_long_ma_days = 1; + signal_dates_cfg.market_cap_lower_expr = "0".to_string(); + signal_dates_cfg.market_cap_upper_expr = "100".to_string(); + signal_dates_cfg.selection_limit_expr = "2".to_string(); + signal_dates_cfg.stock_filter_expr = "close > 0".to_string(); + signal_dates_cfg + .signal_rebalance_dates + .insert(third_ctx.decision_date); + let mut signal_dates_strategy = PlatformExprStrategy::new(signal_dates_cfg); + signal_dates_strategy.rebalance_day_counter = 2; + let signal_dates_decision = signal_dates_strategy + .on_day(&third_ctx) + .expect("signal-date refresh decision"); + assert!( + signal_dates_decision + .diagnostics + .iter() + .any(|item| item.contains("periodic_rebalance=true")), + "{:?}", + signal_dates_decision.diagnostics + ); + assert!( + signal_dates_decision + .order_intents + .iter() + .any(|intent| matches!(intent, OrderIntent::Value { reason, .. } if reason == "periodic_rebalance_buy")), + "{:?}", + signal_dates_decision.order_intents + ); + let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation(); no_retry_cfg.signal_symbol = "000001.SZ".to_string(); no_retry_cfg.refresh_rate = 15; diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 4217e44..330acf2 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1,6 +1,6 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use chrono::NaiveTime; +use chrono::{NaiveDate, NaiveTime}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -55,6 +55,10 @@ pub struct StrategyUniverseSpec { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyRebalanceSpec { + #[serde(default)] + pub frequency: Option, + #[serde(default)] + pub dates: Vec, #[serde(default)] pub trade_times: Vec, } @@ -663,6 +667,9 @@ pub fn platform_expr_config_from_spec( { cfg.market = market.clone(); } + if let Some(dates) = spec.rebalance.as_ref().and_then(signal_rebalance_dates) { + cfg.signal_rebalance_dates = dates; + } if let Some(engine) = spec.engine_config.as_ref() { if let Some(rank_limit) = engine.rank_limit.filter(|value| *value > 0) { cfg.max_positions = rank_limit; @@ -1194,6 +1201,24 @@ fn parse_platform_rebalance_schedule( } } +fn signal_rebalance_dates(rebalance: &StrategyRebalanceSpec) -> Option> { + let frequency = rebalance + .frequency + .as_deref() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + if frequency != "signal_dates" && frequency != "signal-dates" && frequency != "signal dates" { + return None; + } + let dates = rebalance + .dates + .iter() + .filter_map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").ok()) + .collect::>(); + if dates.is_empty() { None } else { Some(dates) } +} + fn parse_schedule_time_rule( schedule: &StrategyExpressionScheduleConfig, ) -> Option { @@ -1628,6 +1653,28 @@ mod tests { assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.2)); } + #[test] + fn parses_signal_dates_rebalance_into_platform_config() { + let spec = serde_json::json!({ + "rebalance": { + "frequency": "signal_dates", + "dates": ["2025-11-10", "2025-11-11", "invalid"] + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!( + cfg.signal_rebalance_dates + .contains(&NaiveDate::from_ymd_opt(2025, 11, 10).unwrap()) + ); + assert!( + cfg.signal_rebalance_dates + .contains(&NaiveDate::from_ymd_opt(2025, 11, 11).unwrap()) + ); + assert_eq!(cfg.signal_rebalance_dates.len(), 2); + } + #[test] fn parses_execution_cost_overrides_into_platform_config() { let spec = serde_json::json!({