完善生命周期持仓与当前日滚动语义

This commit is contained in:
boris
2026-07-17 07:34:59 +08:00
parent 9d47d06064
commit 01b2ca02ff
4 changed files with 332 additions and 27 deletions
+12
View File
@@ -2362,6 +2362,18 @@ impl DataSet {
} }
} }
pub fn market_current_numeric_values(
&self,
date: NaiveDate,
symbol: &str,
field: &str,
lookback: usize,
) -> Vec<f64> {
self.market_series(symbol)
.map(|series| series.trailing_numeric_values(date, lookback, field, true))
.unwrap_or_default()
}
pub fn factor_numeric_values( pub fn factor_numeric_values(
&self, &self,
date: NaiveDate, date: NaiveDate,
+267 -27
View File
@@ -339,6 +339,7 @@ pub struct PlatformExprStrategyConfig {
pub market_cap_lower_expr: String, pub market_cap_lower_expr: String,
pub market_cap_upper_expr: String, pub market_cap_upper_expr: String,
pub selection_limit_expr: String, pub selection_limit_expr: String,
pub selection_candidate_limit_expr: String,
pub stock_filter_expr: String, pub stock_filter_expr: String,
pub buy_scale_expr: String, pub buy_scale_expr: String,
pub exposure_expr: String, pub exposure_expr: String,
@@ -359,7 +360,9 @@ pub struct PlatformExprStrategyConfig {
pub rotation_enabled: bool, pub rotation_enabled: bool,
pub daily_top_up_enabled: bool, pub daily_top_up_enabled: bool,
pub daily_position_target_adjust_enabled: bool, pub daily_position_target_adjust_enabled: bool,
pub target_portfolio_daily_enabled: bool,
pub rebalance_existing_positions: bool, pub rebalance_existing_positions: bool,
pub hold_until_exit_enabled: bool,
pub selection_buffer_multiple: f64, pub selection_buffer_multiple: f64,
pub daily_replacement_limit: usize, pub daily_replacement_limit: usize,
pub retry_empty_rebalance: bool, pub retry_empty_rebalance: bool,
@@ -412,6 +415,7 @@ fn band_low(index_close) {
market_cap_lower_expr: "band_low(signal_close)".to_string(), market_cap_lower_expr: "band_low(signal_close)".to_string(),
market_cap_upper_expr: "band_low(signal_close) + 10".to_string(), market_cap_upper_expr: "band_low(signal_close) + 10".to_string(),
selection_limit_expr: "stocknum".to_string(), selection_limit_expr: "stocknum".to_string(),
selection_candidate_limit_expr: String::new(),
stock_filter_expr: stock_filter_expr:
"stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long" "stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long"
.to_string(), .to_string(),
@@ -434,7 +438,9 @@ fn band_low(index_close) {
rotation_enabled: true, rotation_enabled: true,
daily_top_up_enabled: false, daily_top_up_enabled: false,
daily_position_target_adjust_enabled: true, daily_position_target_adjust_enabled: true,
target_portfolio_daily_enabled: false,
rebalance_existing_positions: false, rebalance_existing_positions: false,
hold_until_exit_enabled: false,
selection_buffer_multiple: 1.0, selection_buffer_multiple: 1.0,
daily_replacement_limit: 0, daily_replacement_limit: 0,
retry_empty_rebalance: false, retry_empty_rebalance: false,
@@ -750,6 +756,8 @@ pub struct PlatformExprStrategy {
pending_highlimit_holdings: BTreeSet<String>, pending_highlimit_holdings: BTreeSet<String>,
pending_full_close_symbols: BTreeSet<String>, pending_full_close_symbols: BTreeSet<String>,
position_entry_dates: BTreeMap<String, NaiveDate>, position_entry_dates: BTreeMap<String, NaiveDate>,
position_holding_days: BTreeMap<String, i64>,
position_holding_days_last_counted: BTreeMap<String, NaiveDate>,
/// 已编译表达式 AST 缓存。 /// 已编译表达式 AST 缓存。
/// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本, /// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本,
/// Value 是 Rhai 编译产物。命中后 eval 走 eval_ast_with_scope,避免重复 /// Value 是 Rhai 编译产物。命中后 eval 走 eval_ast_with_scope,避免重复
@@ -1032,6 +1040,8 @@ impl PlatformExprStrategy {
pending_highlimit_holdings: BTreeSet::new(), pending_highlimit_holdings: BTreeSet::new(),
pending_full_close_symbols: BTreeSet::new(), pending_full_close_symbols: BTreeSet::new(),
position_entry_dates: BTreeMap::new(), position_entry_dates: BTreeMap::new(),
position_holding_days: BTreeMap::new(),
position_holding_days_last_counted: BTreeMap::new(),
compiled_cache: RefCell::new(HashMap::new()), compiled_cache: RefCell::new(HashMap::new()),
cache_hits: RefCell::new(0), cache_hits: RefCell::new(0),
cache_misses: RefCell::new(0), cache_misses: RefCell::new(0),
@@ -1319,6 +1329,8 @@ impl PlatformExprStrategy {
| "day_factor" | "day_factor"
| "rolling_mean" | "rolling_mean"
| "rolling_mean_current" | "rolling_mean_current"
| "rolling_max_current"
| "rolling_return_stddev_current"
| "ma" | "ma"
| "sma" | "sma"
| "vma" | "vma"
@@ -1398,38 +1410,73 @@ impl PlatformExprStrategy {
} }
fn sync_position_entry_dates(&mut self, portfolio: &PortfolioState, signal_date: NaiveDate) { fn sync_position_entry_dates(&mut self, portfolio: &PortfolioState, signal_date: NaiveDate) {
self.position_entry_dates.retain(|symbol, _| { let is_held = |symbol: &str| {
portfolio portfolio
.position(symbol) .position(symbol)
.map(|position| position.quantity > 0) .map(|position| position.quantity > 0)
.unwrap_or(false) .unwrap_or(false)
}); };
self.position_entry_dates
.retain(|symbol, _| is_held(symbol));
self.position_holding_days
.retain(|symbol, _| is_held(symbol));
self.position_holding_days_last_counted
.retain(|symbol, _| is_held(symbol));
for position in portfolio.positions().values() { for position in portfolio.positions().values() {
if position.quantity == 0 { if position.quantity == 0 {
continue; continue;
} }
let symbol = position.symbol.clone();
self.position_entry_dates self.position_entry_dates
.entry(position.symbol.clone()) .entry(symbol.clone())
.or_insert(signal_date); .or_insert(signal_date);
let holding_days = self
.position_holding_days
.entry(symbol.clone())
.or_insert(0);
match self
.position_holding_days_last_counted
.get(&symbol)
.copied()
{
Some(last_counted) if last_counted < signal_date => {
*holding_days = holding_days.saturating_add(1);
self.position_holding_days_last_counted
.insert(symbol, signal_date);
}
None => {
self.position_holding_days_last_counted
.insert(symbol, signal_date);
}
_ => {}
}
} }
} }
fn remember_position_entry_date(&mut self, symbol: &str, signal_date: NaiveDate) { fn remember_position_entry_date(&mut self, symbol: &str, signal_date: NaiveDate) {
if !symbol.trim().is_empty() { if !symbol.trim().is_empty() {
let symbol = symbol.to_string();
self.position_entry_dates self.position_entry_dates
.entry(symbol.to_string()) .entry(symbol.clone())
.or_insert(signal_date);
self.position_holding_days
.entry(symbol.clone())
.or_insert(0);
self.position_holding_days_last_counted
.entry(symbol)
.or_insert(signal_date); .or_insert(signal_date);
} }
} }
fn forget_position_entry_date(&mut self, symbol: &str) { fn forget_position_entry_date(&mut self, symbol: &str) {
self.position_entry_dates.remove(symbol); self.position_entry_dates.remove(symbol);
self.position_holding_days.remove(symbol);
self.position_holding_days_last_counted.remove(symbol);
} }
fn max_holding_days_exceeded(&self, signal_date: NaiveDate, symbol: &str) -> Option<i64> { fn max_holding_days_exceeded(&self, symbol: &str) -> Option<i64> {
let max_days = self.config.max_holding_days.filter(|value| *value > 0)?; let max_days = self.config.max_holding_days.filter(|value| *value > 0)?;
let entry_date = *self.position_entry_dates.get(symbol)?; let holding_days = *self.position_holding_days.get(symbol)?;
let holding_days = signal_date.signed_duration_since(entry_date).num_days();
(holding_days >= max_days).then_some(holding_days) (holding_days >= max_days).then_some(holding_days)
} }
@@ -4694,6 +4741,45 @@ impl PlatformExprStrategy {
Dynamic::from(value), Dynamic::from(value),
)) ))
} }
"rolling_max_current" => {
if args.len() != 2 {
return Err(BacktestError::Execution(
"rolling_max_current expects field and lookback".to_string(),
));
}
let field = Self::parse_string_or_identifier(&args[0])?;
let lookback = Self::parse_positive_usize(&args[1])?;
let values =
self.resolve_current_rolling_values(ctx, day, stock, &field, lookback)?;
let value = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Ok(Self::format_rhai_float(value))
}
"rolling_return_stddev_current" => {
if args.len() != 2 {
return Err(BacktestError::Execution(
"rolling_return_stddev_current expects field and return count".to_string(),
));
}
let field = Self::parse_string_or_identifier(&args[0])?;
let return_count = Self::parse_positive_usize(&args[1])?;
let values = self.resolve_current_rolling_values(
ctx,
day,
stock,
&field,
return_count.saturating_add(1),
)?;
let returns = values
.windows(2)
.map(|pair| pair[1] / pair[0] - 1.0)
.collect::<Vec<_>>();
if returns.iter().any(|value| !value.is_finite()) {
return Err(BacktestError::Execution(format!(
"invalid current rolling return for field {field} with count {return_count}"
)));
}
Ok(Self::format_rhai_float(rolling_sample_stddev(&returns)))
}
"vma" => { "vma" => {
if args.len() != 1 { if args.len() != 1 {
return Err(BacktestError::Execution( return Err(BacktestError::Execution(
@@ -5381,12 +5467,69 @@ impl PlatformExprStrategy {
Ok(values) Ok(values)
} }
fn resolve_current_rolling_values(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
field: &str,
lookback: usize,
) -> Result<Vec<f64>, BacktestError> {
if lookback == 0 {
return Err(BacktestError::Execution(
"current rolling lookback must be positive".to_string(),
));
}
let values = match field {
"benchmark_open" => ctx
.data
.benchmark_numeric_values(day.date, "open", lookback),
"benchmark_close" => ctx
.data
.benchmark_numeric_values(day.date, "close", lookback),
"signal_open" => ctx.data.market_current_numeric_values(
day.date,
&self.config.signal_symbol,
"open",
lookback,
),
"signal_close" => ctx.data.market_current_numeric_values(
day.date,
&self.config.signal_symbol,
"close",
lookback,
),
"signal_volume" => ctx.data.market_current_numeric_values(
day.date,
&self.config.signal_symbol,
"volume",
lookback,
),
other => {
let stock = stock.ok_or_else(|| {
BacktestError::Execution(format!(
"current rolling helper for {other} requires stock context"
))
})?;
ctx.data
.market_current_numeric_values(day.date, &stock.symbol, other, lookback)
}
};
if values.len() < lookback {
return Err(BacktestError::Execution(format!(
"missing current rolling values for field {field} with lookback {lookback}"
)));
}
Ok(values)
}
fn is_missing_rolling_mean_error(error: &BacktestError) -> bool { fn is_missing_rolling_mean_error(error: &BacktestError) -> bool {
matches!( matches!(
error, error,
BacktestError::Execution(message) BacktestError::Execution(message)
if message.starts_with("missing rolling mean for field ") if message.starts_with("missing rolling mean for field ")
|| message.starts_with("missing current rolling mean for field ") || message.starts_with("missing current rolling mean for field ")
|| message.starts_with("missing current rolling values for field ")
) )
} }
@@ -5843,6 +5986,25 @@ impl PlatformExprStrategy {
Ok(value.round().max(1.0) as usize) Ok(value.round().max(1.0) as usize)
} }
fn selection_candidate_limit(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
selection_limit: usize,
) -> Result<usize, BacktestError> {
let expr = self.config.selection_candidate_limit_expr.trim();
if expr.is_empty() {
return Ok(self.selection_buffer_rank(selection_limit));
}
let value = self.eval_float(ctx, expr, day, None, None)?;
if !value.is_finite() {
return Err(BacktestError::Execution(format!(
"platform selection_candidate_limit_expr did not produce a finite number: {expr}"
)));
}
Ok(value.round().max(selection_limit as f64) as usize)
}
fn effective_refresh_rate( fn effective_refresh_rate(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -8437,7 +8599,7 @@ impl PlatformExprStrategy {
let selection_limit = self let selection_limit = self
.selection_limit(ctx, &day)? .selection_limit(ctx, &day)?
.min(self.config.max_positions.max(1)); .min(self.config.max_positions.max(1));
let quote_selection_limit = self.selection_buffer_rank(selection_limit); let quote_selection_limit = self.selection_candidate_limit(ctx, &day, selection_limit)?;
let (candidate_symbols, order_symbols, processed_scope, diagnostics) = self let (candidate_symbols, order_symbols, processed_scope, diagnostics) = self
.select_quote_plan_symbols( .select_quote_plan_symbols(
ctx, ctx,
@@ -8754,7 +8916,8 @@ impl Strategy for PlatformExprStrategy {
}; };
let mut risk_decisions = Vec::new(); let mut risk_decisions = Vec::new();
let stock_list = if self.config.rotation_enabled && !in_skip_window { let stock_list = if self.config.rotation_enabled && !in_skip_window {
let selection_buffer_rank = self.selection_buffer_rank(selection_limit); let selection_buffer_rank =
self.selection_candidate_limit(ctx, &day, selection_limit)?;
let ranked_selection_limit = if self.config.daily_replacement_limit > 0 { let ranked_selection_limit = if self.config.daily_replacement_limit > 0 {
usize::MAX usize::MAX
} else { } else {
@@ -8778,13 +8941,25 @@ impl Strategy for PlatformExprStrategy {
.keys() .keys()
.cloned() .cloned()
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
Self::buffered_selection( let selected = Self::buffered_selection(
&ranked_stock_list, &ranked_stock_list,
&held_symbols, &held_symbols,
selection_limit, selection_limit,
selection_buffer_rank, selection_buffer_rank,
self.config.daily_replacement_limit, self.config.daily_replacement_limit,
) );
if self.config.hold_until_exit_enabled {
let mut candidates = selected;
let mut seen = candidates.iter().cloned().collect::<BTreeSet<_>>();
for symbol in ranked_stock_list.iter().take(selection_buffer_rank) {
if seen.insert(symbol.clone()) {
candidates.push(symbol.clone());
}
}
candidates
} else {
selected
}
} else { } else {
Vec::new() Vec::new()
}; };
@@ -8792,7 +8967,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 !self.config.signal_rebalance_dates.is_empty() { if self.config.hold_until_exit_enabled {
self.last_rebalance_date.is_none() || empty_rebalance_retry
} else if !self.config.signal_rebalance_dates.is_empty() {
self.config.signal_rebalance_dates.contains(&decision_date) || empty_rebalance_retry self.config.signal_rebalance_dates.contains(&decision_date) || empty_rebalance_retry
} else if let Some(schedule) = &self.config.rebalance_schedule { } else if let Some(schedule) = &self.config.rebalance_schedule {
schedule.matches( schedule.matches(
@@ -9059,9 +9236,7 @@ impl Strategy for PlatformExprStrategy {
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) { if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
continue; continue;
} }
let Some(holding_days) = let Some(holding_days) = self.max_holding_days_exceeded(&position.symbol) else {
self.max_holding_days_exceeded(signal_date, &position.symbol)
else {
continue; continue;
}; };
pending_full_close_symbols.insert(position.symbol.clone()); pending_full_close_symbols.insert(position.symbol.clone());
@@ -9193,12 +9368,11 @@ impl Strategy for PlatformExprStrategy {
&& self.config.rotation_enabled && self.config.rotation_enabled
&& self.config.daily_position_target_adjust_enabled && self.config.daily_position_target_adjust_enabled
&& trading_ratio > 0.0 && trading_ratio > 0.0
&& trading_ratio < 1.0 && (self.config.target_portfolio_daily_enabled || trading_ratio < 1.0)
&& selection_limit > 0 && selection_limit > 0
&& !ctx.portfolio.positions().is_empty() && !ctx.portfolio.positions().is_empty()
{ {
let target_value = aiquant_total_value * trading_ratio / selection_limit as f64; if aiquant_total_value.is_finite() && aiquant_total_value > 0.0 {
if target_value.is_finite() && target_value > 0.0 {
for position in ctx.portfolio.positions().values() { for position in ctx.portfolio.positions().values() {
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) { if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
continue; continue;
@@ -9216,16 +9390,29 @@ impl Strategy for PlatformExprStrategy {
if pending_full_close_symbols.contains(&position.symbol) { if pending_full_close_symbols.contains(&position.symbol) {
continue; continue;
} }
let decision_stock = self.stock_state_with_factor_date(
ctx,
decision_date,
selection_factor_date,
&position.symbol,
)?;
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
let target_value =
aiquant_total_value * trading_ratio / selection_limit as f64 * stock_scale;
if !target_value.is_finite() || target_value <= 0.0 {
continue;
}
let current_value = self.projected_position_value_at_execution_price( let current_value = self.projected_position_value_at_execution_price(
ctx, ctx,
&projected, &projected,
projection_date, projection_date,
&position.symbol, &position.symbol,
); );
if let Some(threshold) = self if !self.config.target_portfolio_daily_enabled
.config && let Some(threshold) = self
.weak_market_shrink_overweight_threshold .config
.filter(|value| value.is_finite() && *value > 0.0) .weak_market_shrink_overweight_threshold
.filter(|value| value.is_finite() && *value > 0.0)
{ {
if current_value <= target_value * threshold { if current_value <= target_value * threshold {
continue; continue;
@@ -9255,10 +9442,11 @@ impl Strategy for PlatformExprStrategy {
{ {
continue; continue;
} }
if self if !self.config.target_portfolio_daily_enabled
.config && self
.weak_market_shrink_overweight_threshold .config
.is_some() .weak_market_shrink_overweight_threshold
.is_some()
&& quantity_delta >= 0 && quantity_delta >= 0
{ {
continue; continue;
@@ -10010,6 +10198,9 @@ impl Strategy for PlatformExprStrategy {
} }
} }
} }
if self.config.hold_until_exit_enabled && periodic_rebalance {
self.last_rebalance_date = Some(signal_date);
}
if self.config.rotation_enabled && self.config.rebalance_schedule.is_none() { if self.config.rotation_enabled && self.config.rebalance_schedule.is_none() {
if self.config.calendar_rebalance_interval { if self.config.calendar_rebalance_interval {
if periodic_rebalance { if periodic_rebalance {
@@ -10136,6 +10327,22 @@ fn rolling_stddev(values: &[f64]) -> f64 {
variance.sqrt() variance.sqrt()
} }
fn rolling_sample_stddev(values: &[f64]) -> f64 {
if values.len() < 2 {
return 0.0;
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values
.iter()
.map(|value| {
let diff = value - mean;
diff * diff
})
.sum::<f64>()
/ (values.len() - 1) as f64;
variance.sqrt()
}
fn rolling_zscore(values: &[f64]) -> f64 { fn rolling_zscore(values: &[f64]) -> f64 {
let Some(latest) = values.last().copied() else { let Some(latest) = values.last().copied() else {
return 0.0; return 0.0;
@@ -20279,7 +20486,7 @@ mod tests {
cfg.market_cap_upper_expr = "100".to_string(); cfg.market_cap_upper_expr = "100".to_string();
cfg.selection_limit_expr = "1".to_string(); cfg.selection_limit_expr = "1".to_string();
cfg.stock_filter_expr = concat!( cfg.stock_filter_expr = concat!(
"rolling_mean(\"volume\", 60) > 0", "rolling_max_current(\"volume\", 60) > 0",
" && pct_change(\"close\", 1) > 0.0" " && pct_change(\"close\", 1) > 0.0"
) )
.to_string(); .to_string();
@@ -20765,6 +20972,9 @@ mod tests {
" && vma(2) == 150.0", " && vma(2) == 150.0",
" && rolling_mean_current(\"close\", 2) == 11.7", " && rolling_mean_current(\"close\", 2) == 11.7",
" && rolling_mean_current(\"volume\", 2) == 250.0", " && rolling_mean_current(\"volume\", 2) == 250.0",
" && rolling_max_current(\"close\", 2) == 12.2",
" && rolling_return_stddev_current(\"close\", 2) > 0.006",
" && rolling_return_stddev_current(\"close\", 2) < 0.007",
" && rolling_sum(\"volume\", 2) == 300.0", " && rolling_sum(\"volume\", 2) == 300.0",
" && rolling_min(\"close\", 2) == 11.0", " && rolling_min(\"close\", 2) == 11.0",
" && rolling_max(\"close\", 2) == 12.0", " && rolling_max(\"close\", 2) == 12.0",
@@ -23694,6 +23904,30 @@ mod tests {
assert!(top_up_symbols.is_empty(), "{top_up_symbols:?}"); assert!(top_up_symbols.is_empty(), "{top_up_symbols:?}");
} }
#[test]
fn platform_max_holding_days_counts_decision_trading_days_not_calendar_days() {
let symbol = "000001.SZ";
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.max_holding_days = Some(3);
let mut strategy = PlatformExprStrategy::new(cfg);
let entry_signal_date = d(2025, 1, 2);
strategy.remember_position_entry_date(symbol, entry_signal_date);
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.position_mut(symbol)
.buy(entry_signal_date, 100, 10.0);
strategy.sync_position_entry_dates(&portfolio, d(2025, 1, 3));
strategy.sync_position_entry_dates(&portfolio, d(2025, 1, 3));
strategy.sync_position_entry_dates(&portfolio, d(2025, 1, 6));
assert_eq!(strategy.position_holding_days.get(symbol), Some(&2));
assert_eq!(strategy.max_holding_days_exceeded(symbol), None);
strategy.sync_position_entry_dates(&portfolio, d(2025, 1, 7));
assert_eq!(strategy.max_holding_days_exceeded(symbol), Some(3));
}
#[test] #[test]
fn platform_max_holding_days_exit_preempts_take_profit_exit() { fn platform_max_holding_days_exit_preempts_take_profit_exit() {
let date = d(2025, 2, 26); let date = d(2025, 2, 26);
@@ -23829,6 +24063,12 @@ mod tests {
strategy strategy
.position_entry_dates .position_entry_dates
.insert(symbol.to_string(), entry_date); .insert(symbol.to_string(), entry_date);
strategy
.position_holding_days
.insert(symbol.to_string(), 90);
strategy
.position_holding_days_last_counted
.insert(symbol.to_string(), date);
let decision = strategy.on_day(&ctx).expect("platform decision"); let decision = strategy.on_day(&ctx).expect("platform decision");
@@ -228,6 +228,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
"day_factor", "day_factor",
"rolling_mean", "rolling_mean",
"rolling_mean_current", "rolling_mean_current",
"rolling_max_current",
"rolling_return_stddev_current",
"ma", "ma",
"sma", "sma",
"vma", "vma",
@@ -640,6 +640,8 @@ pub struct StrategyExpressionSelectionConfig {
#[serde(default)] #[serde(default)]
pub limit_expr: Option<String>, pub limit_expr: Option<String>,
#[serde(default)] #[serde(default)]
pub candidate_limit_expr: Option<String>,
#[serde(default)]
pub market_cap_field: Option<String>, pub market_cap_field: Option<String>,
#[serde(default)] #[serde(default)]
pub market_cap_lower_expr: Option<String>, pub market_cap_lower_expr: Option<String>,
@@ -711,8 +713,12 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)] #[serde(default)]
pub daily_position_target_adjust: Option<bool>, pub daily_position_target_adjust: Option<bool>,
#[serde(default)] #[serde(default)]
pub target_portfolio_daily: Option<bool>,
#[serde(default)]
pub rebalance_existing_positions: Option<bool>, pub rebalance_existing_positions: Option<bool>,
#[serde(default)] #[serde(default)]
pub hold_until_exit: Option<bool>,
#[serde(default)]
pub selection_buffer_multiple: Option<f64>, pub selection_buffer_multiple: Option<f64>,
#[serde(default)] #[serde(default)]
pub daily_replacement_limit: Option<usize>, pub daily_replacement_limit: Option<usize>,
@@ -1549,6 +1555,13 @@ pub fn platform_expr_config_from_spec(
{ {
cfg.selection_limit_expr = expr.clone(); cfg.selection_limit_expr = expr.clone();
} }
if let Some(expr) = selection
.candidate_limit_expr
.as_ref()
.filter(|value| !value.trim().is_empty())
{
cfg.selection_candidate_limit_expr = expr.clone();
}
if let Some(field) = selection if let Some(field) = selection
.market_cap_field .market_cap_field
.as_ref() .as_ref()
@@ -1695,9 +1708,18 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = trading.daily_position_target_adjust { if let Some(enabled) = trading.daily_position_target_adjust {
cfg.daily_position_target_adjust_enabled = enabled; cfg.daily_position_target_adjust_enabled = enabled;
} }
if let Some(enabled) = trading.target_portfolio_daily {
cfg.target_portfolio_daily_enabled = enabled;
if enabled {
cfg.daily_position_target_adjust_enabled = true;
}
}
if let Some(enabled) = trading.rebalance_existing_positions { if let Some(enabled) = trading.rebalance_existing_positions {
cfg.rebalance_existing_positions = enabled; cfg.rebalance_existing_positions = enabled;
} }
if let Some(enabled) = trading.hold_until_exit {
cfg.hold_until_exit_enabled = enabled;
}
if let Some(multiple) = trading if let Some(multiple) = trading
.selection_buffer_multiple .selection_buffer_multiple
.filter(|value| value.is_finite() && *value >= 1.0) .filter(|value| value.is_finite() && *value >= 1.0)
@@ -2479,6 +2501,35 @@ mod tests {
assert_eq!(cfg.max_holding_days, Some(90)); assert_eq!(cfg.max_holding_days, Some(90));
} }
#[test]
fn parses_lifecycle_target_portfolio_contract() {
let spec = serde_json::json!({
"runtimeExpressions": {
"selection": {
"limitExpr": "30",
"candidateLimitExpr": "50"
},
"trading": {
"dailyPositionTargetAdjust": true,
"targetPortfolioDaily": true,
"rebalanceExistingPositions": true,
"holdUntilExit": true,
"releaseSlotOnExitSignal": true
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.selection_limit_expr, "30");
assert_eq!(cfg.selection_candidate_limit_expr, "50");
assert!(cfg.daily_position_target_adjust_enabled);
assert!(cfg.target_portfolio_daily_enabled);
assert!(cfg.rebalance_existing_positions);
assert!(cfg.hold_until_exit_enabled);
assert!(cfg.release_slot_on_exit_signal);
}
#[test] #[test]
fn parses_signal_dates_rebalance_into_platform_config() { fn parses_signal_dates_rebalance_into_platform_config() {
let spec = serde_json::json!({ let spec = serde_json::json!({