Fix decision-time rolling factor semantics

This commit is contained in:
boris
2026-04-24 01:00:28 -07:00
parent e6621c1719
commit 55e8a59866
3 changed files with 237 additions and 59 deletions

View File

@@ -451,7 +451,6 @@ struct SymbolPriceSeries {
closes: Vec<f64>, closes: Vec<f64>,
prev_closes: Vec<f64>, prev_closes: Vec<f64>,
last_prices: Vec<f64>, last_prices: Vec<f64>,
volumes: Vec<f64>,
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>,
@@ -485,7 +484,6 @@ impl SymbolPriceSeries {
closes, closes,
prev_closes, prev_closes,
last_prices, last_prices,
volumes,
open_prefix, open_prefix,
close_prefix, close_prefix,
prev_close_prefix, prev_close_prefix,
@@ -532,6 +530,14 @@ impl SymbolPriceSeries {
} }
} }
fn previous_completed_end_index(&self, date: NaiveDate) -> Option<usize> {
match self.dates.binary_search(&date) {
Ok(idx) => Some(idx),
Err(0) => None,
Err(idx) => Some(idx),
}
}
fn decision_close_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> { fn decision_close_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
if lookback == 0 { if lookback == 0 {
return None; return None;
@@ -545,28 +551,11 @@ impl SymbolPriceSeries {
Some(sum / lookback as f64) Some(sum / lookback as f64)
} }
fn decision_close_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
if lookback == 0 {
return None;
}
let end = self.decision_end_index(date)?;
if end == 0 {
return None;
}
let start = end.saturating_sub(lookback);
let count = end.saturating_sub(start);
if count == 0 {
return None;
}
let sum = self.prev_close_prefix[end] - self.prev_close_prefix[start];
Some(sum / count as f64)
}
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> { fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
if lookback == 0 { if lookback == 0 {
return None; return None;
} }
let end = self.decision_end_index(date)?; let end = self.previous_completed_end_index(date)?;
if end < lookback { if end < lookback {
return None; return None;
} }
@@ -575,23 +564,6 @@ impl SymbolPriceSeries {
Some(sum / lookback as f64) Some(sum / lookback as f64)
} }
fn decision_volume_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
if lookback == 0 {
return None;
}
let end = self.decision_end_index(date)?;
if end == 0 {
return None;
}
let start = end.saturating_sub(lookback);
let count = end.saturating_sub(start);
if count == 0 {
return None;
}
let sum = self.volume_prefix[end] - self.volume_prefix[start];
Some(sum / count as f64)
}
fn end_index(&self, date: NaiveDate) -> Option<usize> { fn end_index(&self, date: NaiveDate) -> Option<usize> {
match self.dates.binary_search(&date) { match self.dates.binary_search(&date) {
Ok(idx) => Some(idx + 1), Ok(idx) => Some(idx + 1),
@@ -630,7 +602,6 @@ impl SymbolPriceSeries {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct BenchmarkPriceSeries { struct BenchmarkPriceSeries {
dates: Vec<NaiveDate>, dates: Vec<NaiveDate>,
opens: Vec<f64>,
closes: Vec<f64>, closes: Vec<f64>,
open_prefix: Vec<f64>, open_prefix: Vec<f64>,
close_prefix: Vec<f64>, close_prefix: Vec<f64>,
@@ -647,7 +618,6 @@ impl BenchmarkPriceSeries {
let close_prefix = prefix_sums(&closes); let close_prefix = prefix_sums(&closes);
Self { Self {
dates, dates,
opens,
closes, closes,
open_prefix, open_prefix,
close_prefix, close_prefix,
@@ -2014,11 +1984,11 @@ impl DataSet {
"close" | "prev_close" | "stock_close" | "price" => self "close" | "prev_close" | "stock_close" | "price" => self
.market_series_by_symbol .market_series_by_symbol
.get(symbol) .get(symbol)
.and_then(|series| series.decision_close_rolling_average(date, lookback)), .and_then(|series| series.decision_close_moving_average(date, lookback)),
"volume" | "stock_volume" => self "volume" | "stock_volume" => self
.market_series_by_symbol .market_series_by_symbol
.get(symbol) .get(symbol)
.and_then(|series| series.decision_volume_rolling_average(date, lookback)), .and_then(|series| series.decision_volume_moving_average(date, 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)
} }
@@ -3111,6 +3081,63 @@ mod tests {
std::env::temp_dir().join(format!("{}_{}_{}.csv", name, std::process::id(), nanos)) std::env::temp_dir().join(format!("{}_{}_{}.csv", name, std::process::id(), nanos))
} }
fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
symbol: "000001.SZ".to_string(),
timestamp: None,
day_open: prev_close,
open: prev_close,
high: prev_close,
low: prev_close,
close: prev_close,
last_price: prev_close,
bid1: prev_close,
ask1: prev_close,
prev_close,
volume,
tick_volume: 0,
bid1_volume: 0,
ask1_volume: 0,
trading_phase: None,
paused: false,
upper_limit: prev_close * 1.1,
lower_limit: prev_close * 0.9,
price_tick: 0.01,
}
}
#[test]
fn decision_volume_average_uses_previous_completed_days_only() {
let series = SymbolPriceSeries::new(&[
market_row("2025-01-02", 10.0, 100),
market_row("2025-01-03", 11.0, 200),
market_row("2025-01-06", 12.0, 10_000),
]);
assert_eq!(
series.decision_close_moving_average(
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
2
),
Some(11.5)
);
assert_eq!(
series.decision_volume_moving_average(
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
2
),
Some(150.0)
);
assert_eq!(
series.decision_volume_moving_average(
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
3
),
None
);
}
#[test] #[test]
fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() { fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() {
let path = temp_csv_path("mixed_factor_maps"); let path = temp_csv_path("mixed_factor_maps");

View File

@@ -1224,47 +1224,47 @@ impl PlatformExprStrategy {
let stock_ma_short = ctx let stock_ma_short = ctx
.data .data
.market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days) .market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days)
.unwrap_or(0.0); .unwrap_or(f64::NAN);
let stock_ma_mid = ctx let stock_ma_mid = ctx
.data .data
.market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days) .market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days)
.unwrap_or(0.0); .unwrap_or(f64::NAN);
let stock_ma_long = ctx let stock_ma_long = ctx
.data .data
.market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days) .market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days)
.unwrap_or(0.0); .unwrap_or(f64::NAN);
let stock_ma5 = ctx let stock_ma5 = ctx
.data .data
.market_decision_close_moving_average(date, symbol, 5) .market_decision_close_moving_average(date, symbol, 5)
.unwrap_or(stock_ma_short); .unwrap_or(f64::NAN);
let stock_ma10 = ctx let stock_ma10 = ctx
.data .data
.market_decision_close_moving_average(date, symbol, 10) .market_decision_close_moving_average(date, symbol, 10)
.unwrap_or(stock_ma_mid); .unwrap_or(f64::NAN);
let stock_ma20 = ctx let stock_ma20 = ctx
.data .data
.market_decision_close_moving_average(date, symbol, 20) .market_decision_close_moving_average(date, symbol, 20)
.unwrap_or(stock_ma_long); .unwrap_or(f64::NAN);
let stock_ma30 = ctx let stock_ma30 = ctx
.data .data
.market_decision_close_moving_average(date, symbol, 30) .market_decision_close_moving_average(date, symbol, 30)
.unwrap_or(stock_ma20); .unwrap_or(f64::NAN);
let stock_volume_ma5 = ctx let stock_volume_ma5 = ctx
.data .data
.market_decision_volume_moving_average(date, symbol, 5) .market_decision_volume_moving_average(date, symbol, 5)
.unwrap_or(market.volume as f64); .unwrap_or(f64::NAN);
let stock_volume_ma10 = ctx let stock_volume_ma10 = ctx
.data .data
.market_decision_volume_moving_average(date, symbol, 10) .market_decision_volume_moving_average(date, symbol, 10)
.unwrap_or(stock_volume_ma5); .unwrap_or(f64::NAN);
let stock_volume_ma20 = ctx let stock_volume_ma20 = ctx
.data .data
.market_decision_volume_moving_average(date, symbol, 20) .market_decision_volume_moving_average(date, symbol, 20)
.unwrap_or(stock_volume_ma10); .unwrap_or(f64::NAN);
let stock_volume_ma60 = ctx let stock_volume_ma60 = ctx
.data .data
.market_decision_volume_moving_average(date, symbol, 60) .market_decision_volume_moving_average(date, symbol, 60)
.unwrap_or(stock_volume_ma20); .unwrap_or(f64::NAN);
let touched_upper_limit = factor let touched_upper_limit = factor
.extra_factors .extra_factors
.get("touched_upper_limit") .get("touched_upper_limit")
@@ -2623,6 +2623,14 @@ impl PlatformExprStrategy {
}) })
} }
fn is_missing_rolling_mean_error(error: &BacktestError) -> bool {
matches!(
error,
BacktestError::Execution(message)
if message.starts_with("missing rolling mean for field ")
)
}
fn split_top_level_args(args: &str) -> Vec<String> { fn split_top_level_args(args: &str) -> Vec<String> {
let mut parts = Vec::new(); let mut parts = Vec::new();
let mut start = 0usize; let mut start = 0usize;
@@ -2961,8 +2969,11 @@ impl PlatformExprStrategy {
if self.config.buy_scale_expr.trim().is_empty() { if self.config.buy_scale_expr.trim().is_empty() {
return Ok(1.0); return Ok(1.0);
} }
self.eval_float(ctx, &self.config.buy_scale_expr, day, Some(stock), None) match self.eval_float(ctx, &self.config.buy_scale_expr, day, Some(stock), None) {
.map(|value| value.clamp(0.0, 1.0)) Ok(value) => Ok(value.clamp(0.0, 1.0)),
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(0.0),
Err(error) => Err(error),
}
} }
fn eval_i32( fn eval_i32(
@@ -3832,7 +3843,11 @@ impl PlatformExprStrategy {
if self.config.stock_filter_expr.trim().is_empty() { if self.config.stock_filter_expr.trim().is_empty() {
return Ok(true); return Ok(true);
} }
self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) match self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) {
Ok(value) => Ok(value),
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(false),
Err(error) => Err(error),
}
} }
fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 { fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 {
@@ -3919,7 +3934,11 @@ impl PlatformExprStrategy {
stock: &StockExpressionState, stock: &StockExpressionState,
) -> Result<f64, BacktestError> { ) -> Result<f64, BacktestError> {
if !self.config.rank_expr.trim().is_empty() { if !self.config.rank_expr.trim().is_empty() {
return self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None); return match self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None) {
Ok(value) => Ok(value),
Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(f64::NAN),
Err(error) => Err(error),
};
} }
Ok(self Ok(self
.stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str()) .stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str())
@@ -4003,10 +4022,28 @@ impl PlatformExprStrategy {
for candidate in universe { for candidate in universe {
let stock = self.stock_state(ctx, date, &candidate.symbol)?; let stock = self.stock_state(ctx, date, &candidate.symbol)?;
let field_value = self.selection_field_value(&candidate, &stock); let field_value = self.selection_field_value(&candidate, &stock);
if !field_value.is_finite() {
if diagnostics.len() < 12 {
diagnostics.push(format!(
"{} rejected by missing selection field",
candidate.symbol
));
}
continue;
}
if field_value < band_low || field_value > band_high { if field_value < band_low || field_value > band_high {
continue; continue;
} }
let rank_value = self.rank_value(ctx, day, &candidate, &stock)?; let rank_value = self.rank_value(ctx, day, &candidate, &stock)?;
if !rank_value.is_finite() {
if diagnostics.len() < 12 {
diagnostics.push(format!(
"{} rejected by missing rank field",
candidate.symbol
));
}
continue;
}
candidates.push((candidate, stock, rank_value)); candidates.push((candidate, stock, rank_value));
} }
candidates.sort_by(|lhs, rhs| { candidates.sort_by(|lhs, rhs| {
@@ -5070,6 +5107,120 @@ mod tests {
} }
} }
#[test]
fn platform_strategy_treats_missing_stock_rolling_window_as_filter_reject() {
let date = d(2025, 2, 3);
let data = DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "Short History Stock".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2025, 1, 20)),
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2025-02-03 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.3,
low: 9.9,
close: 10.1,
last_price: 10.05,
bid1: 10.04,
ask1: 10.05,
prev_close: 9.95,
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: 10.95,
lower_limit: 8.95,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
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(),
}],
vec![CandidateEligibility {
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,
}],
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 portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 0,
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 = 1;
cfg.max_positions = 1;
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 = "1".to_string();
cfg.stock_filter_expr = "rolling_mean(\"volume\", 60) > 0".to_string();
let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).expect("platform decision");
assert!(decision.order_intents.is_empty());
assert!(
decision
.diagnostics
.iter()
.any(|item| item.contains("selected=0"))
);
assert!(
decision
.diagnostics
.iter()
.any(|item| item.contains("000001.SZ rejected by stock_expr"))
);
}
#[test] #[test]
fn platform_strategy_emits_target_shares_explicit_action() { fn platform_strategy_emits_target_shares_explicit_action() {
let date = d(2025, 2, 3); let date = d(2025, 2, 3);

View File

@@ -177,8 +177,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".to_string() }, ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".to_string() },
ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() }, ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() },
ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() }, ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() },
ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名。只内建这几个窗口15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() }, ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名,按当前交易日前 N 个已完成交易日的收盘价计算;历史窗口不足时为 NaN比较条件会自然不通过15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() },
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名。只内建这几个窗口;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() }, ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名,按当前交易日前 N 个已完成交易日的成交量计算,不包含回测当天未来成交量;历史窗口不足时为 NaN比较条件会自然不通过;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
ManualField { name: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日数据库因子。数值字段返回数字,字符串字段返回字符串;字符串字段名如果是合法标识符,也可直接写字段名,例如 concept == \"ai_chip\"".to_string() }, ManualField { name: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日数据库因子。数值字段返回数字,字符串字段返回字符串;字符串字段名如果是合法标识符,也可直接写字段名,例如 concept == \"ai_chip\"".to_string() },
ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() }, ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() },
], ],
@@ -229,7 +229,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason可用便捷函数读取状态、成交均价和费用对齐 平台内核 Order 的核心属性。".to_string() }, ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason可用便捷函数读取状态、成交均价和费用对齐 平台内核 Order 的核心属性。".to_string() },
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() }, ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金receiving_days 大于 0 时按交易日延迟到账并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() }, ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金receiving_days 大于 0 时按交易日延迟到账并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() },
ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".to_string() }, ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。个股 volume 与 close 均按当前交易日前已完成交易日计算;单只股票历史窗口不足时,在选股过滤和买入仓位表达式中按不通过/0 仓处理,不会中断整次回测。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".to_string() },
ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。任意价格均线窗口推荐用它,比如 sma(\"close\", 15)。".to_string() }, ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。任意价格均线窗口推荐用它,比如 sma(\"close\", 15)。".to_string() },
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() }, ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() }, ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },