修复信号日期调仓执行语义

This commit is contained in:
boris
2026-07-01 08:42:38 +08:00
parent c3101aa995
commit eeaf061932
2 changed files with 89 additions and 3 deletions
+40 -1
View File
@@ -204,6 +204,7 @@ pub struct PlatformExprStrategyConfig {
pub stock_long_ma_days: usize, pub stock_long_ma_days: usize,
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>, pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
pub rebalance_schedule: Option<PlatformRebalanceSchedule>, pub rebalance_schedule: Option<PlatformRebalanceSchedule>,
pub signal_rebalance_dates: BTreeSet<NaiveDate>,
pub rotation_enabled: bool, pub rotation_enabled: bool,
pub daily_top_up_enabled: bool, pub daily_top_up_enabled: bool,
pub retry_empty_rebalance: bool, pub retry_empty_rebalance: bool,
@@ -274,6 +275,7 @@ fn band_low(index_close) {
stock_long_ma_days: 20, stock_long_ma_days: 20,
skip_month_day_ranges: Vec::new(), skip_month_day_ranges: Vec::new(),
rebalance_schedule: None, rebalance_schedule: None,
signal_rebalance_dates: BTreeSet::new(),
rotation_enabled: true, rotation_enabled: true,
daily_top_up_enabled: false, daily_top_up_enabled: false,
retry_empty_rebalance: false, retry_empty_rebalance: false,
@@ -7324,7 +7326,9 @@ impl Strategy for PlatformExprStrategy {
self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty(); self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty();
let effective_refresh_rate = self.effective_refresh_rate(ctx, &day)?; let effective_refresh_rate = self.effective_refresh_rate(ctx, &day)?;
let periodic_rebalance = if self.config.rotation_enabled && !in_skip_window { let periodic_rebalance = if self.config.rotation_enabled && !in_skip_window {
if let Some(schedule) = &self.config.rebalance_schedule { if !self.config.signal_rebalance_dates.is_empty() {
self.config.signal_rebalance_dates.contains(&decision_date) || empty_rebalance_retry
} else if let Some(schedule) = &self.config.rebalance_schedule {
schedule.matches( schedule.matches(
ctx.data.calendar(), ctx.data.calendar(),
execution_date, execution_date,
@@ -16852,6 +16856,41 @@ mod tests {
dynamic_decision.diagnostics dynamic_decision.diagnostics
); );
let mut signal_dates_cfg = PlatformExprStrategyConfig::microcap_rotation();
signal_dates_cfg.signal_symbol = "000001.SZ".to_string();
signal_dates_cfg.refresh_rate = 20;
signal_dates_cfg.max_positions = 2;
signal_dates_cfg.benchmark_short_ma_days = 1;
signal_dates_cfg.benchmark_long_ma_days = 1;
signal_dates_cfg.market_cap_lower_expr = "0".to_string();
signal_dates_cfg.market_cap_upper_expr = "100".to_string();
signal_dates_cfg.selection_limit_expr = "2".to_string();
signal_dates_cfg.stock_filter_expr = "close > 0".to_string();
signal_dates_cfg
.signal_rebalance_dates
.insert(third_ctx.decision_date);
let mut signal_dates_strategy = PlatformExprStrategy::new(signal_dates_cfg);
signal_dates_strategy.rebalance_day_counter = 2;
let signal_dates_decision = signal_dates_strategy
.on_day(&third_ctx)
.expect("signal-date refresh decision");
assert!(
signal_dates_decision
.diagnostics
.iter()
.any(|item| item.contains("periodic_rebalance=true")),
"{:?}",
signal_dates_decision.diagnostics
);
assert!(
signal_dates_decision
.order_intents
.iter()
.any(|intent| matches!(intent, OrderIntent::Value { reason, .. } if reason == "periodic_rebalance_buy")),
"{:?}",
signal_dates_decision.order_intents
);
let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation();
no_retry_cfg.signal_symbol = "000001.SZ".to_string(); no_retry_cfg.signal_symbol = "000001.SZ".to_string();
no_retry_cfg.refresh_rate = 15; no_retry_cfg.refresh_rate = 15;
+49 -2
View File
@@ -1,6 +1,6 @@
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveTime; use chrono::{NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -55,6 +55,10 @@ pub struct StrategyUniverseSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyRebalanceSpec { pub struct StrategyRebalanceSpec {
#[serde(default)]
pub frequency: Option<String>,
#[serde(default)]
pub dates: Vec<String>,
#[serde(default)] #[serde(default)]
pub trade_times: Vec<String>, pub trade_times: Vec<String>,
} }
@@ -663,6 +667,9 @@ pub fn platform_expr_config_from_spec(
{ {
cfg.market = market.clone(); cfg.market = market.clone();
} }
if let Some(dates) = spec.rebalance.as_ref().and_then(signal_rebalance_dates) {
cfg.signal_rebalance_dates = dates;
}
if let Some(engine) = spec.engine_config.as_ref() { if let Some(engine) = spec.engine_config.as_ref() {
if let Some(rank_limit) = engine.rank_limit.filter(|value| *value > 0) { if let Some(rank_limit) = engine.rank_limit.filter(|value| *value > 0) {
cfg.max_positions = rank_limit; cfg.max_positions = rank_limit;
@@ -1194,6 +1201,24 @@ fn parse_platform_rebalance_schedule(
} }
} }
fn signal_rebalance_dates(rebalance: &StrategyRebalanceSpec) -> Option<BTreeSet<NaiveDate>> {
let frequency = rebalance
.frequency
.as_deref()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if frequency != "signal_dates" && frequency != "signal-dates" && frequency != "signal dates" {
return None;
}
let dates = rebalance
.dates
.iter()
.filter_map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").ok())
.collect::<BTreeSet<_>>();
if dates.is_empty() { None } else { Some(dates) }
}
fn parse_schedule_time_rule( fn parse_schedule_time_rule(
schedule: &StrategyExpressionScheduleConfig, schedule: &StrategyExpressionScheduleConfig,
) -> Option<ScheduleTimeRule> { ) -> Option<ScheduleTimeRule> {
@@ -1628,6 +1653,28 @@ mod tests {
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.2)); assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.2));
} }
#[test]
fn parses_signal_dates_rebalance_into_platform_config() {
let spec = serde_json::json!({
"rebalance": {
"frequency": "signal_dates",
"dates": ["2025-11-10", "2025-11-11", "invalid"]
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(
cfg.signal_rebalance_dates
.contains(&NaiveDate::from_ymd_opt(2025, 11, 10).unwrap())
);
assert!(
cfg.signal_rebalance_dates
.contains(&NaiveDate::from_ymd_opt(2025, 11, 11).unwrap())
);
assert_eq!(cfg.signal_rebalance_dates.len(), 2);
}
#[test] #[test]
fn parses_execution_cost_overrides_into_platform_config() { fn parses_execution_cost_overrides_into_platform_config() {
let spec = serde_json::json!({ let spec = serde_json::json!({