From e400dec4645cb42723874c880ae16d54daadf70e Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 02:42:28 -0700 Subject: [PATCH] Expose weekly and monthly platform rebalance schedules --- crates/fidc-core/src/lib.rs | 5 +- .../fidc-core/src/platform_expr_strategy.rs | 116 +++++++++++++++++- crates/fidc-core/src/strategy_ai.rs | 4 + 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 2eac300..0c8b672 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -32,7 +32,10 @@ pub use events::{ }; pub use instrument::Instrument; pub use metrics::{BacktestMetrics, compute_backtest_metrics}; -pub use platform_expr_strategy::{PlatformExprStrategy, PlatformExprStrategyConfig}; +pub use platform_expr_strategy::{ + PlatformExprStrategy, PlatformExprStrategyConfig, PlatformRebalanceSchedule, + PlatformScheduleFrequency, +}; pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use scheduler::{ScheduleFrequency, ScheduleRule, ScheduleStage, Scheduler}; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index bfc2988..c76e165 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -8,8 +8,71 @@ use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; use crate::engine::BacktestError; use crate::events::OrderSide; use crate::portfolio::PortfolioState; +use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlatformScheduleFrequency { + Weekly { + weekday: Option, + tradingday: Option, + }, + Monthly { + tradingday: i32, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformRebalanceSchedule { + pub frequency: PlatformScheduleFrequency, +} + +impl PlatformRebalanceSchedule { + fn as_schedule_rule(&self) -> ScheduleRule { + match self.frequency { + PlatformScheduleFrequency::Weekly { + weekday: Some(weekday), + .. + } => ScheduleRule::weekly_by_weekday( + "platform_periodic_rebalance", + weekday, + ScheduleStage::OnDay, + ), + PlatformScheduleFrequency::Weekly { + tradingday: Some(tradingday), + .. + } => ScheduleRule::weekly_by_tradingday( + "platform_periodic_rebalance", + tradingday, + ScheduleStage::OnDay, + ), + PlatformScheduleFrequency::Monthly { tradingday } => ScheduleRule::monthly( + "platform_periodic_rebalance", + tradingday, + ScheduleStage::OnDay, + ), + PlatformScheduleFrequency::Weekly { + weekday: None, + tradingday: None, + } => ScheduleRule::weekly_by_weekday( + "platform_periodic_rebalance", + 1, + ScheduleStage::OnDay, + ), + } + } + + fn matches(&self, calendar: &crate::calendar::TradingCalendar, date: NaiveDate) -> bool { + let scheduler = Scheduler::new(calendar); + let rule = self.as_schedule_rule(); + scheduler + .triggered_rules(date, ScheduleStage::OnDay, std::slice::from_ref(&rule)) + .into_iter() + .next() + .is_some() + } +} + #[derive(Debug, Clone)] pub struct PlatformExprStrategyConfig { pub strategy_name: String, @@ -38,6 +101,7 @@ pub struct PlatformExprStrategyConfig { pub stock_mid_ma_days: usize, pub stock_long_ma_days: usize, pub skip_month_day_ranges: Vec<(u32, u32, u32)>, + pub rebalance_schedule: Option, } impl PlatformExprStrategyConfig { @@ -82,6 +146,7 @@ fn band_low(index_close) { stock_mid_ma_days: 10, stock_long_ma_days: 20, skip_month_day_ranges: Vec::new(), + rebalance_schedule: None, } } @@ -2136,7 +2201,11 @@ impl Strategy for PlatformExprStrategy { .min(self.config.max_positions.max(1)); let (stock_list, selection_notes) = self.select_symbols(ctx, date, &day, band_low, band_high, selection_limit)?; - let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; + let periodic_rebalance = if let Some(schedule) = &self.config.rebalance_schedule { + schedule.matches(ctx.data.calendar(), date) + } else { + ctx.decision_index % self.config.refresh_rate == 0 + }; let mut projected = ctx.portfolio.clone(); let mut projected_execution_state = ProjectedExecutionState::default(); let mut order_intents = Vec::new(); @@ -2323,3 +2392,48 @@ impl Strategy for PlatformExprStrategy { }) } } + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::{PlatformRebalanceSchedule, PlatformScheduleFrequency}; + use crate::TradingCalendar; + + fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") + } + + fn sample_calendar() -> TradingCalendar { + TradingCalendar::new(vec![ + d(2025, 1, 30), + d(2025, 1, 31), + d(2025, 2, 3), + d(2025, 2, 4), + d(2025, 2, 7), + ]) + } + + #[test] + fn platform_rebalance_schedule_matches_weekly_weekday() { + let calendar = sample_calendar(); + let schedule = PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Weekly { + weekday: Some(5), + tradingday: None, + }, + }; + assert!(schedule.matches(&calendar, d(2025, 1, 31))); + assert!(!schedule.matches(&calendar, d(2025, 2, 3))); + } + + #[test] + fn platform_rebalance_schedule_matches_monthly_tradingday() { + let calendar = sample_calendar(); + let schedule = PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Monthly { tradingday: 1 }, + }; + assert!(schedule.matches(&calendar, d(2025, 2, 3))); + assert!(!schedule.matches(&calendar, d(2025, 2, 4))); + } +} diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 3306a74..1fccb4c 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -98,6 +98,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "rebalance.every_days(n).at([..])".to_string(), detail: "设置调仓周期和盘中决策/执行时刻。".to_string(), }, + ManualSection { + title: "rebalance.weekly / rebalance.monthly".to_string(), + detail: "支持按交易周或交易月调仓,例如 rebalance.weekly(weekday=5).at([\"10:18\"])、rebalance.weekly(tradingday=-1).at([\"10:18\"])、rebalance.monthly(tradingday=1).at([\"10:18\"])。当前这些调度规则会在 on_day 阶段触发。".to_string(), + }, ManualSection { title: "selection.market_cap_band / selection.limit / ordering.rank_by / ordering.rank_expr".to_string(), detail: "控制候选范围、数量和排序。支持表达式驱动的动态市值带和排序表达式。".to_string(),