diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index d1f317f..c1b32e2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -152,6 +152,7 @@ pub struct PlatformExprStrategyConfig { pub rebalance_schedule: Option, pub rotation_enabled: bool, pub explicit_action_stage: PlatformExplicitActionStage, + pub explicit_action_schedule: Option, pub explicit_actions: Vec, } @@ -200,6 +201,7 @@ fn band_low(index_close) { rebalance_schedule: None, rotation_enabled: true, explicit_action_stage: PlatformExplicitActionStage::OnDay, + explicit_action_schedule: None, explicit_actions: Vec::new(), } } @@ -2265,6 +2267,17 @@ impl PlatformExprStrategy { }) } + fn explicit_actions_active( + &self, + calendar: &crate::calendar::TradingCalendar, + date: NaiveDate, + ) -> bool { + self.config + .explicit_action_schedule + .as_ref() + .is_none_or(|schedule| schedule.matches(calendar, date)) + } + fn stock_passes_expr( &self, ctx: &StrategyContext<'_>, @@ -2586,6 +2599,7 @@ impl Strategy for PlatformExprStrategy { ) -> Result { if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction && !self.config.explicit_actions.is_empty() + && self.explicit_actions_active(ctx.data.calendar(), ctx.execution_date) { return self.explicit_action_decision(ctx); } @@ -2616,12 +2630,14 @@ impl Strategy for PlatformExprStrategy { } let day = self.day_state(ctx, date)?; - let explicit_action_intents = - if self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay { - self.explicit_action_intents(ctx, date, &day)? - } else { - Vec::new() - }; + let explicit_action_intents = if self.config.explicit_action_stage + == PlatformExplicitActionStage::OnDay + && self.explicit_actions_active(ctx.data.calendar(), date) + { + self.explicit_action_intents(ctx, date, &day)? + } else { + Vec::new() + }; let mut selection_notes = Vec::new(); let trading_ratio = if self.config.rotation_enabled { self.trading_ratio(ctx, &day)? @@ -3117,6 +3133,12 @@ mod tests { cfg.signal_symbol = "000001.SZ".to_string(); cfg.rotation_enabled = false; cfg.explicit_action_stage = PlatformExplicitActionStage::OpenAuction; + cfg.explicit_action_schedule = Some(PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Weekly { + weekday: Some(1), + tradingday: None, + }, + }); cfg.benchmark_short_ma_days = 1; cfg.benchmark_long_ma_days = 1; cfg.explicit_actions = vec![PlatformTradeAction::Order { @@ -3141,4 +3163,105 @@ mod tests { let on_day_decision = strategy.on_day(&ctx).expect("on day decision"); assert!(on_day_decision.order_intents.is_empty()); } + + #[test] + fn platform_strategy_skips_explicit_actions_when_schedule_does_not_match() { + let date = d(2025, 2, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Ping An Bank".to_string(), + board: "SZSE".to_string(), + round_lot: 100, + listed_at: Some(d(2010, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("09:25:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.9, + close: 10.1, + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + prev_close: 9.95, + volume: 1_000_000, + tick_volume: 5_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("open_auction".to_string()), + paused: false, + upper_limit: 10.94, + lower_limit: 8.96, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(22.0), + effective_turnover_ratio: Some(18.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".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, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.rotation_enabled = false; + cfg.explicit_action_stage = PlatformExplicitActionStage::OpenAuction; + cfg.explicit_action_schedule = Some(PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Weekly { + weekday: Some(5), + tradingday: None, + }, + }); + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::Percent, + symbol: "000001.SZ".to_string(), + amount_expr: "0.25".to_string(), + limit_price_expr: None, + when_expr: Some("allow_buy".to_string()), + reason: "auction_percent_entry".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let auction_decision = strategy.open_auction(&ctx).expect("auction decision"); + assert!(auction_decision.order_intents.is_empty()); + } } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 017f377..5c1929b 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "trading.rotation / order.* / cancel.*".to_string(), - detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再写 order.shares(\"600000.SH\", 1000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), + detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily() / trading.schedule.weekly(weekday=5) / trading.schedule.weekly(tradingday=-1) / trading.schedule.monthly(tradingday=1) 指定触发频率,然后写 order.shares(\"600000.SH\", 1000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), }, ManualSection { title: "when / unless / else".to_string(),