From 8c86918970838421579702d8fe0c720cddbfe1e9 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 28 May 2026 10:39:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=BE=AE=E7=9B=98=E4=B9=B0?= =?UTF-8?q?=E5=85=A5=E9=A2=84=E7=AE=97=E4=B8=8E=E8=A1=A8=E8=BE=BE=E5=BC=8F?= =?UTF-8?q?=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 56 +- .../fidc-core/src/platform_expr_strategy.rs | 1286 ++++++++++++----- 2 files changed, 937 insertions(+), 405 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 423a72c..952f265 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -453,11 +453,13 @@ struct SymbolPriceSeries { closes: Vec, prev_closes: Vec, last_prices: Vec, - paused: Vec, open_prefix: Vec, close_prefix: Vec, prev_close_prefix: Vec, last_prefix: Vec, + unpaused_volumes: Vec, + unpaused_volume_prefix: Vec, + unpaused_count_prefix: Vec, } impl SymbolPriceSeries { @@ -470,11 +472,20 @@ impl SymbolPriceSeries { let closes = sorted.iter().map(|row| row.close).collect::>(); let prev_closes = sorted.iter().map(|row| row.prev_close).collect::>(); let last_prices = sorted.iter().map(|row| row.last_price).collect::>(); - let paused = sorted.iter().map(|row| row.paused).collect::>(); let open_prefix = prefix_sums(&opens); let close_prefix = prefix_sums(&closes); let prev_close_prefix = prefix_sums(&prev_closes); let last_prefix = prefix_sums(&last_prices); + let mut unpaused_volumes = Vec::new(); + let mut unpaused_count_prefix = Vec::with_capacity(sorted.len() + 1); + unpaused_count_prefix.push(0); + for row in &sorted { + if !row.paused { + unpaused_volumes.push(row.volume as f64); + } + unpaused_count_prefix.push(unpaused_volumes.len()); + } + let unpaused_volume_prefix = prefix_sums(&unpaused_volumes); Self { snapshots: sorted, @@ -483,11 +494,13 @@ impl SymbolPriceSeries { closes, prev_closes, last_prices, - paused, open_prefix, close_prefix, prev_close_prefix, last_prefix, + unpaused_volumes, + unpaused_volume_prefix, + unpaused_count_prefix, } } @@ -597,11 +610,12 @@ impl SymbolPriceSeries { return None; } let end = self.end_index(date)?; - let values = self.trailing_unpaused_volumes(end, lookback)?; - if values.len() < lookback { + let end_count = *self.unpaused_count_prefix.get(end)?; + if end_count < lookback { return None; } - let sum = values.iter().sum::(); + let start_count = end_count - lookback; + let sum = self.unpaused_volume_prefix[end_count] - self.unpaused_volume_prefix[start_count]; Some(sum / lookback as f64) } @@ -621,22 +635,12 @@ impl SymbolPriceSeries { if lookback == 0 || end == 0 { return None; } - let mut values = Vec::with_capacity(lookback); - for idx in (0..end).rev() { - if self.paused.get(idx).copied().unwrap_or(false) { - continue; - } - values.push(self.snapshots[idx].volume as f64); - if values.len() == lookback { - break; - } - } - if values.len() < lookback { - None - } else { - values.reverse(); - Some(values) + let end_count = *self.unpaused_count_prefix.get(end)?; + if end_count < lookback { + return None; } + let start_count = end_count - lookback; + Some(self.unpaused_volumes[start_count..end_count].to_vec()) } fn end_index(&self, date: NaiveDate) -> Option { @@ -2146,12 +2150,10 @@ impl DataSet { self.market_moving_average(date, symbol, lookback, PriceField::Close) } "volume" | "stock_volume" => self - .factor_moving_average(date, symbol, "daily_volume", lookback) - .or_else(|| { - self.market_series_by_symbol - .get(symbol) - .and_then(|series| series.current_volume_moving_average(date, lookback)) - }), + .market_series_by_symbol + .get(symbol) + .and_then(|series| series.current_volume_moving_average(date, lookback)) + .or_else(|| self.factor_moving_average(date, symbol, "daily_volume", lookback)), "day_open" | "dayopen" => { self.market_moving_average(date, symbol, lookback, PriceField::DayOpen) } diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index df075ed..385dc69 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1238,6 +1238,17 @@ impl PlatformExprStrategy { .count() } + fn pending_exit_exclusion_symbols( + unresolved_stop_loss_symbols: &BTreeSet, + exit_symbols: &BTreeSet, + delayed_sold_symbols: &BTreeSet, + ) -> BTreeSet { + let mut excluded = unresolved_stop_loss_symbols.clone(); + excluded.extend(exit_symbols.iter().cloned()); + excluded.extend(delayed_sold_symbols.iter().cloned()); + excluded + } + fn projected_position_value_at_execution_price( &self, ctx: &StrategyContext<'_>, @@ -1259,6 +1270,47 @@ impl PlatformExprStrategy { } } + fn projected_position_value_excluding( + &self, + ctx: &StrategyContext<'_>, + projected: &PortfolioState, + date: NaiveDate, + excluded_symbols: &BTreeSet, + ) -> f64 { + projected + .positions() + .keys() + .filter(|symbol| !excluded_symbols.contains(*symbol)) + .map(|symbol| { + self.projected_position_value_at_execution_price(ctx, projected, date, symbol) + }) + .filter(|value| value.is_finite() && *value > 0.0) + .sum() + } + + fn remaining_buy_cash_per_slot( + &self, + ctx: &StrategyContext<'_>, + projected: &PortfolioState, + date: NaiveDate, + target_budget: f64, + selection_limit: usize, + excluded_symbols: &BTreeSet, + ) -> f64 { + if selection_limit == 0 || !target_budget.is_finite() || target_budget <= 0.0 { + return 0.0; + } + let active_count = Self::projected_position_count_excluding(projected, excluded_symbols); + if active_count >= selection_limit { + return 0.0; + } + let slots_remaining = selection_limit.saturating_sub(active_count).max(1); + let active_value = + self.projected_position_value_excluding(ctx, projected, date, excluded_symbols); + let remaining_budget = (target_budget - active_value).max(0.0); + remaining_budget / slots_remaining as f64 + } + fn project_order_value( &self, ctx: &StrategyContext<'_>, @@ -1704,6 +1756,9 @@ impl PlatformExprStrategy { day: &DayExpressionState, stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, + include_day_factors: bool, + include_factors_map: bool, + include_process_event_counts: bool, ) -> Scope<'static> { let mut scope = Scope::new(); scope.push("signal_open", day.signal_open); @@ -1820,188 +1875,194 @@ impl PlatformExprStrategy { "latest_process_detail", ctx.latest_process_event_detail().to_string(), ); - let mut process_event_counts = Map::new(); - for (key, value) in ctx.process_event_counts() { - process_event_counts.insert(key.into(), Dynamic::from(value)); + let process_event_counts = if include_day_factors || include_process_event_counts { + let mut counts = Map::new(); + for (key, value) in ctx.process_event_counts() { + counts.insert(key.into(), Dynamic::from(value)); + } + scope.push("process_event_counts", counts.clone()); + Some(counts) + } else { + None + }; + if include_day_factors { + let mut day_factors = Map::new(); + day_factors.insert("signal_open".into(), Dynamic::from(day.signal_open)); + day_factors.insert("signal_close".into(), Dynamic::from(day.signal_close)); + day_factors.insert("benchmark_open".into(), Dynamic::from(day.benchmark_open)); + day_factors.insert("benchmark_close".into(), Dynamic::from(day.benchmark_close)); + day_factors.insert("signal_ma5".into(), Dynamic::from(day.signal_ma5)); + day_factors.insert("signal_ma10".into(), Dynamic::from(day.signal_ma10)); + day_factors.insert("signal_ma20".into(), Dynamic::from(day.signal_ma20)); + day_factors.insert("signal_ma30".into(), Dynamic::from(day.signal_ma30)); + day_factors.insert("benchmark_ma5".into(), Dynamic::from(day.benchmark_ma5)); + day_factors.insert("benchmark_ma10".into(), Dynamic::from(day.benchmark_ma10)); + day_factors.insert("benchmark_ma20".into(), Dynamic::from(day.benchmark_ma20)); + day_factors.insert("benchmark_ma30".into(), Dynamic::from(day.benchmark_ma30)); + day_factors.insert( + "benchmark_ma_short".into(), + Dynamic::from(day.benchmark_ma_short), + ); + day_factors.insert( + "benchmark_ma_long".into(), + Dynamic::from(day.benchmark_ma_long), + ); + day_factors.insert("cash".into(), Dynamic::from(day.cash)); + day_factors.insert("available_cash".into(), Dynamic::from(day.available_cash)); + day_factors.insert("frozen_cash".into(), Dynamic::from(day.frozen_cash)); + day_factors.insert("market_value".into(), Dynamic::from(day.market_value)); + day_factors.insert("total_equity".into(), Dynamic::from(day.total_equity)); + day_factors.insert("total_value".into(), Dynamic::from(day.total_value)); + day_factors.insert("portfolio_value".into(), Dynamic::from(day.portfolio_value)); + day_factors.insert("starting_cash".into(), Dynamic::from(day.starting_cash)); + day_factors.insert("unit_net_value".into(), Dynamic::from(day.unit_net_value)); + day_factors.insert( + "static_unit_net_value".into(), + Dynamic::from(day.static_unit_net_value), + ); + day_factors.insert("daily_pnl".into(), Dynamic::from(day.daily_pnl)); + day_factors.insert("daily_returns".into(), Dynamic::from(day.daily_returns)); + day_factors.insert("total_returns".into(), Dynamic::from(day.total_returns)); + day_factors.insert( + "transaction_cost".into(), + Dynamic::from(day.transaction_cost), + ); + day_factors.insert("trading_pnl".into(), Dynamic::from(day.trading_pnl)); + day_factors.insert("position_pnl".into(), Dynamic::from(day.position_pnl)); + day_factors.insert( + "cash_liabilities".into(), + Dynamic::from(day.cash_liabilities), + ); + day_factors.insert( + "management_fee_rate".into(), + Dynamic::from(day.management_fee_rate), + ); + day_factors.insert("management_fees".into(), Dynamic::from(day.management_fees)); + day_factors.insert( + "current_exposure".into(), + Dynamic::from(day.current_exposure), + ); + day_factors.insert("position_count".into(), Dynamic::from(day.position_count)); + day_factors.insert("max_positions".into(), Dynamic::from(day.max_positions)); + day_factors.insert("refresh_rate".into(), Dynamic::from(day.refresh_rate)); + day_factors.insert("year".into(), Dynamic::from(day.year)); + day_factors.insert("month".into(), Dynamic::from(day.month)); + day_factors.insert("quarter".into(), Dynamic::from(day.quarter)); + day_factors.insert("day_of_month".into(), Dynamic::from(day.day_of_month)); + day_factors.insert("day_of_year".into(), Dynamic::from(day.day_of_year)); + day_factors.insert("week_of_year".into(), Dynamic::from(day.week_of_year)); + day_factors.insert("weekday".into(), Dynamic::from(day.weekday)); + day_factors.insert("is_month_start".into(), Dynamic::from(day.is_month_start)); + day_factors.insert("is_month_end".into(), Dynamic::from(day.is_month_end)); + day_factors.insert( + "has_open_orders".into(), + Dynamic::from(ctx.has_open_orders()), + ); + day_factors.insert( + "open_order_count".into(), + Dynamic::from(ctx.open_order_count() as i64), + ); + day_factors.insert( + "open_buy_order_count".into(), + Dynamic::from(ctx.open_buy_order_count() as i64), + ); + day_factors.insert( + "open_sell_order_count".into(), + Dynamic::from(ctx.open_sell_order_count() as i64), + ); + day_factors.insert( + "open_buy_qty".into(), + Dynamic::from(ctx.open_buy_quantity() as i64), + ); + day_factors.insert( + "open_sell_qty".into(), + Dynamic::from(ctx.open_sell_quantity() as i64), + ); + day_factors.insert( + "latest_open_order_id".into(), + Dynamic::from(ctx.latest_open_order_id() as i64), + ); + day_factors.insert( + "latest_open_order_status".into(), + Dynamic::from(ctx.latest_open_order_status().to_string()), + ); + day_factors.insert( + "latest_open_order_unfilled_qty".into(), + Dynamic::from(ctx.latest_open_order_unfilled_quantity() as i64), + ); + day_factors.insert( + "has_dynamic_universe".into(), + Dynamic::from(ctx.has_dynamic_universe()), + ); + day_factors.insert( + "dynamic_universe_count".into(), + Dynamic::from(ctx.dynamic_universe_count() as i64), + ); + day_factors.insert( + "has_subscriptions".into(), + Dynamic::from(ctx.has_subscriptions()), + ); + day_factors.insert( + "subscription_count".into(), + Dynamic::from(ctx.subscription_count() as i64), + ); + day_factors.insert( + "subscription_guard_required".into(), + Dynamic::from(self.config.subscription_guard_required), + ); + day_factors.insert( + "has_process_events".into(), + Dynamic::from(ctx.has_process_events()), + ); + day_factors.insert( + "process_event_count".into(), + Dynamic::from(ctx.process_event_count() as i64), + ); + day_factors.insert( + "current_process_kind".into(), + Dynamic::from(ctx.current_process_event_kind().to_string()), + ); + day_factors.insert( + "current_process_order_id".into(), + Dynamic::from(ctx.current_process_event_order_id() as i64), + ); + day_factors.insert( + "current_process_symbol".into(), + Dynamic::from(ctx.current_process_event_symbol().to_string()), + ); + day_factors.insert( + "current_process_side".into(), + Dynamic::from(ctx.current_process_event_side().to_string()), + ); + day_factors.insert( + "current_process_detail".into(), + Dynamic::from(ctx.current_process_event_detail().to_string()), + ); + day_factors.insert( + "latest_process_kind".into(), + Dynamic::from(ctx.latest_process_event_kind().to_string()), + ); + day_factors.insert( + "latest_process_order_id".into(), + Dynamic::from(ctx.latest_process_event_order_id() as i64), + ); + day_factors.insert( + "latest_process_symbol".into(), + Dynamic::from(ctx.latest_process_event_symbol().to_string()), + ); + day_factors.insert( + "latest_process_side".into(), + Dynamic::from(ctx.latest_process_event_side().to_string()), + ); + day_factors.insert( + "latest_process_detail".into(), + Dynamic::from(ctx.latest_process_event_detail().to_string()), + ); + if let Some(counts) = process_event_counts { + day_factors.insert("process_event_counts".into(), Dynamic::from(counts)); + } + scope.push("day_factors", day_factors); } - scope.push("process_event_counts", process_event_counts.clone()); - let mut day_factors = Map::new(); - day_factors.insert("signal_open".into(), Dynamic::from(day.signal_open)); - day_factors.insert("signal_close".into(), Dynamic::from(day.signal_close)); - day_factors.insert("benchmark_open".into(), Dynamic::from(day.benchmark_open)); - day_factors.insert("benchmark_close".into(), Dynamic::from(day.benchmark_close)); - day_factors.insert("signal_ma5".into(), Dynamic::from(day.signal_ma5)); - day_factors.insert("signal_ma10".into(), Dynamic::from(day.signal_ma10)); - day_factors.insert("signal_ma20".into(), Dynamic::from(day.signal_ma20)); - day_factors.insert("signal_ma30".into(), Dynamic::from(day.signal_ma30)); - day_factors.insert("benchmark_ma5".into(), Dynamic::from(day.benchmark_ma5)); - day_factors.insert("benchmark_ma10".into(), Dynamic::from(day.benchmark_ma10)); - day_factors.insert("benchmark_ma20".into(), Dynamic::from(day.benchmark_ma20)); - day_factors.insert("benchmark_ma30".into(), Dynamic::from(day.benchmark_ma30)); - day_factors.insert( - "benchmark_ma_short".into(), - Dynamic::from(day.benchmark_ma_short), - ); - day_factors.insert( - "benchmark_ma_long".into(), - Dynamic::from(day.benchmark_ma_long), - ); - day_factors.insert("cash".into(), Dynamic::from(day.cash)); - day_factors.insert("available_cash".into(), Dynamic::from(day.available_cash)); - day_factors.insert("frozen_cash".into(), Dynamic::from(day.frozen_cash)); - day_factors.insert("market_value".into(), Dynamic::from(day.market_value)); - day_factors.insert("total_equity".into(), Dynamic::from(day.total_equity)); - day_factors.insert("total_value".into(), Dynamic::from(day.total_value)); - day_factors.insert("portfolio_value".into(), Dynamic::from(day.portfolio_value)); - day_factors.insert("starting_cash".into(), Dynamic::from(day.starting_cash)); - day_factors.insert("unit_net_value".into(), Dynamic::from(day.unit_net_value)); - day_factors.insert( - "static_unit_net_value".into(), - Dynamic::from(day.static_unit_net_value), - ); - day_factors.insert("daily_pnl".into(), Dynamic::from(day.daily_pnl)); - day_factors.insert("daily_returns".into(), Dynamic::from(day.daily_returns)); - day_factors.insert("total_returns".into(), Dynamic::from(day.total_returns)); - day_factors.insert( - "transaction_cost".into(), - Dynamic::from(day.transaction_cost), - ); - day_factors.insert("trading_pnl".into(), Dynamic::from(day.trading_pnl)); - day_factors.insert("position_pnl".into(), Dynamic::from(day.position_pnl)); - day_factors.insert( - "cash_liabilities".into(), - Dynamic::from(day.cash_liabilities), - ); - day_factors.insert( - "management_fee_rate".into(), - Dynamic::from(day.management_fee_rate), - ); - day_factors.insert("management_fees".into(), Dynamic::from(day.management_fees)); - day_factors.insert( - "current_exposure".into(), - Dynamic::from(day.current_exposure), - ); - day_factors.insert("position_count".into(), Dynamic::from(day.position_count)); - day_factors.insert("max_positions".into(), Dynamic::from(day.max_positions)); - day_factors.insert("refresh_rate".into(), Dynamic::from(day.refresh_rate)); - day_factors.insert("year".into(), Dynamic::from(day.year)); - day_factors.insert("month".into(), Dynamic::from(day.month)); - day_factors.insert("quarter".into(), Dynamic::from(day.quarter)); - day_factors.insert("day_of_month".into(), Dynamic::from(day.day_of_month)); - day_factors.insert("day_of_year".into(), Dynamic::from(day.day_of_year)); - day_factors.insert("week_of_year".into(), Dynamic::from(day.week_of_year)); - day_factors.insert("weekday".into(), Dynamic::from(day.weekday)); - day_factors.insert("is_month_start".into(), Dynamic::from(day.is_month_start)); - day_factors.insert("is_month_end".into(), Dynamic::from(day.is_month_end)); - day_factors.insert( - "has_open_orders".into(), - Dynamic::from(ctx.has_open_orders()), - ); - day_factors.insert( - "open_order_count".into(), - Dynamic::from(ctx.open_order_count() as i64), - ); - day_factors.insert( - "open_buy_order_count".into(), - Dynamic::from(ctx.open_buy_order_count() as i64), - ); - day_factors.insert( - "open_sell_order_count".into(), - Dynamic::from(ctx.open_sell_order_count() as i64), - ); - day_factors.insert( - "open_buy_qty".into(), - Dynamic::from(ctx.open_buy_quantity() as i64), - ); - day_factors.insert( - "open_sell_qty".into(), - Dynamic::from(ctx.open_sell_quantity() as i64), - ); - day_factors.insert( - "latest_open_order_id".into(), - Dynamic::from(ctx.latest_open_order_id() as i64), - ); - day_factors.insert( - "latest_open_order_status".into(), - Dynamic::from(ctx.latest_open_order_status().to_string()), - ); - day_factors.insert( - "latest_open_order_unfilled_qty".into(), - Dynamic::from(ctx.latest_open_order_unfilled_quantity() as i64), - ); - day_factors.insert( - "has_dynamic_universe".into(), - Dynamic::from(ctx.has_dynamic_universe()), - ); - day_factors.insert( - "dynamic_universe_count".into(), - Dynamic::from(ctx.dynamic_universe_count() as i64), - ); - day_factors.insert( - "has_subscriptions".into(), - Dynamic::from(ctx.has_subscriptions()), - ); - day_factors.insert( - "subscription_count".into(), - Dynamic::from(ctx.subscription_count() as i64), - ); - day_factors.insert( - "subscription_guard_required".into(), - Dynamic::from(self.config.subscription_guard_required), - ); - day_factors.insert( - "has_process_events".into(), - Dynamic::from(ctx.has_process_events()), - ); - day_factors.insert( - "process_event_count".into(), - Dynamic::from(ctx.process_event_count() as i64), - ); - day_factors.insert( - "current_process_kind".into(), - Dynamic::from(ctx.current_process_event_kind().to_string()), - ); - day_factors.insert( - "current_process_order_id".into(), - Dynamic::from(ctx.current_process_event_order_id() as i64), - ); - day_factors.insert( - "current_process_symbol".into(), - Dynamic::from(ctx.current_process_event_symbol().to_string()), - ); - day_factors.insert( - "current_process_side".into(), - Dynamic::from(ctx.current_process_event_side().to_string()), - ); - day_factors.insert( - "current_process_detail".into(), - Dynamic::from(ctx.current_process_event_detail().to_string()), - ); - day_factors.insert( - "latest_process_kind".into(), - Dynamic::from(ctx.latest_process_event_kind().to_string()), - ); - day_factors.insert( - "latest_process_order_id".into(), - Dynamic::from(ctx.latest_process_event_order_id() as i64), - ); - day_factors.insert( - "latest_process_symbol".into(), - Dynamic::from(ctx.latest_process_event_symbol().to_string()), - ); - day_factors.insert( - "latest_process_side".into(), - Dynamic::from(ctx.latest_process_event_side().to_string()), - ); - day_factors.insert( - "latest_process_detail".into(), - Dynamic::from(ctx.latest_process_event_detail().to_string()), - ); - day_factors.insert( - "process_event_counts".into(), - Dynamic::from(process_event_counts), - ); - scope.push("day_factors", day_factors); if let Some(stock) = stock { let at_upper_limit = Self::price_is_at_or_above_upper_limit( stock.last, @@ -2101,162 +2162,139 @@ impl PlatformExprStrategy { scope.push("volume_ma10", stock.stock_volume_ma10); scope.push("volume_ma20", stock.stock_volume_ma20); scope.push("volume_ma60", stock.stock_volume_ma60); - let mut factors = Map::new(); - factors.insert("symbol".into(), Dynamic::from(stock.symbol.clone())); - factors.insert("market_cap".into(), Dynamic::from(stock.market_cap)); - factors.insert("free_float_cap".into(), Dynamic::from(stock.free_float_cap)); - factors.insert("pe_ttm".into(), Dynamic::from(stock.pe_ttm)); - factors.insert("volume".into(), Dynamic::from(stock.volume)); - factors.insert("tick_volume".into(), Dynamic::from(stock.tick_volume)); - factors.insert("bid1_volume".into(), Dynamic::from(stock.bid1_volume)); - factors.insert("ask1_volume".into(), Dynamic::from(stock.ask1_volume)); - factors.insert("turnover".into(), Dynamic::from(stock.turnover_ratio)); - factors.insert("turnover_ratio".into(), Dynamic::from(stock.turnover_ratio)); - factors.insert( - "effective_turnover_ratio".into(), - Dynamic::from(stock.effective_turnover_ratio), - ); - factors.insert("open".into(), Dynamic::from(stock.open)); - factors.insert("high".into(), Dynamic::from(stock.high)); - factors.insert("low".into(), Dynamic::from(stock.low)); - factors.insert("close".into(), Dynamic::from(stock.close)); - factors.insert("last_price".into(), Dynamic::from(stock.last)); - factors.insert("prev_close".into(), Dynamic::from(stock.prev_close)); - factors.insert("amount".into(), Dynamic::from(stock.amount)); - factors.insert("upper_limit".into(), Dynamic::from(stock.upper_limit)); - factors.insert("lower_limit".into(), Dynamic::from(stock.lower_limit)); - factors.insert("price_tick".into(), Dynamic::from(stock.price_tick)); - factors.insert("round_lot".into(), Dynamic::from(stock.round_lot)); - factors.insert( - "minimum_order_quantity".into(), - Dynamic::from(stock.minimum_order_quantity), - ); - factors.insert( - "order_step_size".into(), - Dynamic::from(stock.order_step_size), - ); - factors.insert("paused".into(), Dynamic::from(stock.paused)); - factors.insert("is_st".into(), Dynamic::from(stock.is_st)); - factors.insert("is_kcb".into(), Dynamic::from(stock.is_kcb)); - factors.insert("is_one_yuan".into(), Dynamic::from(stock.is_one_yuan)); - factors.insert("is_new_listing".into(), Dynamic::from(stock.is_new_listing)); - factors.insert("allow_buy".into(), Dynamic::from(stock.allow_buy)); - factors.insert("allow_sell".into(), Dynamic::from(stock.allow_sell)); - factors.insert( - "touched_upper_limit".into(), - Dynamic::from(stock.touched_upper_limit), - ); - factors.insert( - "touched_lower_limit".into(), - Dynamic::from(stock.touched_lower_limit), - ); - factors.insert( - "hit_upper_limit".into(), - Dynamic::from(stock.touched_upper_limit), - ); - factors.insert( - "hit_lower_limit".into(), - Dynamic::from(stock.touched_lower_limit), - ); - factors.insert("listed_days".into(), Dynamic::from(stock.listed_days)); - factors.insert("at_upper_limit".into(), Dynamic::from(at_upper_limit)); - factors.insert("at_lower_limit".into(), Dynamic::from(at_lower_limit)); - factors.insert( - "symbol_open_order_count".into(), - Dynamic::from(ctx.symbol_open_order_count(&stock.symbol) as i64), - ); - factors.insert( - "symbol_open_buy_qty".into(), - Dynamic::from(ctx.symbol_open_buy_quantity(&stock.symbol) as i64), - ); - factors.insert( - "symbol_open_sell_qty".into(), - Dynamic::from(ctx.symbol_open_sell_quantity(&stock.symbol) as i64), - ); - factors.insert( - "latest_symbol_open_order_id".into(), - Dynamic::from(ctx.latest_symbol_open_order_id(&stock.symbol) as i64), - ); - factors.insert( - "latest_symbol_open_order_status".into(), - Dynamic::from( - ctx.latest_symbol_open_order_status(&stock.symbol) - .to_string(), - ), - ); - factors.insert( - "latest_symbol_open_order_unfilled_qty".into(), - Dynamic::from(ctx.latest_symbol_open_order_unfilled_quantity(&stock.symbol) as i64), - ); - factors.insert( - "in_dynamic_universe".into(), - Dynamic::from(ctx.dynamic_universe_contains(&stock.symbol)), - ); - factors.insert( - "is_subscribed".into(), - Dynamic::from(ctx.is_subscribed(&stock.symbol)), - ); - factors.insert("stock_ma5".into(), Dynamic::from(stock.stock_ma5)); - factors.insert("stock_ma10".into(), Dynamic::from(stock.stock_ma10)); - factors.insert("stock_ma20".into(), Dynamic::from(stock.stock_ma20)); - factors.insert("stock_ma30".into(), Dynamic::from(stock.stock_ma30)); - factors.insert("ma5".into(), Dynamic::from(stock.stock_ma5)); - factors.insert("ma10".into(), Dynamic::from(stock.stock_ma10)); - factors.insert("ma20".into(), Dynamic::from(stock.stock_ma20)); - factors.insert("ma30".into(), Dynamic::from(stock.stock_ma30)); - factors.insert( - "stock_volume_ma5".into(), - Dynamic::from(stock.stock_volume_ma5), - ); - factors.insert( - "stock_volume_ma10".into(), - Dynamic::from(stock.stock_volume_ma10), - ); - factors.insert( - "stock_volume_ma20".into(), - Dynamic::from(stock.stock_volume_ma20), - ); - factors.insert( - "stock_volume_ma60".into(), - Dynamic::from(stock.stock_volume_ma60), - ); - factors.insert("volume_ma5".into(), Dynamic::from(stock.stock_volume_ma5)); - factors.insert("volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10)); - factors.insert("volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20)); - factors.insert("volume_ma60".into(), Dynamic::from(stock.stock_volume_ma60)); - for (key, value) in &stock.extra_factors { - factors.insert(key.clone().into(), Dynamic::from(*value)); - } - for (key, value) in &stock.extra_text_factors { - factors.insert(key.clone().into(), Dynamic::from(value.clone())); - } - scope.push("factors", factors); - let reserved_names = Self::reserved_scope_names(); - for (key, value) in &stock.extra_factors { - if Self::is_expression_identifier(key) && !reserved_names.contains(key.as_str()) { - scope.push_dynamic(key.clone(), Dynamic::from(*value)); + if include_factors_map { + let mut factors = Map::new(); + factors.insert("symbol".into(), Dynamic::from(stock.symbol.clone())); + factors.insert("market_cap".into(), Dynamic::from(stock.market_cap)); + factors.insert("free_float_cap".into(), Dynamic::from(stock.free_float_cap)); + factors.insert("pe_ttm".into(), Dynamic::from(stock.pe_ttm)); + factors.insert("volume".into(), Dynamic::from(stock.volume)); + factors.insert("tick_volume".into(), Dynamic::from(stock.tick_volume)); + factors.insert("bid1_volume".into(), Dynamic::from(stock.bid1_volume)); + factors.insert("ask1_volume".into(), Dynamic::from(stock.ask1_volume)); + factors.insert("turnover".into(), Dynamic::from(stock.turnover_ratio)); + factors.insert("turnover_ratio".into(), Dynamic::from(stock.turnover_ratio)); + factors.insert( + "effective_turnover_ratio".into(), + Dynamic::from(stock.effective_turnover_ratio), + ); + factors.insert("open".into(), Dynamic::from(stock.open)); + factors.insert("high".into(), Dynamic::from(stock.high)); + factors.insert("low".into(), Dynamic::from(stock.low)); + factors.insert("close".into(), Dynamic::from(stock.close)); + factors.insert("last_price".into(), Dynamic::from(stock.last)); + factors.insert("prev_close".into(), Dynamic::from(stock.prev_close)); + factors.insert("amount".into(), Dynamic::from(stock.amount)); + factors.insert("upper_limit".into(), Dynamic::from(stock.upper_limit)); + factors.insert("lower_limit".into(), Dynamic::from(stock.lower_limit)); + factors.insert("price_tick".into(), Dynamic::from(stock.price_tick)); + factors.insert("round_lot".into(), Dynamic::from(stock.round_lot)); + factors.insert( + "minimum_order_quantity".into(), + Dynamic::from(stock.minimum_order_quantity), + ); + factors.insert( + "order_step_size".into(), + Dynamic::from(stock.order_step_size), + ); + factors.insert("paused".into(), Dynamic::from(stock.paused)); + factors.insert("is_st".into(), Dynamic::from(stock.is_st)); + factors.insert("is_kcb".into(), Dynamic::from(stock.is_kcb)); + factors.insert("is_one_yuan".into(), Dynamic::from(stock.is_one_yuan)); + factors.insert("is_new_listing".into(), Dynamic::from(stock.is_new_listing)); + factors.insert("allow_buy".into(), Dynamic::from(stock.allow_buy)); + factors.insert("allow_sell".into(), Dynamic::from(stock.allow_sell)); + factors.insert( + "touched_upper_limit".into(), + Dynamic::from(stock.touched_upper_limit), + ); + factors.insert( + "touched_lower_limit".into(), + Dynamic::from(stock.touched_lower_limit), + ); + factors.insert( + "hit_upper_limit".into(), + Dynamic::from(stock.touched_upper_limit), + ); + factors.insert( + "hit_lower_limit".into(), + Dynamic::from(stock.touched_lower_limit), + ); + factors.insert("listed_days".into(), Dynamic::from(stock.listed_days)); + factors.insert("at_upper_limit".into(), Dynamic::from(at_upper_limit)); + factors.insert("at_lower_limit".into(), Dynamic::from(at_lower_limit)); + factors.insert( + "symbol_open_order_count".into(), + Dynamic::from(ctx.symbol_open_order_count(&stock.symbol) as i64), + ); + factors.insert( + "symbol_open_buy_qty".into(), + Dynamic::from(ctx.symbol_open_buy_quantity(&stock.symbol) as i64), + ); + factors.insert( + "symbol_open_sell_qty".into(), + Dynamic::from(ctx.symbol_open_sell_quantity(&stock.symbol) as i64), + ); + factors.insert( + "latest_symbol_open_order_id".into(), + Dynamic::from(ctx.latest_symbol_open_order_id(&stock.symbol) as i64), + ); + factors.insert( + "latest_symbol_open_order_status".into(), + Dynamic::from( + ctx.latest_symbol_open_order_status(&stock.symbol) + .to_string(), + ), + ); + factors.insert( + "latest_symbol_open_order_unfilled_qty".into(), + Dynamic::from( + ctx.latest_symbol_open_order_unfilled_quantity(&stock.symbol) as i64, + ), + ); + factors.insert( + "in_dynamic_universe".into(), + Dynamic::from(ctx.dynamic_universe_contains(&stock.symbol)), + ); + factors.insert( + "is_subscribed".into(), + Dynamic::from(ctx.is_subscribed(&stock.symbol)), + ); + factors.insert("stock_ma5".into(), Dynamic::from(stock.stock_ma5)); + factors.insert("stock_ma10".into(), Dynamic::from(stock.stock_ma10)); + factors.insert("stock_ma20".into(), Dynamic::from(stock.stock_ma20)); + factors.insert("stock_ma30".into(), Dynamic::from(stock.stock_ma30)); + factors.insert("ma5".into(), Dynamic::from(stock.stock_ma5)); + factors.insert("ma10".into(), Dynamic::from(stock.stock_ma10)); + factors.insert("ma20".into(), Dynamic::from(stock.stock_ma20)); + factors.insert("ma30".into(), Dynamic::from(stock.stock_ma30)); + factors.insert( + "stock_volume_ma5".into(), + Dynamic::from(stock.stock_volume_ma5), + ); + factors.insert( + "stock_volume_ma10".into(), + Dynamic::from(stock.stock_volume_ma10), + ); + factors.insert( + "stock_volume_ma20".into(), + Dynamic::from(stock.stock_volume_ma20), + ); + factors.insert( + "stock_volume_ma60".into(), + Dynamic::from(stock.stock_volume_ma60), + ); + factors.insert("volume_ma5".into(), Dynamic::from(stock.stock_volume_ma5)); + factors.insert("volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10)); + factors.insert("volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20)); + factors.insert("volume_ma60".into(), Dynamic::from(stock.stock_volume_ma60)); + for (key, value) in &stock.extra_factors { + factors.insert(key.clone().into(), Dynamic::from(*value)); } - } - for (key, value) in &stock.extra_text_factors { - if Self::is_expression_identifier(key) && !reserved_names.contains(key.as_str()) { - scope.push_dynamic(key.clone(), Dynamic::from(value.clone())); - } - } - for key in &day.available_factor_names { - if Self::is_expression_identifier(key) - && !reserved_names.contains(key.as_str()) - && !stock.extra_factors.contains_key(key) - { - scope.push_dynamic(key.clone(), Dynamic::from(0.0)); - } - } - for key in &day.available_text_factor_names { - if Self::is_expression_identifier(key) - && !reserved_names.contains(key.as_str()) - && !stock.extra_text_factors.contains_key(key) - { - scope.push_dynamic(key.clone(), Dynamic::from(String::new())); + for (key, value) in &stock.extra_text_factors { + factors.insert(key.clone().into(), Dynamic::from(value.clone())); } + scope.push("factors", factors); } } if let Some(position) = position { @@ -2319,9 +2357,29 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, ) -> Result { - let mut scope = self.eval_scope(ctx, day, stock, position); let normalized_expr = Self::normalize_expr(expr); - let expanded_expr = self.expand_runtime_helpers(ctx, day, stock, &normalized_expr)?; + let prelude = Self::normalize_prelude_for_eval(&self.config.prelude); + let normalized_identifiers = Self::extract_identifier_candidates(&normalized_expr); + let prelude_identifiers = Self::extract_identifier_candidates(&prelude); + let include_day_factors = normalized_identifiers.contains("day_factors") + || normalized_identifiers.contains("day_factor") + || prelude_identifiers.contains("day_factors"); + let include_factors_map = + normalized_identifiers.contains("factors") || normalized_identifiers.contains("factor"); + let include_process_event_counts = include_day_factors + || normalized_identifiers.contains("process_event_counts") + || prelude_identifiers.contains("process_event_counts"); + let mut scope = self.eval_scope( + ctx, + day, + stock, + position, + include_day_factors, + include_factors_map, + include_process_event_counts, + ); + let expanded_expr = + self.expand_runtime_helpers(ctx, day, stock, &normalized_expr, &mut scope)?; let prelude_declared_identifiers = Self::declared_prelude_identifiers(&self.config.prelude); if let Some(item) = stock { let reserved_names = Self::reserved_scope_names(); @@ -2341,29 +2399,10 @@ impl PlatformExprStrategy { } } } - let factor_alias_prelude = stock - .map(|item| { - let reserved_names = Self::reserved_scope_names(); - item.extra_factors - .keys() - .chain(item.extra_text_factors.keys()) - .filter(|key| { - Self::is_expression_identifier(key) - && !reserved_names.contains(key.as_str()) - }) - .map(|key| format!("let {key} = factors[\"{key}\"];")) - .collect::>() - .join("\n") - }) - .filter(|value| !value.trim().is_empty()); let mut script_parts = Vec::new(); - let prelude = Self::normalize_prelude_for_eval(&self.config.prelude); if !prelude.trim().is_empty() { script_parts.push(prelude); } - if let Some(alias_prelude) = factor_alias_prelude { - script_parts.push(alias_prelude); - } script_parts.push(expanded_expr); let script = script_parts.join("\n"); self.eval_with_cache(&mut scope, &script) @@ -2533,6 +2572,7 @@ impl PlatformExprStrategy { day: &DayExpressionState, stock: Option<&StockExpressionState>, expr: &str, + scope: &mut Scope<'_>, ) -> Result { let mut output = String::with_capacity(expr.len()); let mut cursor = 0usize; @@ -2616,7 +2656,7 @@ impl PlatformExprStrategy { ))); }; let inner = &expr[cursor + 1..close_idx]; - let replacement = self.resolve_runtime_helper(ctx, day, stock, ident, inner)?; + let replacement = self.resolve_runtime_helper(ctx, day, stock, ident, inner, scope)?; output.push_str(&replacement); cursor = close_idx + 1; let _ = whitespace_start; @@ -2631,6 +2671,7 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, helper: &str, args_src: &str, + scope: &mut Scope<'_>, ) -> Result { let args = Self::split_top_level_args(args_src); match helper { @@ -2655,7 +2696,12 @@ impl PlatformExprStrategy { let field = Self::parse_string_or_identifier(&args[0])?; let lookback = Self::parse_positive_usize(&args[1])?; let value = self.resolve_rolling_mean(ctx, day, stock, &field, lookback)?; - Ok(format!("{value:.12}")) + Ok(Self::push_runtime_helper_value( + scope, + helper, + &args, + Dynamic::from(value), + )) } "rolling_mean_current" => { if args.len() != 2 { @@ -2666,7 +2712,12 @@ impl PlatformExprStrategy { let field = Self::parse_string_or_identifier(&args[0])?; let lookback = Self::parse_positive_usize(&args[1])?; let value = self.resolve_current_rolling_mean(ctx, day, stock, &field, lookback)?; - Ok(format!("{value:.12}")) + Ok(Self::push_runtime_helper_value( + scope, + helper, + &args, + Dynamic::from(value), + )) } "vma" => { if args.len() != 1 { @@ -2676,7 +2727,12 @@ impl PlatformExprStrategy { } let lookback = Self::parse_positive_usize(&args[0])?; let value = self.resolve_rolling_mean(ctx, day, stock, "volume", lookback)?; - Ok(format!("{value:.12}")) + Ok(Self::push_runtime_helper_value( + scope, + helper, + &args, + Dynamic::from(value), + )) } "rolling_sum" | "rolling_min" | "rolling_max" | "rolling_stddev" | "stddev" | "rolling_zscore" => { @@ -3147,6 +3203,37 @@ impl PlatformExprStrategy { } } + fn push_runtime_helper_value( + scope: &mut Scope<'_>, + helper: &str, + args: &[String], + value: Dynamic, + ) -> String { + let name = Self::runtime_helper_scope_name(helper, args); + scope.push_dynamic(name.clone(), value); + name + } + + fn runtime_helper_scope_name(helper: &str, args: &[String]) -> String { + let signature = format!("{helper}({})", args.join(",")); + let mut hash = 14_695_981_039_346_656_037u64; + for byte in signature.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(1_099_511_628_211); + } + let mut name = String::from("__fidc_helper_"); + for ch in helper.chars() { + if ch == '_' || ch.is_ascii_alphanumeric() { + name.push(ch); + } else { + name.push('_'); + } + } + name.push('_'); + name.push_str(&format!("{hash:x}")); + name + } + fn price_bar_field(row: &crate::data::PriceBar, field: &str) -> f64 { match field.trim().to_ascii_lowercase().as_str() { "open" => row.open, @@ -5622,18 +5709,38 @@ impl Strategy for PlatformExprStrategy { if self.config.daily_top_up_enabled && self.config.rotation_enabled && trading_ratio > 0.0 - && Self::projected_position_count_excluding( - &projected, - &unresolved_stop_loss_symbols, - ) < selection_limit + && { + let excluded_symbols = Self::pending_exit_exclusion_symbols( + &unresolved_stop_loss_symbols, + &exit_symbols, + &delayed_sold_symbols, + ); + Self::projected_position_count_excluding(&projected, &excluded_symbols) + < selection_limit + } { - let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; - let available_buy_cash = fixed_buy_cash.min(aiquant_available_cash); - if available_buy_cash >= fixed_buy_cash * 0.5 { + let target_budget = aiquant_total_value * trading_ratio; + let excluded_symbols = Self::pending_exit_exclusion_symbols( + &unresolved_stop_loss_symbols, + &exit_symbols, + &delayed_sold_symbols, + ); + let slot_buy_cash = self.remaining_buy_cash_per_slot( + ctx, + &projected, + execution_date, + target_budget, + selection_limit, + &excluded_symbols, + ); + let available_buy_cash = slot_buy_cash.min(aiquant_available_cash); + if slot_buy_cash > 0.0 && available_buy_cash >= slot_buy_cash * 0.5 { for symbol in &stock_list { if symbol == &position.symbol || projected.positions().contains_key(symbol) || same_day_sold_symbols.contains(symbol) + || exit_symbols.contains(symbol) + || delayed_sold_symbols.contains(symbol) || intraday_attempted_buys.contains(symbol) { continue; @@ -5717,6 +5824,7 @@ impl Strategy for PlatformExprStrategy { aiquant_available_cash = projected.cash(); } let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; + let target_budget = aiquant_total_value * trading_ratio; for symbol in stock_list.iter().take(selection_limit) { let decision_stock = self.stock_state_with_factor_date( ctx, @@ -5790,18 +5898,20 @@ impl Strategy for PlatformExprStrategy { { continue; } - let slots_remaining = selection_limit - .saturating_sub(Self::projected_position_count_excluding( - &projected, + let slot_buy_cash = self.remaining_buy_cash_per_slot( + ctx, + &projected, + execution_date, + target_budget, + selection_limit, + &Self::pending_exit_exclusion_symbols( &unresolved_stop_loss_symbols, - )) - .max(1); - let cash_cap = if self.config.aiquant_transaction_cost { - aiquant_available_cash - } else { - aiquant_available_cash / slots_remaining as f64 - }; - let buy_cash = target_cash.min(cash_cap); + &exit_symbols, + &delayed_sold_symbols, + ), + ); + let target_cash = slot_buy_cash * stock_scale; + let buy_cash = target_cash.min(aiquant_available_cash); if buy_cash <= 0.0 { break; } @@ -8857,6 +8967,138 @@ mod tests { ); } + #[test] + fn platform_daily_top_up_allocates_remaining_budget_over_empty_slots() { + let prev_date = d(2025, 2, 2); + let date = d(2025, 2, 3); + let symbols = ["000001.SZ", "000002.SZ"]; + let data = DataSet::from_components( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-02-03 14:59:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: 10.0 + index as f64, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + symbols + .iter() + .map(|symbol| CandidateEligibility { + date, + symbol: (*symbol).to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }) + .collect(), + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(20_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 1_000, 10.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 2, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 99; + cfg.max_positions = 2; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "2".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.daily_top_up_enabled = true; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 2; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!(decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Value { + symbol, + value, + reason, + } if symbol == "000002.SZ" + && reason == "daily_top_up_buy" + && (*value - 20_000.0).abs() < 1e-6 + ))); + } + #[test] fn platform_refresh_rate_uses_stateful_aiquant_day_counter() { let dates = [d(2025, 2, 5), d(2025, 2, 6), d(2025, 2, 7)]; @@ -9875,6 +10117,157 @@ mod tests { ); } + #[test] + fn platform_periodic_rebalance_allocates_remaining_budget_over_empty_slots() { + let prev_date = d(2025, 5, 13); + let date = d(2025, 5, 14); + let symbols = ["000001.SZ", "000002.SZ"]; + let data = DataSet::from_components_with_actions_and_quotes( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-05-14 14:59:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: 10.0 + index as f64, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + symbols + .iter() + .map(|symbol| CandidateEligibility { + date, + symbol: (*symbol).to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }) + .collect(), + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + Vec::new(), + symbols + .iter() + .map(|symbol| IntradayExecutionQuote { + date, + symbol: (*symbol).to_string(), + timestamp: date.and_hms_opt(14, 59, 0).expect("valid timestamp"), + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 100_000.0, + trading_phase: Some("continuous".to_string()), + }) + .collect(), + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(20_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 1_000, 10.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 20, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 20; + cfg.max_positions = 2; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "2".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.take_profit_expr.clear(); + cfg.stop_loss_expr.clear(); + cfg.aiquant_transaction_cost = true; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 20; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!(decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Value { + symbol, + value, + reason, + } if symbol == "000002.SZ" + && reason == "periodic_rebalance_buy" + && (*value - 20_000.0).abs() < 1e-6 + ))); + } + #[test] fn platform_periodic_rebalance_tops_up_underweight_selected_holding() { let prev_date = d(2025, 5, 13); @@ -11968,4 +12361,141 @@ mod tests { "second run should not introduce new misses for same scripts" ); } + + #[test] + fn ast_cache_reuses_rolling_helper_scripts_across_dates() { + let dates = [d(2025, 2, 3), d(2025, 2, 4)]; + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Ping An Bank".to_string(), + board: "SZSE".to_string(), + round_lot: 100, + listed_at: Some(d(2010, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + dates + .iter() + .enumerate() + .map(|(index, date)| DailyMarketSnapshot { + date: *date, + symbol: "000001.SZ".to_string(), + timestamp: Some("10:18:00".to_string()), + day_open: 10.0 + index as f64, + open: 10.0 + index as f64, + high: 10.2 + index as f64, + low: 9.9 + index as f64, + close: 10.1 + index as f64, + last_price: 10.05 + index as f64, + bid1: 10.04 + index as f64, + ask1: 10.05 + index as f64, + prev_close: 9.95 + index as f64, + volume: 1_000_000 + index as u64 * 100_000, + tick_volume: 5_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.94 + index as f64, + lower_limit: 8.96 + index as f64, + price_tick: 0.01, + }) + .collect(), + dates + .iter() + .map(|date| DailyFactorSnapshot { + date: *date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(22.0), + effective_turnover_ratio: Some(18.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + dates + .iter() + .map(|date| CandidateEligibility { + date: *date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }) + .collect(), + dates + .iter() + .map(|date| BenchmarkSnapshot { + date: *date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }) + .collect(), + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::Value, + symbol: "000001.SZ".to_string(), + amount_expr: "cash * 0.1".to_string(), + limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, + when_expr: Some( + "rolling_mean_current(\"close\", 1) > 0 && rolling_mean_current(\"volume\", 1) > 0" + .to_string(), + ), + reason: "ast_cache_rolling_helper_reuse".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let mut misses_after_first = 0; + for (index, date) in dates.iter().enumerate() { + let ctx = StrategyContext { + execution_date: *date, + decision_date: *date, + decision_index: index, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let _ = strategy.on_day(&ctx).expect("platform decision"); + if index == 0 { + misses_after_first = strategy.ast_cache_misses(); + } + } + + assert!( + strategy.ast_cache_hits() > 0, + "second date should reuse helper-expanded scripts" + ); + assert_eq!( + strategy.ast_cache_misses(), + misses_after_first, + "rolling helper values must not change cached script identity across dates" + ); + } }