diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b7abec7..1774059 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use ahash::{AHashMap, AHashSet}; -use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; +use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; use rhai::{AST, Dynamic, Engine, Map, Scope}; use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel}; @@ -429,6 +429,7 @@ pub struct PlatformExprStrategyConfig { pub quote_quantity_limit: bool, pub current_day_precomputed_factors: bool, pub intraday_execution_time: Option, + pub explicit_action_times: Vec, pub delayed_limit_open_exit_enabled: bool, pub delayed_limit_open_exit_time: Option, pub release_slot_on_exit_signal: bool, @@ -500,6 +501,7 @@ impl PlatformExprStrategyConfig { quote_quantity_limit: true, current_day_precomputed_factors: false, intraday_execution_time: None, + explicit_action_times: Vec::new(), delayed_limit_open_exit_enabled: false, delayed_limit_open_exit_time: None, release_slot_on_exit_signal: false, @@ -10430,11 +10432,21 @@ impl Strategy for PlatformExprStrategy { PlatformExplicitActionStage::OnDay => ScheduleStage::OnDay, PlatformExplicitActionStage::Minute => ScheduleStage::Minute, }; + let Some(schedule) = self.config.explicit_action_schedule.as_ref() else { + return Vec::new(); + }; + if self.config.explicit_action_times.is_empty() { + return vec![schedule.as_schedule_rule(stage)]; + } self.config - .explicit_action_schedule - .as_ref() - .map(|schedule| schedule.as_schedule_rule(stage)) - .into_iter() + .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() } @@ -10452,7 +10464,11 @@ impl Strategy for PlatformExprStrategy { fn decision_quote_times(&self) -> Vec { let mut times = BTreeSet::new(); if self.uses_intraday_execution_quotes() { - times.insert(self.intraday_execution_start_time()); + if self.config.explicit_action_times.is_empty() { + times.insert(self.intraday_execution_start_time()); + } else { + times.extend(self.config.explicit_action_times.iter().copied()); + } } if self.config.delayed_limit_open_exit_enabled { if let Some(time) = self.config.delayed_limit_open_exit_time { @@ -21945,6 +21961,61 @@ mod tests { )); } + #[test] + fn platform_explicit_actions_keep_every_declared_trade_time() { + let calendar = sample_calendar(); + let date = d(2025, 2, 3); + let first = NaiveTime::from_hms_opt(10, 18, 0).unwrap(); + let second = NaiveTime::from_hms_opt(14, 59, 0).unwrap(); + let mut config = PlatformExprStrategyConfig::generic(); + config.matching_type = MatchingType::MinuteLast; + config.intraday_execution_time = Some(second); + config.explicit_action_times = vec![first, second]; + config.explicit_action_schedule = Some(PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Daily, + time_rule: Some(ScheduleTimeRule::physical_time(14, 59)), + }); + config.explicit_actions = vec![PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::TargetPercent, + symbol: "000001.SZ".to_string(), + amount_expr: "0.5".to_string(), + limit_price_expr: None, + time_in_force: None, + start_time_expr: None, + end_time_expr: None, + when_expr: None, + reason: "multi_time_target".to_string(), + }]; + let strategy = PlatformExprStrategy::new(config); + let rules = strategy.schedule_rules(); + let scheduler = Scheduler::new(&calendar); + + assert_eq!(rules.len(), 2); + assert_eq!(strategy.decision_quote_times(), vec![first, second]); + assert_eq!( + scheduler + .triggered_rules_at(date, ScheduleStage::OnDay, Some(first), &rules) + .len(), + 1 + ); + assert_eq!( + scheduler + .triggered_rules_at(date, ScheduleStage::OnDay, Some(second), &rules) + .len(), + 1 + ); + assert!( + scheduler + .triggered_rules_at( + date, + ScheduleStage::OnDay, + NaiveTime::from_hms_opt(12, 0, 0), + &rules, + ) + .is_empty() + ); + } + #[test] fn platform_rebalance_date_due_does_not_use_the_on_day_default_clock() { let calendar = sample_calendar(); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index f008412..11274f1 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -2214,7 +2214,27 @@ pub fn platform_expr_config_from_spec( cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None); } let trade_times = spec_trade_times(spec); - if let Some(main_trade_time) = trade_times.last().copied() { + let explicit_trading_schedule = spec + .runtime_expressions + .as_ref() + .and_then(|runtime| runtime.trading.as_ref()) + .and_then(|trading| trading.schedule.as_ref()); + if !cfg.explicit_actions.is_empty() { + cfg.explicit_action_times = if explicit_trading_schedule.is_some() { + explicit_trading_schedule + .and_then(parse_schedule_execution_time) + .into_iter() + .collect() + } else { + trade_times.clone() + }; + } + if let Some(main_trade_time) = cfg + .explicit_action_times + .last() + .or_else(|| trade_times.last()) + .copied() + { cfg.intraday_execution_time = Some(main_trade_time); } if let Some(execution) = spec.execution.as_ref() { @@ -2359,6 +2379,8 @@ fn parse_schedule_clock_time(raw: Option<&str>) -> Option { fn parse_trade_times(raw: &[String]) -> Vec { raw.iter() .filter_map(|item| parse_schedule_clock_time(Some(item.as_str()))) + .collect::>() + .into_iter() .collect() } @@ -3858,9 +3880,17 @@ mod tests { #[test] fn multiple_trade_times_do_not_imply_delayed_limit_exit() { let spec = serde_json::json!({ - "rebalance": { "tradeTimes": ["10:31", "10:40"] }, + "rebalance": { "tradeTimes": ["10:40", "10:31", "10:40"] }, "runtimeExpressions": { - "schedule": { "frequency": "daily", "time": "10:40" } + "schedule": { "frequency": "daily", "time": "10:40" }, + "trading": { + "rotationEnabled": false, + "actions": [{ + "kind": "target_percent", + "symbol": "000001.SZ", + "amountExpr": "0.5" + }] + } } }); @@ -3870,10 +3900,47 @@ mod tests { cfg.intraday_execution_time, Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap()) ); + assert_eq!( + cfg.explicit_action_times, + vec![ + NaiveTime::from_hms_opt(10, 31, 0).unwrap(), + NaiveTime::from_hms_opt(10, 40, 0).unwrap(), + ] + ); assert!(!cfg.delayed_limit_open_exit_enabled); assert_eq!(cfg.delayed_limit_open_exit_time, None); } + #[test] + fn explicit_trading_schedule_overrides_rebalance_trade_times() { + let spec = serde_json::json!({ + "rebalance": { "tradeTimes": ["10:18", "14:59"] }, + "runtimeExpressions": { + "schedule": { "frequency": "daily", "time": "14:59" }, + "trading": { + "rotationEnabled": false, + "schedule": { "frequency": "daily", "time": "11:07" }, + "actions": [{ + "kind": "target_percent", + "symbol": "000001.SZ", + "amountExpr": "0.5" + }] + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!( + cfg.explicit_action_times, + vec![NaiveTime::from_hms_opt(11, 7, 0).unwrap()] + ); + assert_eq!( + cfg.intraday_execution_time, + Some(NaiveTime::from_hms_opt(11, 7, 0).unwrap()) + ); + } + #[test] fn rejects_removed_compatibility_profile() { let spec = serde_json::json!({