kronos / webui /factor_engine.py
sgfw's picture
Upload 68 files
459d1c8 verified
Raw History Blame Contribute Delete
27.3 kB
# -*- coding: utf-8 -*-
"""
希希量化分析系统 - 因子表达式引擎
基于QuantGPT expression_parser.py适配,去除Rust依赖和外部模块引用
支持60+算子:时序、截面、技术指标、基本面、算术/比较/逻辑运算、三元运算符
"""
import numpy as np
import pandas as pd
import re
import warnings
warnings.filterwarnings('ignore')
# ================================================================
# 允许的基本面列名(替代QuantGPT的fundamental_data模块)
# ================================================================
_FUNDAMENTAL_COLUMNS = {
# 估值
'pe', 'pe_ttm', 'pb', 'ps', 'ps_ttm', 'pcf', 'pcf_ttm', 'ev_ebitda',
# 盈利能力
'roe', 'roa', 'gross_margin', 'net_margin', 'ebitda_margin',
'operating_margin', 'ebit_margin',
# 成长性
'revenue_yoy', 'net_profit_yoy', 'eps_yoy', 'operating_revenue_yoy',
'revenue_qoq', 'net_profit_qoq',
# 每股指标
'eps', 'eps_ttm', 'bvps', 'dps', 'operating_cashflow_ps',
# 资产负债
'debt_ratio', 'current_ratio', 'quick_ratio', 'asset_turnover',
'equity_multiplier', 'cash_ratio',
# 现金流
'operating_cashflow', 'free_cashflow', 'cashflow_to_debt',
'fcf_yoy', 'ocf_yoy',
# 其他
'market_cap', 'float_cap', 'total_shares', 'float_shares',
'dividend_yield', 'retention_ratio', 'payout_ratio',
'institutional_ratio', 'north_flow', 'main_net_inflow',
'volume_ratio', 'amplitude',
}
# 价格/量相关列
_PRICE_COLUMNS = {
'open', 'high', 'low', 'close', 'volume', 'amount',
'pre_close', 'turnover', 'turnover_rate', 'change_pct',
'vwap', 'adj_factor',
}
# 所有允许的列名
ALLOWED_COLUMNS = _FUNDAMENTAL_COLUMNS | _PRICE_COLUMNS
# Alpha101 别名映射
_ALPHA101_ALIASES = {
'delta': 'ts_delta',
'delay': 'ts_shift',
'correlation': 'ts_corr',
'covariance': 'ts_cov',
'rank': 'ts_rank',
'scale': 'ts_scale',
'signed_power': 'ts_signed_power',
'product': 'ts_product',
'stddev': 'ts_std',
'log': 'ts_log',
'sum': 'ts_sum',
'adv': 'ts_mean',
'min': 'ts_min',
'max': 'ts_max',
'argmax': 'ts_argmax',
'argmin': 'ts_argmin',
'av_diff': 'ts_av_diff',
'mean': 'ts_mean',
'indneutralize': 'group_neutralize',
'ts_decay_linear': 'decay_linear',
'ts_std_dev': 'ts_std',
'ts_regression': 'ts_corr',
}
class ExpressionParser:
"""因子表达式解析引擎 - 支持嵌套运算、时序/截面算子"""
def __init__(self):
self._operators = {
# 时序算子
'ts_mean': self._calc_ts_mean, 'ts_std': self._calc_ts_std,
'ts_delta': self._calc_ts_delta, 'ts_rank': self._calc_ts_rank,
'ts_argmax': self._calc_ts_argmax, 'ts_argmin': self._calc_ts_argmin,
'ts_corr': self._calc_ts_corr, 'ts_cov': self._calc_ts_cov,
'decay_linear': self._calc_decay_linear, 'product': self._calc_product,
'ts_av_diff': self._calc_ts_av_diff, 'ts_zscore': self._calc_ts_zscore,
'ts_sum': self._calc_ts_sum, 'ts_min': self._calc_ts_min,
'ts_max': self._calc_ts_max, 'ts_shift': self._calc_ts_shift,
'ts_skewness': self._calc_ts_skewness, 'ts_kurtosis': self._calc_ts_kurtosis,
'ts_median': self._calc_ts_median, 'ts_quantile': self._calc_ts_quantile,
'ts_log': self._calc_ts_log, 'ts_abs': self._calc_ts_abs,
'ts_sign': self._calc_ts_sign, 'ts_pow': self._calc_ts_pow,
'ts_prod': self._calc_product, 'ts_co_kurtosis': lambda f, w: f,
# 截面算子
'rank': self._calc_cross_rank, 'zscore': self._calc_cross_zscore,
'scale': self._calc_cross_scale, 'group_rank': self._calc_group_rank,
'group_zscore': self._calc_group_zscore,
'group_neutralize': self._calc_group_neutralize,
# 技术指标
'ema': self._calc_ema, 'sma': self._calc_sma, 'wma': self._calc_wma,
'rsi': self._calc_rsi, 'macd': self._calc_macd, 'obv': self._calc_obv,
'atr': self._calc_atr, 'boll_upper': self._calc_boll_upper,
'boll_lower': self._calc_boll_lower, 'boll_mid': self._calc_boll_mid,
'kdj_k': self._calc_kdj_k, 'kdj_d': self._calc_kdj_d,
'wr': self._calc_wr, 'cci': self._calc_cci,
# Alpha101别名已在上层解析时替换
}
def parse(self, expression, data, group_col=None):
"""
解析并计算因子表达式
Parameters
----------
expression : str
因子表达式,如 "ts_rank(ts_delta(close, 5), 20)"
data : pd.DataFrame
包含价格/基本面列的DataFrame,columns中须有表达式引用的列
group_col : str, optional
分组列名(用于group_rank等算子)
Returns
-------
pd.Series : 计算结果
"""
# 1. 替换Alpha101别名
expr = self._replace_aliases(expression)
# 2. 替换三元运算符 a ? b : c → (a) if (a, b, c)
expr = self._convert_ternary_operators(expr)
# 3. 解析并计算
result = self._evaluate(expr, data, group_col)
return result
def parse_expression(expression, data, group_col=None):
"""便捷函数"""
parser = ExpressionParser()
return parser.parse(expression, data, group_col)
# ──── 别名替换 ────
def _replace_aliases(self, expr):
for alias, target in _ALPHA101_ALIASES.items():
pattern = rf'\b{alias}\s*\('
expr = re.sub(pattern, f'{target}(', expr)
return expr
# ──── 三元运算符转换 ────
def _convert_ternary_operators(self, expr):
"""将 a ? b : c 转换为嵌套函数调用"""
while '?' in expr:
# 找到最内层的 ?...:...
idx = expr.rfind('?')
colon_idx = expr.find(':', idx)
if colon_idx == -1:
break
condition = expr[:idx].rstrip()
true_val = expr[idx+1:colon_idx].strip()
false_val = expr[colon_idx+1:].strip()
expr = f'ternary({condition},{true_val},{false_val})'
return expr
# ──── 核心解析器 ────
def _evaluate(self, expr, data, group_col=None):
"""递归解析表达式树"""
expr = expr.strip()
# 括号包裹的表达式
if expr.startswith('(') and self._find_matching_paren(expr, 0) == len(expr) - 1:
return self._evaluate(expr[1:-1], data, group_col)
# 函数调用
match = re.match(r'^(\w+)\s*\(', expr)
if match:
func_name = match.group(1)
left_paren_pos = match.end() - 1 # 左括号位置
# 用_find_matching_paren从左括号位置找匹配的右括号
paren_end = self._find_matching_paren(expr, left_paren_pos)
if paren_end is not None and paren_end >= 0:
args_str = expr[left_paren_pos+1:paren_end]
else:
# 退化处理
args_str = expr[match.end():]
if args_str.endswith(')'):
args_str = args_str[:-1]
args = self._split_args(args_str)
return self._call_function(func_name, args, data, group_col)
# 算术/比较/逻辑运算符(从低优先级开始解析)
for op in ['&&', '||']:
idx = self._find_operator(expr, op, data)
if idx is not None:
left = self._evaluate(expr[:idx], data, group_col)
right = self._evaluate(expr[idx+len(op):], data, group_col)
if op == '&&':
return left.astype(float) * right.astype(float)
else:
return (left.astype(float) + right.astype(float)).clip(upper=1)
for op in ['>=', '<=', '!=', '==', '>', '<']:
idx = self._find_operator(expr, op, data)
if idx is not None:
left = self._evaluate(expr[:idx], data, group_col)
right = self._evaluate(expr[idx+len(op):], data, group_col)
if op == '>': return (left > right).astype(float)
elif op == '<': return (left < right).astype(float)
elif op == '>=': return (left >= right).astype(float)
elif op == '<=': return (left <= right).astype(float)
elif op == '==': return (left == right).astype(float)
elif op == '!=': return (left != right).astype(float)
for op in ['+', '-']:
idx = self._find_operator(expr, op, data)
if idx is not None and idx > 0:
left = self._evaluate(expr[:idx], data, group_col)
right = self._evaluate(expr[idx+1:], data, group_col)
if op == '+': return left.astype(float) + right.astype(float)
else: return left.astype(float) - right.astype(float)
for op in ['*', '/']:
idx = self._find_operator(expr, op, data)
if idx is not None and idx > 0:
left = self._evaluate(expr[:idx], data, group_col)
right = self._evaluate(expr[idx+1:], data, group_col)
if op == '*': return left.astype(float) * right.astype(float)
else: return left.astype(float) / right.astype(float).replace(0, np.nan)
# 一元运算符
if expr.startswith('-'):
return -self._evaluate(expr[1:], data, group_col)
if expr.startswith('!'):
return (1 - self._evaluate(expr[1:], data, group_col)).astype(float)
if expr.startswith('abs(') or expr.startswith('Abs('):
inner = expr[4:-1] if expr.endswith(')') else expr[4:]
return self._evaluate(inner, data, group_col).abs()
if expr.startswith('log('):
inner = expr[4:-1] if expr.endswith(')') else expr[4:]
return np.log(self._evaluate(inner, data, group_col).clip(lower=1e-10))
# 特殊变量
if expr == 'ternary':
return pd.Series(0, index=data.index)
# 列名
if expr in data.columns:
return data[expr].astype(float)
# 数值
try:
return pd.Series(float(expr), index=data.index)
except ValueError:
pass
raise ValueError(f"无法解析表达式: {expr}")
def _find_matching_paren(self, expr, start):
"""找到与start位置左括号匹配的右括号位置"""
depth = 0
for i in range(start, len(expr)):
if expr[i] == '(':
depth += 1
elif expr[i] == ')':
depth -= 1
if depth == 0:
return i
return -1
def _split_args(self, args_str):
"""按逗号分割参数(尊重嵌套括号)"""
args = []
depth = 0
current = ''
for ch in args_str:
if ch == '(':
depth += 1
current += ch
elif ch == ')':
depth -= 1
current += ch
elif ch == ',' and depth == 0:
args.append(current.strip())
current = ''
else:
current += ch
if current.strip():
args.append(current.strip())
return args
def _find_operator(self, expr, op, data):
"""找到非括号内的运算符位置"""
depth = 0
in_func = False
for i in range(len(expr)):
ch = expr[i]
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif depth == 0:
if expr[i:i+len(op)] == op:
# 确保不是函数名的一部分
if i > 0 and expr[i-1].isalpha():
continue
if i + len(op) < len(expr) and expr[i+len(op)].isalpha():
continue
return i
return None
def _call_function(self, func_name, args, data, group_col):
"""调用算子函数"""
if func_name == 'ternary':
if len(args) == 3:
cond = self._evaluate(args[0], data, group_col)
true_v = self._evaluate(args[1], data, group_col)
false_v = self._evaluate(args[2], data, group_col)
return np.where(cond > 0, true_v, false_v)
raise ValueError("ternary需要3个参数: condition, true_val, false_val")
if func_name == 'group_rank' and group_col and group_col not in data.columns:
args_eval = [self._evaluate(a, data, group_col) for a in args]
return args_eval[0] if len(args_eval) == 1 else pd.concat(args_eval, axis=1).iloc[:, -1]
if func_name in self._operators:
args_eval = []
for a in args:
a = a.strip()
# 如果参数是纯数字,直接转float
try:
args_eval.append(float(a))
continue
except (ValueError, TypeError):
pass
# 否则作为表达式解析
try:
v = self._evaluate(a, data, group_col)
# 如果结果是长度为1的Series,取标量
if isinstance(v, pd.Series) and len(v) == 1:
v = float(v.iloc[0])
args_eval.append(v)
except (ValueError, KeyError) as e:
args_eval.append(a)
# 处理group_col参数
gc = group_col if func_name.startswith('group_') else None
try:
return self._operators[func_name](*args_eval, group_col=gc)
except TypeError as e:
# 可能不需要group_col参数
try:
return self._operators[func_name](*args_eval)
except TypeError:
raise ValueError(f"算子 {func_name} 调用失败: {e}, 参数: {args_eval}")
raise ValueError(f"未知算子: {func_name}")
# ================================================================
# 时序算子 (Time-Series Operators)
# ================================================================
def _calc_ts_mean(self, x, window, **kw):
if isinstance(window, (int, float)):
return x.rolling(int(window), min_periods=1).mean()
return x.mean()
def _calc_ts_std(self, x, window, **kw):
if isinstance(window, (int, float)):
return x.rolling(int(window), min_periods=1).std()
return x.std()
def _calc_ts_delta(self, x, period, **kw):
if isinstance(period, (int, float)):
return x - x.shift(int(period))
return x.diff()
def _calc_ts_rank(self, x, window, **kw):
if isinstance(window, (int, float)):
return x.rolling(int(window), min_periods=1).apply(
lambda s: pd.Series(s).rank(pct=True).iloc[-1] if len(s) > 1 else 0.5, raw=False)
return x.rank(pct=True)
def _calc_ts_argmax(self, x, window, **kw):
if isinstance(window, (int, float)):
return x.rolling(int(window), min_periods=1).apply(
lambda s: np.argmax(s.values) if len(s) > 0 else 0, raw=True)
return pd.Series(np.argmax(x.values), index=x.index)
def _calc_ts_argmin(self, x, window, **kw):
if isinstance(window, (int, float)):
return x.rolling(int(window), min_periods=1).apply(
lambda s: np.argmin(s.values) if len(s) > 0 else 0, raw=True)
return pd.Series(np.argmin(x.values), index=x.index)
def _calc_ts_corr(self, x, y, window, **kw):
if isinstance(window, (int, float)):
df = pd.concat([x, y], axis=1)
return df.iloc[:,0].rolling(int(window), min_periods=2).corr(df.iloc[:,1])
return x.corr(y)
def _calc_ts_cov(self, x, y, window, **kw):
if isinstance(window, (int, float)):
df = pd.concat([x, y], axis=1)
return df.iloc[:,0].rolling(int(window), min_periods=2).cov(df.iloc[:,1])
return x.cov(y)
def _calc_decay_linear(self, x, window, **kw):
if isinstance(window, (int, float)):
w = int(window)
def weighted_mean(s):
weights = np.arange(1, len(s)+1, dtype=float)
weights /= weights.sum()
return np.nansum(s.values * weights)
return x.rolling(w, min_periods=1).apply(weighted_mean, raw=False)
return x
def _calc_product(self, x, window, **kw):
if isinstance(window, (int, float)):
return x.rolling(int(window), min_periods=1).apply(
lambda s: np.nanprod(s.clip(lower=-1e10, upper=1e10).values), raw=True)
return x.cumprod()
def _calc_ts_av_diff(self, x, window, **kw):
mean_val = x.rolling(int(window), min_periods=1).mean()
return x - mean_val
def _calc_ts_zscore(self, x, window, **kw):
if isinstance(window, (int, float)):
m = x.rolling(int(window), min_periods=1).mean()
s = x.rolling(int(window), min_periods=1).std()
return (x - m) / s.replace(0, np.nan)
return (x - x.mean()) / x.std().replace(0, np.nan)
def _calc_ts_sum(self, x, window, **kw):
return x.rolling(int(window), min_periods=1).sum()
def _calc_ts_min(self, x, window, **kw):
return x.rolling(int(window), min_periods=1).min()
def _calc_ts_max(self, x, window, **kw):
return x.rolling(int(window), min_periods=1).max()
def _calc_ts_shift(self, x, period, **kw):
return x.shift(int(period))
def _calc_ts_skewness(self, x, window, **kw):
return x.rolling(int(window), min_periods=3).skew()
def _calc_ts_kurtosis(self, x, window, **kw):
return x.rolling(int(window), min_periods=4).kurt()
def _calc_ts_median(self, x, window, **kw):
return x.rolling(int(window), min_periods=1).median()
def _calc_ts_quantile(self, x, window, q=0.5, **kw):
return x.rolling(int(window), min_periods=1).quantile(float(q))
def _calc_ts_log(self, x, **kw):
return np.log(x.clip(lower=1e-10))
def _calc_ts_abs(self, x, **kw):
return x.abs()
def _calc_ts_sign(self, x, **kw):
return np.sign(x)
def _calc_ts_pow(self, x, power, **kw):
return x ** float(power)
# ================================================================
# 截面算子 (Cross-Sectional Operators)
# ================================================================
def _calc_cross_rank(self, x, **kw):
return x.rank(pct=True)
def _calc_cross_zscore(self, x, **kw):
m = x.mean()
s = x.std()
return (x - m) / s if s > 0 else pd.Series(0, index=x.index)
def _calc_cross_scale(self, x, **kw):
s = x.abs().sum()
return x / s if s > 0 else pd.Series(0, index=x.index)
def _calc_group_rank(self, x, group_col=None, **kw):
if group_col is not None and hasattr(x, 'index'):
# 尝试从data的index获取分组
return x.rank(pct=True)
return x.rank(pct=True)
def _calc_group_zscore(self, x, group_col=None, **kw):
return self._calc_cross_zscore(x)
def _calc_group_neutralize(self, x, group_col=None, **kw):
return self._calc_cross_zscore(x)
# ================================================================
# 技术指标 (Technical Indicators)
# ================================================================
def _calc_ema(self, x, period, **kw):
return x.ewm(span=int(period), adjust=False).mean()
def _calc_sma(self, x, period, **kw):
return x.rolling(int(period), min_periods=1).mean()
def _calc_wma(self, x, period, **kw):
w = int(period)
def weighted_avg(s):
weights = np.arange(1, len(s)+1, dtype=float)
return np.nansum(s.values * weights) / weights.sum()
return x.rolling(w, min_periods=1).apply(weighted_avg, raw=False)
def _calc_rsi(self, x, period=14, **kw):
delta = x.diff()
gain = delta.where(delta > 0, 0)
loss = (-delta).where(delta < 0, 0)
avg_gain = gain.ewm(alpha=1.0/int(period), min_periods=int(period)).mean()
avg_loss = loss.ewm(alpha=1.0/int(period), min_periods=int(period)).mean()
rs = avg_gain / avg_loss.replace(0, 1e-10)
return 100 - (100 / (1 + rs))
def _calc_macd(self, x, fast=12, slow=26, signal=9, **kw):
ema_fast = x.ewm(span=int(fast), adjust=False).mean()
ema_slow = x.ewm(span=int(slow), adjust=False).mean()
dif = ema_fast - ema_slow
dea = dif.ewm(span=int(signal), adjust=False).mean()
histogram = (dif - dea) * 2
return dif
def _calc_obv(self, x, volume=None, **kw):
if volume is not None:
return x * volume
return x
def _calc_atr(self, x, period=14, **kw):
# 简化:用close的rolling范围近似
return x.rolling(int(period), min_periods=1).apply(
lambda s: s.max() - s.min(), raw=True)
def _calc_boll_upper(self, x, period=20, num_std=2, **kw):
mid = x.rolling(int(period), min_periods=1).mean()
std = x.rolling(int(period), min_periods=1).std()
return mid + float(num_std) * std
def _calc_boll_lower(self, x, period=20, num_std=2, **kw):
mid = x.rolling(int(period), min_periods=1).mean()
std = x.rolling(int(period), min_periods=1).std()
return mid - float(num_std) * std
def _calc_boll_mid(self, x, period=20, **kw):
return x.rolling(int(period), min_periods=1).mean()
def _calc_kdj_k(self, x, period=9, **kw):
lowest = x.rolling(int(period), min_periods=1).min()
highest = x.rolling(int(period), min_periods=1).max()
return (x - lowest) / (highest - lowest).replace(0, np.nan) * 100
def _calc_kdj_d(self, x, period=9, m=3, **kw):
k = self._calc_kdj_k(x, period)
return k.rolling(int(m), min_periods=1).mean()
def _calc_wr(self, x, period=14, **kw):
highest = x.rolling(int(period), min_periods=1).max()
lowest = x.rolling(int(period), min_periods=1).min()
return (highest - x) / (highest - lowest).replace(0, np.nan) * -100
def _calc_cci(self, x, period=20, **kw):
tp = x # simplified: use close as typical price
ma = tp.rolling(int(period), min_periods=1).mean()
md = tp.rolling(int(period), min_periods=1).apply(
lambda s: np.abs(s - s.mean()).mean(), raw=True)
return (tp - ma) / (0.015 * md.replace(0, np.nan))
# 便捷函数
def parse_expression(expression, data, group_col=None):
"""
解析因子表达式(全局便捷入口)
Parameters
----------
expression : str - 因子表达式
data : pd.DataFrame - 包含所需列的数据
group_col : str, optional - 分组列名
Returns
-------
pd.Series
示例:
>>> parse_expression("ts_rank(ts_delta(close, 5), 20)", df)
>>> parse_expression("rank(rsi(close, 14))", df)
>>> parse_expression("ts_zscore(close / ts_mean(close, 20), 60)", df)
>>> parse_expression("close > ts_mean(close, 20) ? 1 : -1", df)
"""
parser = ExpressionParser()
return parser.parse(expression, data, group_col)
# 验证表达式语法
def validate_expression(expression):
"""
验证表达式是否包含允许的算子和列名
返回 (is_valid, error_message)
"""
try:
parser = ExpressionParser()
# 替换别名
expr = parser._replace_aliases(expression)
expr = parser._convert_ternary_operators(expr)
# 检查是否有未知函数调用
func_calls = re.findall(r'(\w+)\s*\(', expr)
known_ops = set(parser._operators.keys()) | {'ternary'}
for fc in func_calls:
if fc not in known_ops and fc not in ALLOWED_COLUMNS:
return False, f"未知算子或列名: {fc}"
return True, None
except Exception as e:
return False, str(e)
# 常用因子模板
FACTOR_TEMPLATES = {
'momentum_5d': {'expr': 'ts_delta(close, 5) / ts_delay(close, 5)', 'name': '5日动量', 'category': '动量'},
'momentum_20d': {'expr': 'ts_delta(close, 20) / ts_delay(close, 20)', 'name': '20日动量', 'category': '动量'},
'volatility_20d': {'expr': 'ts_std(close/ts_delay(close,1) - 1, 20)', 'name': '20日波动率', 'category': '波动率'},
'rsi_14': {'expr': 'rsi(close, 14)', 'name': 'RSI(14)', 'category': '技术'},
'turnover_rate': {'expr': 'ts_mean(volume, 5)', 'name': '5日均量', 'category': '量价'},
'boll_deviation': {'expr': '(close - boll_mid(close, 20)) / ts_std(close, 20)', 'name': '布林偏离度', 'category': '技术'},
'price_momentum_rank': {'expr': 'ts_rank(ts_delta(close, 10) / ts_delay(close, 10), 60)', 'name': '价格动量排名', 'category': '动量'},
'volume_price_corr': {'expr': 'ts_corr(close, volume, 20)', 'name': '量价相关系数', 'category': '量价'},
'mean_reversion_5d': {'expr': '-ts_delta(close, 5) / ts_delay(close, 5)', 'name': '5日均值回归', 'category': '反转'},
'high_low_ratio': {'expr': 'ts_mean(close/ts_delay(close,1) - 1, 5) / ts_std(close/ts_delay(close,1) - 1, 20)', 'name': '高低比率', 'category': '风险'},
'ema_cross': {'expr': 'ema(close, 12) - ema(close, 26)', 'name': 'EMA交叉', 'category': '趋势'},
'kurtosis_20d': {'expr': 'ts_kurtosis(close/ts_delay(close,1) - 1, 20)', 'name': '20日峰度', 'category': '分布'},
'skewness_20d': {'expr': 'ts_skewness(close/ts_delay(close,1) - 1, 20)', 'name': '20日偏度', 'category': '分布'},
'decay_volatility': {'expr': 'decay_linear(ts_std(close/ts_delay(close,1) - 1, 5), 20)', 'name': '衰减波动率', 'category': '波动率'},
'zscore_60d': {'expr': 'ts_zscore(close, 60)', 'name': '60日Z-Score', 'category': '统计'},
'cumulative_return_20d': {'expr': 'product(1 + (close - ts_delay(close, 1))/ts_delay(close, 1), 20)', 'name': '20日累计收益', 'category': '动量'},
'max_drawdown_20d': {'expr': 'ts_min(close/ts_delay(close,1) - 1, 20)', 'name': '20日最大回撤', 'category': '风险'},
'turnover_acceleration': {'expr': 'ts_delta(volume, 5) - ts_delta(volume, 20)', 'name': '换手加速度', 'category': '量价'},
}