diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b95542b..bb6de7b 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1117,6 +1117,7 @@ pub struct PlatformExprStrategy { position_entry_dates: BTreeMap, position_holding_days: BTreeMap, position_holding_days_last_counted: BTreeMap, + executing_scheduled_rotation: bool, /// 已编译表达式 AST 缓存。 /// Key 是经过 normalize/helper template 展开之后的完整 script 文本, /// Value 是 Rhai 编译产物。命中后 eval 走 eval_ast_with_scope,避免重复 @@ -1450,6 +1451,7 @@ impl PlatformExprStrategy { position_entry_dates: BTreeMap::new(), position_holding_days: BTreeMap::new(), position_holding_days_last_counted: BTreeMap::new(), + executing_scheduled_rotation: false, compiled_cache: RefCell::new(AHashMap::new()), cache_hits: RefCell::new(0), cache_misses: RefCell::new(0), @@ -11613,8 +11615,25 @@ impl Strategy for PlatformExprStrategy { } fn schedule_rules(&self) -> Vec { + let mut rules = Vec::new(); + if self + .config + .rebalance_schedule + .as_ref() + .and_then(|schedule| schedule.time_rule.as_ref()) + .is_some() + && self.config.rotation_enabled + { + rules.push( + self.config + .rebalance_schedule + .as_ref() + .expect("checked timed rebalance schedule") + .as_schedule_rule(ScheduleStage::OnDay), + ); + } if self.config.explicit_actions.is_empty() { - return Vec::new(); + return rules; } let stage = match self.config.explicit_action_stage { PlatformExplicitActionStage::OpenAuction => ScheduleStage::OpenAuction, @@ -11622,32 +11641,52 @@ impl Strategy for PlatformExprStrategy { PlatformExplicitActionStage::Minute => ScheduleStage::Minute, }; let Some(schedule) = self.config.explicit_action_schedule.as_ref() else { - return Vec::new(); + return rules; }; if self.config.explicit_action_times.is_empty() { - return vec![schedule.as_schedule_rule(stage)]; + let rule = schedule.as_schedule_rule(stage); + if !rules.contains(&rule) { + rules.push(rule); + } + return rules; } - self.config - .explicit_action_times - .iter() - .map(|time| { - let mut timed_schedule = schedule.clone(); - timed_schedule.time_rule = - Some(ScheduleTimeRule::physical_time(time.hour(), time.minute())); - timed_schedule.as_schedule_rule(stage) - }) - .collect() + for time in &self.config.explicit_action_times { + let mut timed_schedule = schedule.clone(); + timed_schedule.time_rule = + Some(ScheduleTimeRule::physical_time(time.hour(), time.minute())); + let rule = timed_schedule.as_schedule_rule(stage); + if !rules.contains(&rule) { + rules.push(rule); + } + } + rules } fn on_scheduled( &mut self, ctx: &StrategyContext<'_>, - _rule: &ScheduleRule, + rule: &ScheduleRule, ) -> Result { - if !self.config.explicit_actions.is_empty() { - return self.explicit_action_decision(ctx); + let mut decision = if self.config.explicit_actions.is_empty() { + StrategyDecision::default() + } else { + self.explicit_action_decision(ctx)? + }; + let scheduled_rotation = self.config.rotation_enabled + && rule.name == "platform_periodic_rebalance" + && self + .config + .rebalance_schedule + .as_ref() + .and_then(|schedule| schedule.time_rule.as_ref()) + .is_some(); + if scheduled_rotation { + self.executing_scheduled_rotation = true; + let rotation = self.on_day(ctx); + self.executing_scheduled_rotation = false; + decision.merge_from(rotation?); } - Ok(StrategyDecision::default()) + Ok(decision) } fn decision_quote_times(&self) -> Vec { @@ -11701,6 +11740,17 @@ impl Strategy for PlatformExprStrategy { } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + if self.config.rotation_enabled + && self + .config + .rebalance_schedule + .as_ref() + .and_then(|schedule| schedule.time_rule.as_ref()) + .is_some() + && !self.executing_scheduled_rotation + { + return Ok(StrategyDecision::default()); + } let execution_date = ctx.execution_date; let decision_date = ctx.decision_date; let defer_execution_risk = ctx.is_lagged_execution(); @@ -13678,6 +13728,58 @@ mod tests { assert!((scale - 1.2).abs() < 1e-12, "scale={scale}"); } + #[test] + fn timed_rotation_runs_on_its_schedule_instead_of_the_untimed_day_hook() { + let date = d(2025, 1, 2); + let symbol = "000001.SZ"; + let data = single_symbol_platform_data(&[date], symbol); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + 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 config = PlatformExprStrategyConfig::microcap_rotation(); + config.signal_symbol = symbol.to_string(); + config.refresh_rate = 1; + config.max_positions = 1; + config.selection_limit_expr = "1".to_string(); + config.market_cap_lower_expr = "0".to_string(); + config.market_cap_upper_expr = "100".to_string(); + config.stock_filter_expr = "close > 0".to_string(); + config.benchmark_short_ma_days = 1; + config.benchmark_long_ma_days = 1; + config.current_day_precomputed_factors = true; + config.rebalance_schedule = Some(PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Daily, + time_rule: Some(ScheduleTimeRule::physical_time(9, 30)), + }); + let mut strategy = PlatformExprStrategy::new(config); + let rules = strategy.schedule_rules(); + + assert_eq!(rules.len(), 1); + assert!(strategy.on_day(&ctx).expect("day decision").is_empty()); + + let decision = strategy + .on_scheduled(&ctx, &rules[0]) + .expect("scheduled rotation decision"); + + assert!(!decision.is_empty(), "{decision:?}"); + assert!(!decision.order_intents.is_empty(), "{decision:?}"); + } + #[test] fn universe_exclude_matches_explicit_symbols_and_bjse_alias() { let excludes = vec![