完善生命周期持仓与当前日滚动语义
This commit is contained in:
@@ -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(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
|
||||
@@ -339,6 +339,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub market_cap_lower_expr: String,
|
||||
pub market_cap_upper_expr: String,
|
||||
pub selection_limit_expr: String,
|
||||
pub selection_candidate_limit_expr: String,
|
||||
pub stock_filter_expr: String,
|
||||
pub buy_scale_expr: String,
|
||||
pub exposure_expr: String,
|
||||
@@ -359,7 +360,9 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub rotation_enabled: bool,
|
||||
pub daily_top_up_enabled: bool,
|
||||
pub daily_position_target_adjust_enabled: bool,
|
||||
pub target_portfolio_daily_enabled: bool,
|
||||
pub rebalance_existing_positions: bool,
|
||||
pub hold_until_exit_enabled: bool,
|
||||
pub selection_buffer_multiple: f64,
|
||||
pub daily_replacement_limit: usize,
|
||||
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_upper_expr: "band_low(signal_close) + 10".to_string(),
|
||||
selection_limit_expr: "stocknum".to_string(),
|
||||
selection_candidate_limit_expr: String::new(),
|
||||
stock_filter_expr:
|
||||
"stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long"
|
||||
.to_string(),
|
||||
@@ -434,7 +438,9 @@ fn band_low(index_close) {
|
||||
rotation_enabled: true,
|
||||
daily_top_up_enabled: false,
|
||||
daily_position_target_adjust_enabled: true,
|
||||
target_portfolio_daily_enabled: false,
|
||||
rebalance_existing_positions: false,
|
||||
hold_until_exit_enabled: false,
|
||||
selection_buffer_multiple: 1.0,
|
||||
daily_replacement_limit: 0,
|
||||
retry_empty_rebalance: false,
|
||||
@@ -750,6 +756,8 @@ pub struct PlatformExprStrategy {
|
||||
pending_highlimit_holdings: BTreeSet<String>,
|
||||
pending_full_close_symbols: BTreeSet<String>,
|
||||
position_entry_dates: BTreeMap<String, NaiveDate>,
|
||||
position_holding_days: BTreeMap<String, i64>,
|
||||
position_holding_days_last_counted: BTreeMap<String, NaiveDate>,
|
||||
/// 已编译表达式 AST 缓存。
|
||||
/// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本,
|
||||
/// Value 是 Rhai 编译产物。命中后 eval 走 eval_ast_with_scope,避免重复
|
||||
@@ -1032,6 +1040,8 @@ impl PlatformExprStrategy {
|
||||
pending_highlimit_holdings: BTreeSet::new(),
|
||||
pending_full_close_symbols: BTreeSet::new(),
|
||||
position_entry_dates: BTreeMap::new(),
|
||||
position_holding_days: BTreeMap::new(),
|
||||
position_holding_days_last_counted: BTreeMap::new(),
|
||||
compiled_cache: RefCell::new(HashMap::new()),
|
||||
cache_hits: RefCell::new(0),
|
||||
cache_misses: RefCell::new(0),
|
||||
@@ -1319,6 +1329,8 @@ impl PlatformExprStrategy {
|
||||
| "day_factor"
|
||||
| "rolling_mean"
|
||||
| "rolling_mean_current"
|
||||
| "rolling_max_current"
|
||||
| "rolling_return_stddev_current"
|
||||
| "ma"
|
||||
| "sma"
|
||||
| "vma"
|
||||
@@ -1398,38 +1410,73 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
|
||||
fn sync_position_entry_dates(&mut self, portfolio: &PortfolioState, signal_date: NaiveDate) {
|
||||
self.position_entry_dates.retain(|symbol, _| {
|
||||
let is_held = |symbol: &str| {
|
||||
portfolio
|
||||
.position(symbol)
|
||||
.map(|position| position.quantity > 0)
|
||||
.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() {
|
||||
if position.quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
let symbol = position.symbol.clone();
|
||||
self.position_entry_dates
|
||||
.entry(position.symbol.clone())
|
||||
.entry(symbol.clone())
|
||||
.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) {
|
||||
if !symbol.trim().is_empty() {
|
||||
let symbol = symbol.to_string();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn forget_position_entry_date(&mut self, symbol: &str) {
|
||||
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 entry_date = *self.position_entry_dates.get(symbol)?;
|
||||
let holding_days = signal_date.signed_duration_since(entry_date).num_days();
|
||||
let holding_days = *self.position_holding_days.get(symbol)?;
|
||||
(holding_days >= max_days).then_some(holding_days)
|
||||
}
|
||||
|
||||
@@ -4694,6 +4741,45 @@ impl PlatformExprStrategy {
|
||||
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" => {
|
||||
if args.len() != 1 {
|
||||
return Err(BacktestError::Execution(
|
||||
@@ -5381,12 +5467,69 @@ impl PlatformExprStrategy {
|
||||
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 {
|
||||
matches!(
|
||||
error,
|
||||
BacktestError::Execution(message)
|
||||
if message.starts_with("missing 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)
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
@@ -8437,7 +8599,7 @@ impl PlatformExprStrategy {
|
||||
let selection_limit = self
|
||||
.selection_limit(ctx, &day)?
|
||||
.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
|
||||
.select_quote_plan_symbols(
|
||||
ctx,
|
||||
@@ -8754,7 +8916,8 @@ impl Strategy for PlatformExprStrategy {
|
||||
};
|
||||
let mut risk_decisions = Vec::new();
|
||||
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 {
|
||||
usize::MAX
|
||||
} else {
|
||||
@@ -8778,13 +8941,25 @@ impl Strategy for PlatformExprStrategy {
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
Self::buffered_selection(
|
||||
let selected = Self::buffered_selection(
|
||||
&ranked_stock_list,
|
||||
&held_symbols,
|
||||
selection_limit,
|
||||
selection_buffer_rank,
|
||||
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 {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -8792,7 +8967,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 !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
|
||||
} else if let Some(schedule) = &self.config.rebalance_schedule {
|
||||
schedule.matches(
|
||||
@@ -9059,9 +9236,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
|
||||
continue;
|
||||
}
|
||||
let Some(holding_days) =
|
||||
self.max_holding_days_exceeded(signal_date, &position.symbol)
|
||||
else {
|
||||
let Some(holding_days) = self.max_holding_days_exceeded(&position.symbol) else {
|
||||
continue;
|
||||
};
|
||||
pending_full_close_symbols.insert(position.symbol.clone());
|
||||
@@ -9193,12 +9368,11 @@ impl Strategy for PlatformExprStrategy {
|
||||
&& self.config.rotation_enabled
|
||||
&& self.config.daily_position_target_adjust_enabled
|
||||
&& trading_ratio > 0.0
|
||||
&& trading_ratio < 1.0
|
||||
&& (self.config.target_portfolio_daily_enabled || trading_ratio < 1.0)
|
||||
&& selection_limit > 0
|
||||
&& !ctx.portfolio.positions().is_empty()
|
||||
{
|
||||
let target_value = aiquant_total_value * trading_ratio / selection_limit as f64;
|
||||
if target_value.is_finite() && target_value > 0.0 {
|
||||
if aiquant_total_value.is_finite() && aiquant_total_value > 0.0 {
|
||||
for position in ctx.portfolio.positions().values() {
|
||||
if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) {
|
||||
continue;
|
||||
@@ -9216,16 +9390,29 @@ impl Strategy for PlatformExprStrategy {
|
||||
if pending_full_close_symbols.contains(&position.symbol) {
|
||||
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(
|
||||
ctx,
|
||||
&projected,
|
||||
projection_date,
|
||||
&position.symbol,
|
||||
);
|
||||
if let Some(threshold) = self
|
||||
.config
|
||||
.weak_market_shrink_overweight_threshold
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
if !self.config.target_portfolio_daily_enabled
|
||||
&& let Some(threshold) = self
|
||||
.config
|
||||
.weak_market_shrink_overweight_threshold
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
{
|
||||
if current_value <= target_value * threshold {
|
||||
continue;
|
||||
@@ -9255,10 +9442,11 @@ impl Strategy for PlatformExprStrategy {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.config
|
||||
.weak_market_shrink_overweight_threshold
|
||||
.is_some()
|
||||
if !self.config.target_portfolio_daily_enabled
|
||||
&& self
|
||||
.config
|
||||
.weak_market_shrink_overweight_threshold
|
||||
.is_some()
|
||||
&& quantity_delta >= 0
|
||||
{
|
||||
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.calendar_rebalance_interval {
|
||||
if periodic_rebalance {
|
||||
@@ -10136,6 +10327,22 @@ fn rolling_stddev(values: &[f64]) -> f64 {
|
||||
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 {
|
||||
let Some(latest) = values.last().copied() else {
|
||||
return 0.0;
|
||||
@@ -20279,7 +20486,7 @@ mod tests {
|
||||
cfg.market_cap_upper_expr = "100".to_string();
|
||||
cfg.selection_limit_expr = "1".to_string();
|
||||
cfg.stock_filter_expr = concat!(
|
||||
"rolling_mean(\"volume\", 60) > 0",
|
||||
"rolling_max_current(\"volume\", 60) > 0",
|
||||
" && pct_change(\"close\", 1) > 0.0"
|
||||
)
|
||||
.to_string();
|
||||
@@ -20765,6 +20972,9 @@ mod tests {
|
||||
" && vma(2) == 150.0",
|
||||
" && rolling_mean_current(\"close\", 2) == 11.7",
|
||||
" && 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_min(\"close\", 2) == 11.0",
|
||||
" && rolling_max(\"close\", 2) == 12.0",
|
||||
@@ -23694,6 +23904,30 @@ mod tests {
|
||||
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]
|
||||
fn platform_max_holding_days_exit_preempts_take_profit_exit() {
|
||||
let date = d(2025, 2, 26);
|
||||
@@ -23829,6 +24063,12 @@ mod tests {
|
||||
strategy
|
||||
.position_entry_dates
|
||||
.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");
|
||||
|
||||
|
||||
@@ -228,6 +228,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
|
||||
"day_factor",
|
||||
"rolling_mean",
|
||||
"rolling_mean_current",
|
||||
"rolling_max_current",
|
||||
"rolling_return_stddev_current",
|
||||
"ma",
|
||||
"sma",
|
||||
"vma",
|
||||
|
||||
@@ -640,6 +640,8 @@ pub struct StrategyExpressionSelectionConfig {
|
||||
#[serde(default)]
|
||||
pub limit_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub candidate_limit_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub market_cap_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub market_cap_lower_expr: Option<String>,
|
||||
@@ -711,8 +713,12 @@ pub struct StrategyExpressionTradingConfig {
|
||||
#[serde(default)]
|
||||
pub daily_position_target_adjust: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub target_portfolio_daily: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub rebalance_existing_positions: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub hold_until_exit: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub selection_buffer_multiple: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub daily_replacement_limit: Option<usize>,
|
||||
@@ -1549,6 +1555,13 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
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
|
||||
.market_cap_field
|
||||
.as_ref()
|
||||
@@ -1695,9 +1708,18 @@ pub fn platform_expr_config_from_spec(
|
||||
if let Some(enabled) = trading.daily_position_target_adjust {
|
||||
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 {
|
||||
cfg.rebalance_existing_positions = enabled;
|
||||
}
|
||||
if let Some(enabled) = trading.hold_until_exit {
|
||||
cfg.hold_until_exit_enabled = enabled;
|
||||
}
|
||||
if let Some(multiple) = trading
|
||||
.selection_buffer_multiple
|
||||
.filter(|value| value.is_finite() && *value >= 1.0)
|
||||
@@ -2479,6 +2501,35 @@ mod tests {
|
||||
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]
|
||||
fn parses_signal_dates_rebalance_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user