Fix decision-time rolling factor semantics
This commit is contained in:
@@ -451,7 +451,6 @@ struct SymbolPriceSeries {
|
||||
closes: Vec<f64>,
|
||||
prev_closes: Vec<f64>,
|
||||
last_prices: Vec<f64>,
|
||||
volumes: Vec<f64>,
|
||||
open_prefix: Vec<f64>,
|
||||
close_prefix: Vec<f64>,
|
||||
prev_close_prefix: Vec<f64>,
|
||||
@@ -485,7 +484,6 @@ impl SymbolPriceSeries {
|
||||
closes,
|
||||
prev_closes,
|
||||
last_prices,
|
||||
volumes,
|
||||
open_prefix,
|
||||
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> {
|
||||
if lookback == 0 {
|
||||
return None;
|
||||
@@ -545,28 +551,11 @@ impl SymbolPriceSeries {
|
||||
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> {
|
||||
if lookback == 0 {
|
||||
return None;
|
||||
}
|
||||
let end = self.decision_end_index(date)?;
|
||||
let end = self.previous_completed_end_index(date)?;
|
||||
if end < lookback {
|
||||
return None;
|
||||
}
|
||||
@@ -575,23 +564,6 @@ impl SymbolPriceSeries {
|
||||
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> {
|
||||
match self.dates.binary_search(&date) {
|
||||
Ok(idx) => Some(idx + 1),
|
||||
@@ -630,7 +602,6 @@ impl SymbolPriceSeries {
|
||||
#[derive(Debug, Clone)]
|
||||
struct BenchmarkPriceSeries {
|
||||
dates: Vec<NaiveDate>,
|
||||
opens: Vec<f64>,
|
||||
closes: Vec<f64>,
|
||||
open_prefix: Vec<f64>,
|
||||
close_prefix: Vec<f64>,
|
||||
@@ -647,7 +618,6 @@ impl BenchmarkPriceSeries {
|
||||
let close_prefix = prefix_sums(&closes);
|
||||
Self {
|
||||
dates,
|
||||
opens,
|
||||
closes,
|
||||
open_prefix,
|
||||
close_prefix,
|
||||
@@ -2014,11 +1984,11 @@ impl DataSet {
|
||||
"close" | "prev_close" | "stock_close" | "price" => self
|
||||
.market_series_by_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
|
||||
.market_series_by_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" => {
|
||||
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))
|
||||
}
|
||||
|
||||
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]
|
||||
fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() {
|
||||
let path = temp_csv_path("mixed_factor_maps");
|
||||
|
||||
@@ -1224,47 +1224,47 @@ impl PlatformExprStrategy {
|
||||
let stock_ma_short = ctx
|
||||
.data
|
||||
.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
|
||||
.data
|
||||
.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
|
||||
.data
|
||||
.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
|
||||
.data
|
||||
.market_decision_close_moving_average(date, symbol, 5)
|
||||
.unwrap_or(stock_ma_short);
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_ma10 = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(date, symbol, 10)
|
||||
.unwrap_or(stock_ma_mid);
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_ma20 = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(date, symbol, 20)
|
||||
.unwrap_or(stock_ma_long);
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_ma30 = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(date, symbol, 30)
|
||||
.unwrap_or(stock_ma20);
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_volume_ma5 = ctx
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 5)
|
||||
.unwrap_or(market.volume as f64);
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_volume_ma10 = ctx
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 10)
|
||||
.unwrap_or(stock_volume_ma5);
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_volume_ma20 = ctx
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 20)
|
||||
.unwrap_or(stock_volume_ma10);
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_volume_ma60 = ctx
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 60)
|
||||
.unwrap_or(stock_volume_ma20);
|
||||
.unwrap_or(f64::NAN);
|
||||
let touched_upper_limit = factor
|
||||
.extra_factors
|
||||
.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> {
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0usize;
|
||||
@@ -2961,8 +2969,11 @@ impl PlatformExprStrategy {
|
||||
if self.config.buy_scale_expr.trim().is_empty() {
|
||||
return Ok(1.0);
|
||||
}
|
||||
self.eval_float(ctx, &self.config.buy_scale_expr, day, Some(stock), None)
|
||||
.map(|value| value.clamp(0.0, 1.0))
|
||||
match self.eval_float(ctx, &self.config.buy_scale_expr, day, Some(stock), None) {
|
||||
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(
|
||||
@@ -3832,7 +3843,11 @@ impl PlatformExprStrategy {
|
||||
if self.config.stock_filter_expr.trim().is_empty() {
|
||||
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 {
|
||||
@@ -3919,7 +3934,11 @@ impl PlatformExprStrategy {
|
||||
stock: &StockExpressionState,
|
||||
) -> Result<f64, BacktestError> {
|
||||
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
|
||||
.stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str())
|
||||
@@ -4003,10 +4022,28 @@ impl PlatformExprStrategy {
|
||||
for candidate in universe {
|
||||
let stock = self.stock_state(ctx, date, &candidate.symbol)?;
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
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.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]
|
||||
fn platform_strategy_emits_target_shares_explicit_action() {
|
||||
let date = d(2025, 2, 3);
|
||||
|
||||
@@ -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: "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: "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_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_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: "个股成交量均线内建别名,按当前交易日前 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: "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: "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: "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: "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() },
|
||||
|
||||
Reference in New Issue
Block a user