Files
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现:

回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标
被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、
组合体检品种费率、寻优端点费率透传。

安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、
错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。

数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/
provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、
baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作)
+ 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。

Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、
submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。

公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。

前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、
空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。

CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、
CI 超时与缓存、spec 补 baostock 前提。

约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
2026-09-06 22:16:48 +08:00

293 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通达信公式解析器测试(tokenizer / AST / 白名单求值 / 信号归类 / 安全性)。"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from easy_tdx.formula import FormulaError, compile_formula
def _df(n: int = 60, seed: int = 3) -> pd.DataFrame:
rng = np.random.default_rng(seed)
dates = pd.date_range("2024-01-01", periods=n, freq="B")
close = 10.0 * np.cumprod(1.0 + 0.002 + rng.normal(0, 0.015, n))
return pd.DataFrame(
{
"datetime": dates,
"open": close * 0.999,
"high": close * 1.02,
"low": close * 0.98,
"close": close,
"vol": rng.uniform(1e6, 5e6, n),
"amount": close * rng.uniform(1e6, 5e6, n),
}
)
# ── 编译与语法 ───────────────────────────────────────────────────────────────
def test_compile_and_outputs():
formula = compile_formula(
"""
N := 9;
RSV := (C - LLV(L, N)) / (HHV(H, N) - LLV(L, N)) * 100;
K := SMA(RSV, 3, 1);
金叉: CROSS(K, 20);
强度: K;
"""
)
res = formula.compute(_df())
assert "金叉" in res.signals
assert "强度" in res.values
frame = res.to_frame()
assert list(frame.columns) == ["金叉", "强度"]
assert len(frame) == 60
def test_syntax_error_has_position():
with pytest.raises(FormulaError):
compile_formula("A := ;")
with pytest.raises(FormulaError):
compile_formula("A := UNKNOWN_FUNC(C)")
with pytest.raises(FormulaError):
compile_formula("A := B + ") # 引用未定义变量且语法断裂
def test_unknown_variable_rejected():
with pytest.raises(FormulaError, match="未知变量"):
compile_formula("A: X1;").compute(_df())
def test_unknown_function_rejected():
with pytest.raises(FormulaError, match="白名单"):
compile_formula("A: EVAL(C);").compute(_df())
def test_empty_formula_rejected():
with pytest.raises(FormulaError, match="为空"):
compile_formula("{只有注释}")
def test_no_python_eval_injection():
"""公式层不走 Python eval:危险标识符按未知变量/函数拒绝。"""
with pytest.raises(FormulaError):
compile_formula("__import__('os'): 1;").compute(_df())
with pytest.raises(FormulaError):
compile_formula("A: OPEN(C);").compute(_df())
# ── 语义正确性 ───────────────────────────────────────────────────────────────
def test_series_aliases():
"""C/O/H/L/V/AMOUNT 别名与底层列一致。"""
df = _df(50)
res = compile_formula("高价: H; 低价: L; 收盘: CLOSE; 量: VOL; 额: AMOUNT;").compute(df)
assert np.allclose(res.columns["高价"], df["high"])
assert np.allclose(res.columns["收盘"], df["close"])
assert np.allclose(res.columns["量"], df["vol"])
def test_ma_matches_mytt():
from easy_tdx.MyTT import MA
df = _df(50)
res = compile_formula("均线: MA(C, 5);").compute(df)
assert np.allclose(res.columns["均线"], MA(df["close"].to_numpy(), 5), equal_nan=True)
def test_cross_semantics():
"""CROSS(A,B)A 上穿 B 的那一根为 1,其余 0。"""
df = _df(50)
res = compile_formula(
"""
快: MA(C, 3);
慢: MA(C, 10);
金叉: CROSS(快, 慢);
"""
).compute(df)
golden = res.columns["金叉"]
assert set(np.unique(golden[np.isfinite(golden)])).issubset({0.0, 1.0})
assert golden.sum() >= 0 # 结构完整(趋势数据至少存在或为 0)
# CROSS 手工复算对拍
from easy_tdx.MyTT import CROSS, MA
fast = MA(df["close"].to_numpy(), 3)
slow = MA(df["close"].to_numpy(), 10)
assert np.allclose(golden, CROSS(fast, slow), equal_nan=True)
def test_safe_division_zero_denominator_nan():
"""除零 → NaN(不炸、不 inf)。"""
df = _df(30)
res = compile_formula("比值: C / (C - C);").compute(df) # 分母全 0
assert np.isnan(res.columns["比值"]).all()
def test_logic_operators():
df = _df(40)
res = compile_formula(
"""
条件1: C > MA(C, 5);
条件2: C > MA(C, 20);
同时: 条件1 AND 条件2;
任一: 条件1 OR 条件2;
取反: NOT(条件1);
"""
).compute(df)
c1 = res.columns["条件1"] > 0.5
c2 = res.columns["条件2"] > 0.5
assert np.allclose(res.columns["同时"] > 0.5, c1 & c2)
assert np.allclose(res.columns["任一"] > 0.5, c1 | c2)
assert np.allclose(res.columns["取反"] > 0.5, ~c1)
def test_comparison_and_unary():
df = _df(30)
res = compile_formula("跌幅: -(C - REF(C, 1)) / REF(C, 1) * 100; 平: C == C;").compute(df)
assert (res.columns["平"] == 1.0).all()
assert "跌幅" in res.values
def test_warmup_nan_not_signal():
"""预热期 NaN 不产生信号(比较含 NaN → 0)。"""
df = _df(30)
res = compile_formula("信号: CROSS(MA(C, 20), MA(C, 25));").compute(df)
sig = res.columns["信号"]
assert np.nanmax(np.nan_to_num(sig[:25])) <= 1.0
assert np.isnan(sig).sum() == 0 # 布尔输出不含 NaN
def test_output_classification_boolean_vs_numeric():
"""比较/逻辑输出 → 信号;数值输出 → 数值列;0/1 值域数值也归信号。"""
df = _df(40)
res = compile_formula(
"""
布尔输出: C > REF(C, 1);
数值输出: MA(C, 5) - MA(C, 20);
"""
).compute(df)
assert res.signals == ["布尔输出"]
assert res.values == ["数值输出"]
def test_last_row_for_screening():
df = _df(30)
res = compile_formula("买入: CROSS(MA(C, 3), MA(C, 10)); 值: MA(C, 5);").compute(df)
last = res.last_row()
assert set(last) == {"买入", "值"}
assert last["买入"] in (0.0, 1.0)
def test_chinese_identifier_and_comment():
df = _df(30)
formula = compile_formula(
"""
{这是注释:N 周期}
周期 := 5;
均线: MA(C, 周期);
"""
)
res = formula.compute(df)
assert res.columns["均线"][0] != res.columns["均线"][-1]
def test_compiled_formula_reusable_across_frames():
f = compile_formula("值: MA(C, 5);")
r1 = f.compute(_df(30, seed=1))
r2 = f.compute(_df(40, seed=2))
assert len(r1.columns["值"]) == 30
assert len(r2.columns["值"]) == 40
def test_compiled_formula_is_dataclass_safe():
"""CompiledFormula 可 pickle(进程池/后台任务传输)。"""
import pickle
f = compile_formula("值: MA(C, 5);")
f2 = pickle.loads(pickle.dumps(f))
assert np.allclose(
f.compute(_df(20)).columns["值"], f2.compute(_df(20)).columns["值"], equal_nan=True
)
# ── 回归:归类收严 / FILTER 副作用 / 递归上限 / REF 负移位(审查修复) ────────
def test_ratio_output_classified_as_value_not_signal():
"""0~1 区间的价格比率是数值列,不是信号列(旧码按 [0,1] 值域兜底误判)。"""
res = compile_formula("比率: C / HHV(C, 20);").compute(_df(40))
assert res.signals == []
assert res.values == ["比率"]
def test_normalized_oscillator_classified_as_value():
"""归一化振荡器(RSI/100)是数值列,不是信号列。"""
res = compile_formula("强度: RSI(C, 14) / 100;").compute(_df(40))
assert res.signals == []
assert res.values == ["强度"]
def test_binary_zero_one_values_still_signal():
"""真正的 0/1 两值输出仍兜底归信号列。"""
res = compile_formula("X: IF(C > MA(C, 5), 1, 0);").compute(_df(40))
assert res.signals == ["X"]
def test_filter_does_not_pollute_series():
"""FILTER(C, N) 不改写输入序列:同公式后续 MA(C, 2) 与未过滤一致。"""
df = _df(30)
res = compile_formula("A: FILTER(C, 2); B: MA(C, 2);").compute(df)
close = pd.to_numeric(df["close"]).to_numpy(dtype=float)
expected = pd.Series(close).rolling(2).mean().to_numpy()
np.testing.assert_allclose(res.columns["B"], expected, equal_nan=True)
def test_deep_paren_nesting_formula_error():
"""超深括号嵌套抛 FormulaError(嵌套过深),而非 RecursionError 逃逸。"""
text = "X: " + "(" * 5000 + "C" + ")" * 5000 + ";"
with pytest.raises(FormulaError, match="嵌套过深"):
compile_formula(text)
def test_deep_unary_chain_formula_error():
"""超长一元运算符链同样受深度上限保护。"""
with pytest.raises(FormulaError, match="嵌套过深"):
compile_formula("X: " + "!" * 5000 + "C;")
def test_moderate_nesting_still_compiles():
"""常规嵌套深度不受上限影响。"""
res = compile_formula("X: -(-(-(C + 1) * 2) + 3);").compute(_df(10))
assert res.columns["X"].shape == (10,)
def test_ref_negative_shift_banned():
"""REF 负移位(未来函数)显式 FormulaError,不再依赖 float 类型巧合。"""
with pytest.raises(FormulaError, match="负移位"):
compile_formula("X: REF(C, -1);").compute(_df(30))
def test_ref_negative_via_expression_banned():
"""负移位经表达式算出(如 0-1)同样被禁。"""
with pytest.raises(FormulaError, match="负移位"):
compile_formula("X: REF(C, 0 - 1);").compute(_df(30))
def test_ref_positive_still_works():
res = compile_formula("X: REF(C, 1);").compute(_df(30))
assert np.isnan(res.columns["X"][0])
assert res.columns["X"][1] == pytest.approx(float(pd.to_numeric(_df(30)["close"]).iloc[0]))
def test_mytt_internal_negative_ref_unaffected():
"""MyTT 库内直调(ICHIMOKU 迟行带)不经公式白名单,负移位仍可用。"""
from easy_tdx.MyTT import REF
close = np.arange(5, dtype=float)
out = REF(close, -1)
assert out[0] == pytest.approx(1.0)