完善生命周期持仓与当前日滚动语义
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(
|
pub fn factor_numeric_values(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
Reference in New Issue
Block a user