修复信号日期调仓执行语义
This commit is contained in:
@@ -204,6 +204,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub stock_long_ma_days: usize,
|
||||
pub skip_month_day_ranges: Vec<(Option<u32>, u32, u32, u32)>,
|
||||
pub rebalance_schedule: Option<PlatformRebalanceSchedule>,
|
||||
pub signal_rebalance_dates: BTreeSet<NaiveDate>,
|
||||
pub rotation_enabled: bool,
|
||||
pub daily_top_up_enabled: bool,
|
||||
pub retry_empty_rebalance: bool,
|
||||
@@ -274,6 +275,7 @@ fn band_low(index_close) {
|
||||
stock_long_ma_days: 20,
|
||||
skip_month_day_ranges: Vec::new(),
|
||||
rebalance_schedule: None,
|
||||
signal_rebalance_dates: BTreeSet::new(),
|
||||
rotation_enabled: true,
|
||||
daily_top_up_enabled: false,
|
||||
retry_empty_rebalance: false,
|
||||
@@ -7324,7 +7326,9 @@ impl Strategy for PlatformExprStrategy {
|
||||
self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty();
|
||||
let effective_refresh_rate = self.effective_refresh_rate(ctx, &day)?;
|
||||
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(
|
||||
ctx.data.calendar(),
|
||||
execution_date,
|
||||
@@ -16852,6 +16856,41 @@ mod tests {
|
||||
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();
|
||||
no_retry_cfg.signal_symbol = "000001.SZ".to_string();
|
||||
no_retry_cfg.refresh_rate = 15;
|
||||
|
||||
@@ -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_json::Value;
|
||||
|
||||
@@ -55,6 +55,10 @@ pub struct StrategyUniverseSpec {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyRebalanceSpec {
|
||||
#[serde(default)]
|
||||
pub frequency: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub trade_times: Vec<String>,
|
||||
}
|
||||
@@ -663,6 +667,9 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
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(rank_limit) = engine.rank_limit.filter(|value| *value > 0) {
|
||||
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(
|
||||
schedule: &StrategyExpressionScheduleConfig,
|
||||
) -> Option<ScheduleTimeRule> {
|
||||
@@ -1628,6 +1653,28 @@ mod tests {
|
||||
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]
|
||||
fn parses_execution_cost_overrides_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user