修复定时轮动提前在日线阶段执行

This commit is contained in:
boris
2026-09-06 23:18:58 +08:00
parent 81f6b7d1a5
commit ce041e0d16
+119 -17
View File
@@ -1117,6 +1117,7 @@ pub struct PlatformExprStrategy {
position_entry_dates: BTreeMap<String, NaiveDate>,
position_holding_days: BTreeMap<String, i64>,
position_holding_days_last_counted: BTreeMap<String, NaiveDate>,
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<ScheduleRule> {
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<StrategyDecision, BacktestError> {
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<NaiveTime> {
@@ -11701,6 +11740,17 @@ impl Strategy for PlatformExprStrategy {
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
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![