修正微盘买入预算与表达式性能
This commit is contained in:
@@ -453,11 +453,13 @@ struct SymbolPriceSeries {
|
||||
closes: Vec<f64>,
|
||||
prev_closes: Vec<f64>,
|
||||
last_prices: Vec<f64>,
|
||||
paused: Vec<bool>,
|
||||
open_prefix: Vec<f64>,
|
||||
close_prefix: Vec<f64>,
|
||||
prev_close_prefix: Vec<f64>,
|
||||
last_prefix: Vec<f64>,
|
||||
unpaused_volumes: Vec<f64>,
|
||||
unpaused_volume_prefix: Vec<f64>,
|
||||
unpaused_count_prefix: Vec<usize>,
|
||||
}
|
||||
|
||||
impl SymbolPriceSeries {
|
||||
@@ -470,11 +472,20 @@ impl SymbolPriceSeries {
|
||||
let closes = sorted.iter().map(|row| row.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 paused = sorted.iter().map(|row| row.paused).collect::<Vec<_>>();
|
||||
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::<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)
|
||||
}
|
||||
|
||||
@@ -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<usize> {
|
||||
@@ -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
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -1238,6 +1238,17 @@ impl PlatformExprStrategy {
|
||||
.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(
|
||||
&self,
|
||||
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(
|
||||
&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,11 +1875,17 @@ impl PlatformExprStrategy {
|
||||
"latest_process_detail",
|
||||
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() {
|
||||
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();
|
||||
day_factors.insert("signal_open".into(), Dynamic::from(day.signal_open));
|
||||
day_factors.insert("signal_close".into(), Dynamic::from(day.signal_close));
|
||||
@@ -1997,11 +2058,11 @@ impl PlatformExprStrategy {
|
||||
"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),
|
||||
);
|
||||
if let Some(counts) = process_event_counts {
|
||||
day_factors.insert("process_event_counts".into(), Dynamic::from(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,6 +2162,7 @@ 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);
|
||||
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));
|
||||
@@ -2186,7 +2248,9 @@ impl PlatformExprStrategy {
|
||||
);
|
||||
factors.insert(
|
||||
"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(
|
||||
"in_dynamic_universe".into(),
|
||||
@@ -2231,32 +2295,6 @@ impl PlatformExprStrategy {
|
||||
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));
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -2319,9 +2357,29 @@ impl PlatformExprStrategy {
|
||||
stock: Option<&StockExpressionState>,
|
||||
position: Option<&PositionExpressionState>,
|
||||
) -> Result<Dynamic, BacktestError> {
|
||||
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::<Vec<_>>()
|
||||
.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<String, BacktestError> {
|
||||
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<String, BacktestError> {
|
||||
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,
|
||||
&& {
|
||||
let excluded_symbols = Self::pending_exit_exclusion_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 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(
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user