修正微盘买入预算与表达式性能

This commit is contained in:
boris
2026-05-28 10:39:43 +08:00
parent 200d5d1f41
commit 8c86918970
2 changed files with 937 additions and 405 deletions
+27 -25
View File
@@ -453,11 +453,13 @@ struct SymbolPriceSeries {
closes: Vec<f64>, closes: Vec<f64>,
prev_closes: Vec<f64>, prev_closes: Vec<f64>,
last_prices: Vec<f64>, last_prices: Vec<f64>,
paused: Vec<bool>,
open_prefix: Vec<f64>, open_prefix: Vec<f64>,
close_prefix: Vec<f64>, close_prefix: Vec<f64>,
prev_close_prefix: Vec<f64>, prev_close_prefix: Vec<f64>,
last_prefix: Vec<f64>, last_prefix: Vec<f64>,
unpaused_volumes: Vec<f64>,
unpaused_volume_prefix: Vec<f64>,
unpaused_count_prefix: Vec<usize>,
} }
impl SymbolPriceSeries { impl SymbolPriceSeries {
@@ -470,11 +472,20 @@ impl SymbolPriceSeries {
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>(); let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>(); let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
let last_prices = sorted.iter().map(|row| row.last_price).collect::<Vec<_>>(); let last_prices = sorted.iter().map(|row| row.last_price).collect::<Vec<_>>();
let paused = sorted.iter().map(|row| row.paused).collect::<Vec<_>>();
let open_prefix = prefix_sums(&opens); let open_prefix = prefix_sums(&opens);
let close_prefix = prefix_sums(&closes); let close_prefix = prefix_sums(&closes);
let prev_close_prefix = prefix_sums(&prev_closes); let prev_close_prefix = prefix_sums(&prev_closes);
let last_prefix = prefix_sums(&last_prices); 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 { Self {
snapshots: sorted, snapshots: sorted,
@@ -483,11 +494,13 @@ impl SymbolPriceSeries {
closes, closes,
prev_closes, prev_closes,
last_prices, last_prices,
paused,
open_prefix, open_prefix,
close_prefix, close_prefix,
prev_close_prefix, prev_close_prefix,
last_prefix, last_prefix,
unpaused_volumes,
unpaused_volume_prefix,
unpaused_count_prefix,
} }
} }
@@ -597,11 +610,12 @@ impl SymbolPriceSeries {
return None; return None;
} }
let end = self.end_index(date)?; let end = self.end_index(date)?;
let values = self.trailing_unpaused_volumes(end, lookback)?; let end_count = *self.unpaused_count_prefix.get(end)?;
if values.len() < lookback { if end_count < lookback {
return None; return None;
} }
let sum = values.iter().sum::<f64>(); 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) Some(sum / lookback as f64)
} }
@@ -621,22 +635,12 @@ impl SymbolPriceSeries {
if lookback == 0 || end == 0 { if lookback == 0 || end == 0 {
return None; return None;
} }
let mut values = Vec::with_capacity(lookback); let end_count = *self.unpaused_count_prefix.get(end)?;
for idx in (0..end).rev() { if end_count < lookback {
if self.paused.get(idx).copied().unwrap_or(false) { return None;
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 start_count = end_count - lookback;
Some(self.unpaused_volumes[start_count..end_count].to_vec())
} }
fn end_index(&self, date: NaiveDate) -> Option<usize> { fn end_index(&self, date: NaiveDate) -> Option<usize> {
@@ -2146,12 +2150,10 @@ impl DataSet {
self.market_moving_average(date, symbol, lookback, PriceField::Close) self.market_moving_average(date, symbol, lookback, PriceField::Close)
} }
"volume" | "stock_volume" => self "volume" | "stock_volume" => self
.factor_moving_average(date, symbol, "daily_volume", lookback) .market_series_by_symbol
.or_else(|| {
self.market_series_by_symbol
.get(symbol) .get(symbol)
.and_then(|series| series.current_volume_moving_average(date, lookback)) .and_then(|series| series.current_volume_moving_average(date, lookback))
}), .or_else(|| self.factor_moving_average(date, symbol, "daily_volume", lookback)),
"day_open" | "dayopen" => { "day_open" | "dayopen" => {
self.market_moving_average(date, symbol, lookback, PriceField::DayOpen) self.market_moving_average(date, symbol, lookback, PriceField::DayOpen)
} }
+605 -75
View File
@@ -1238,6 +1238,17 @@ impl PlatformExprStrategy {
.count() .count()
} }
fn pending_exit_exclusion_symbols(
unresolved_stop_loss_symbols: &BTreeSet<String>,
exit_symbols: &BTreeSet<String>,
delayed_sold_symbols: &BTreeSet<String>,
) -> BTreeSet<String> {
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( fn projected_position_value_at_execution_price(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -1259,6 +1270,47 @@ impl PlatformExprStrategy {
} }
} }
fn projected_position_value_excluding(
&self,
ctx: &StrategyContext<'_>,
projected: &PortfolioState,
date: NaiveDate,
excluded_symbols: &BTreeSet<String>,
) -> 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<String>,
) -> 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( fn project_order_value(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -1704,6 +1756,9 @@ impl PlatformExprStrategy {
day: &DayExpressionState, day: &DayExpressionState,
stock: Option<&StockExpressionState>, stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>, position: Option<&PositionExpressionState>,
include_day_factors: bool,
include_factors_map: bool,
include_process_event_counts: bool,
) -> Scope<'static> { ) -> Scope<'static> {
let mut scope = Scope::new(); let mut scope = Scope::new();
scope.push("signal_open", day.signal_open); scope.push("signal_open", day.signal_open);
@@ -1820,11 +1875,17 @@ impl PlatformExprStrategy {
"latest_process_detail", "latest_process_detail",
ctx.latest_process_event_detail().to_string(), ctx.latest_process_event_detail().to_string(),
); );
let mut process_event_counts = Map::new(); 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() { for (key, value) in ctx.process_event_counts() {
process_event_counts.insert(key.into(), Dynamic::from(value)); counts.insert(key.into(), Dynamic::from(value));
} }
scope.push("process_event_counts", process_event_counts.clone()); scope.push("process_event_counts", counts.clone());
Some(counts)
} else {
None
};
if include_day_factors {
let mut day_factors = Map::new(); let mut day_factors = Map::new();
day_factors.insert("signal_open".into(), Dynamic::from(day.signal_open)); 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("signal_close".into(), Dynamic::from(day.signal_close));
@@ -1997,11 +2058,11 @@ impl PlatformExprStrategy {
"latest_process_detail".into(), "latest_process_detail".into(),
Dynamic::from(ctx.latest_process_event_detail().to_string()), Dynamic::from(ctx.latest_process_event_detail().to_string()),
); );
day_factors.insert( if let Some(counts) = process_event_counts {
"process_event_counts".into(), day_factors.insert("process_event_counts".into(), Dynamic::from(counts));
Dynamic::from(process_event_counts), }
);
scope.push("day_factors", day_factors); scope.push("day_factors", day_factors);
}
if let Some(stock) = stock { if let Some(stock) = stock {
let at_upper_limit = Self::price_is_at_or_above_upper_limit( let at_upper_limit = Self::price_is_at_or_above_upper_limit(
stock.last, stock.last,
@@ -2101,6 +2162,7 @@ impl PlatformExprStrategy {
scope.push("volume_ma10", stock.stock_volume_ma10); scope.push("volume_ma10", stock.stock_volume_ma10);
scope.push("volume_ma20", stock.stock_volume_ma20); scope.push("volume_ma20", stock.stock_volume_ma20);
scope.push("volume_ma60", stock.stock_volume_ma60); scope.push("volume_ma60", stock.stock_volume_ma60);
if include_factors_map {
let mut factors = Map::new(); let mut factors = Map::new();
factors.insert("symbol".into(), Dynamic::from(stock.symbol.clone())); factors.insert("symbol".into(), Dynamic::from(stock.symbol.clone()));
factors.insert("market_cap".into(), Dynamic::from(stock.market_cap)); factors.insert("market_cap".into(), Dynamic::from(stock.market_cap));
@@ -2186,7 +2248,9 @@ impl PlatformExprStrategy {
); );
factors.insert( factors.insert(
"latest_symbol_open_order_unfilled_qty".into(), "latest_symbol_open_order_unfilled_qty".into(),
Dynamic::from(ctx.latest_symbol_open_order_unfilled_quantity(&stock.symbol) as i64), Dynamic::from(
ctx.latest_symbol_open_order_unfilled_quantity(&stock.symbol) as i64,
),
); );
factors.insert( factors.insert(
"in_dynamic_universe".into(), "in_dynamic_universe".into(),
@@ -2231,32 +2295,6 @@ impl PlatformExprStrategy {
factors.insert(key.clone().into(), Dynamic::from(value.clone())); factors.insert(key.clone().into(), Dynamic::from(value.clone()));
} }
scope.push("factors", factors); 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));
}
}
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()));
}
} }
} }
if let Some(position) = position { if let Some(position) = position {
@@ -2319,9 +2357,29 @@ impl PlatformExprStrategy {
stock: Option<&StockExpressionState>, stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>, position: Option<&PositionExpressionState>,
) -> Result<Dynamic, BacktestError> { ) -> Result<Dynamic, BacktestError> {
let mut scope = self.eval_scope(ctx, day, stock, position);
let normalized_expr = Self::normalize_expr(expr); 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); let prelude_declared_identifiers = Self::declared_prelude_identifiers(&self.config.prelude);
if let Some(item) = stock { if let Some(item) = stock {
let reserved_names = Self::reserved_scope_names(); 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::<Vec<_>>()
.join("\n")
})
.filter(|value| !value.trim().is_empty());
let mut script_parts = Vec::new(); let mut script_parts = Vec::new();
let prelude = Self::normalize_prelude_for_eval(&self.config.prelude);
if !prelude.trim().is_empty() { if !prelude.trim().is_empty() {
script_parts.push(prelude); script_parts.push(prelude);
} }
if let Some(alias_prelude) = factor_alias_prelude {
script_parts.push(alias_prelude);
}
script_parts.push(expanded_expr); script_parts.push(expanded_expr);
let script = script_parts.join("\n"); let script = script_parts.join("\n");
self.eval_with_cache(&mut scope, &script) self.eval_with_cache(&mut scope, &script)
@@ -2533,6 +2572,7 @@ impl PlatformExprStrategy {
day: &DayExpressionState, day: &DayExpressionState,
stock: Option<&StockExpressionState>, stock: Option<&StockExpressionState>,
expr: &str, expr: &str,
scope: &mut Scope<'_>,
) -> Result<String, BacktestError> { ) -> Result<String, BacktestError> {
let mut output = String::with_capacity(expr.len()); let mut output = String::with_capacity(expr.len());
let mut cursor = 0usize; let mut cursor = 0usize;
@@ -2616,7 +2656,7 @@ impl PlatformExprStrategy {
))); )));
}; };
let inner = &expr[cursor + 1..close_idx]; 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); output.push_str(&replacement);
cursor = close_idx + 1; cursor = close_idx + 1;
let _ = whitespace_start; let _ = whitespace_start;
@@ -2631,6 +2671,7 @@ impl PlatformExprStrategy {
stock: Option<&StockExpressionState>, stock: Option<&StockExpressionState>,
helper: &str, helper: &str,
args_src: &str, args_src: &str,
scope: &mut Scope<'_>,
) -> Result<String, BacktestError> { ) -> Result<String, BacktestError> {
let args = Self::split_top_level_args(args_src); let args = Self::split_top_level_args(args_src);
match helper { match helper {
@@ -2655,7 +2696,12 @@ impl PlatformExprStrategy {
let field = Self::parse_string_or_identifier(&args[0])?; let field = Self::parse_string_or_identifier(&args[0])?;
let lookback = Self::parse_positive_usize(&args[1])?; let lookback = Self::parse_positive_usize(&args[1])?;
let value = self.resolve_rolling_mean(ctx, day, stock, &field, lookback)?; 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" => { "rolling_mean_current" => {
if args.len() != 2 { if args.len() != 2 {
@@ -2666,7 +2712,12 @@ impl PlatformExprStrategy {
let field = Self::parse_string_or_identifier(&args[0])?; let field = Self::parse_string_or_identifier(&args[0])?;
let lookback = Self::parse_positive_usize(&args[1])?; let lookback = Self::parse_positive_usize(&args[1])?;
let value = self.resolve_current_rolling_mean(ctx, day, stock, &field, lookback)?; 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" => { "vma" => {
if args.len() != 1 { if args.len() != 1 {
@@ -2676,7 +2727,12 @@ impl PlatformExprStrategy {
} }
let lookback = Self::parse_positive_usize(&args[0])?; let lookback = Self::parse_positive_usize(&args[0])?;
let value = self.resolve_rolling_mean(ctx, day, stock, "volume", lookback)?; 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_sum" | "rolling_min" | "rolling_max" | "rolling_stddev" | "stddev"
| "rolling_zscore" => { | "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 { fn price_bar_field(row: &crate::data::PriceBar, field: &str) -> f64 {
match field.trim().to_ascii_lowercase().as_str() { match field.trim().to_ascii_lowercase().as_str() {
"open" => row.open, "open" => row.open,
@@ -5622,18 +5709,38 @@ impl Strategy for PlatformExprStrategy {
if self.config.daily_top_up_enabled if self.config.daily_top_up_enabled
&& self.config.rotation_enabled && self.config.rotation_enabled
&& trading_ratio > 0.0 && trading_ratio > 0.0
&& Self::projected_position_count_excluding( && {
&projected, let excluded_symbols = Self::pending_exit_exclusion_symbols(
&unresolved_stop_loss_symbols, &unresolved_stop_loss_symbols,
) < selection_limit &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 target_budget = aiquant_total_value * trading_ratio;
let available_buy_cash = fixed_buy_cash.min(aiquant_available_cash); let excluded_symbols = Self::pending_exit_exclusion_symbols(
if available_buy_cash >= fixed_buy_cash * 0.5 { &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 { for symbol in &stock_list {
if symbol == &position.symbol if symbol == &position.symbol
|| projected.positions().contains_key(symbol) || projected.positions().contains_key(symbol)
|| same_day_sold_symbols.contains(symbol) || same_day_sold_symbols.contains(symbol)
|| exit_symbols.contains(symbol)
|| delayed_sold_symbols.contains(symbol)
|| intraday_attempted_buys.contains(symbol) || intraday_attempted_buys.contains(symbol)
{ {
continue; continue;
@@ -5717,6 +5824,7 @@ impl Strategy for PlatformExprStrategy {
aiquant_available_cash = projected.cash(); aiquant_available_cash = projected.cash();
} }
let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; 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) { for symbol in stock_list.iter().take(selection_limit) {
let decision_stock = self.stock_state_with_factor_date( let decision_stock = self.stock_state_with_factor_date(
ctx, ctx,
@@ -5790,18 +5898,20 @@ impl Strategy for PlatformExprStrategy {
{ {
continue; continue;
} }
let slots_remaining = selection_limit let slot_buy_cash = self.remaining_buy_cash_per_slot(
.saturating_sub(Self::projected_position_count_excluding( ctx,
&projected, &projected,
execution_date,
target_budget,
selection_limit,
&Self::pending_exit_exclusion_symbols(
&unresolved_stop_loss_symbols, &unresolved_stop_loss_symbols,
)) &exit_symbols,
.max(1); &delayed_sold_symbols,
let cash_cap = if self.config.aiquant_transaction_cost { ),
aiquant_available_cash );
} else { let target_cash = slot_buy_cash * stock_scale;
aiquant_available_cash / slots_remaining as f64 let buy_cash = target_cash.min(aiquant_available_cash);
};
let buy_cash = target_cash.min(cash_cap);
if buy_cash <= 0.0 { if buy_cash <= 0.0 {
break; 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] #[test]
fn platform_refresh_rate_uses_stateful_aiquant_day_counter() { fn platform_refresh_rate_uses_stateful_aiquant_day_counter() {
let dates = [d(2025, 2, 5), d(2025, 2, 6), d(2025, 2, 7)]; 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] #[test]
fn platform_periodic_rebalance_tops_up_underweight_selected_holding() { fn platform_periodic_rebalance_tops_up_underweight_selected_holding() {
let prev_date = d(2025, 5, 13); let prev_date = d(2025, 5, 13);
@@ -11968,4 +12361,141 @@ mod tests {
"second run should not introduce new misses for same scripts" "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"
);
}
} }