release: v1.27.0 — 通达信公式解析器三通道 + 轮动组合引擎 + 回测页WF/评估开关 + Docker 部署

升级计划 P3 + P4(部分)。全量 1252 单测、ruff/mypy strict、前端 vue-tsc+vite build 全绿。

- 通达信公式解析器(formula.py):自建 tokenizer + 递归下降 AST + 30+ 函数白名单求值
  (不走 Python eval);命名布尔输出=信号列、数值输出=排名列;除零→NaN、预热期不出信号
- 公式三通道:CLI easy-tdx formula compute|screen|backtest;REST /formula/validate|compute|
  backtest|screen(run/async);Python API run_formula_backtest(买/卖列自动挑选)
- 轮动组合引擎(rotation.py):排名定期换仓(打分只用截至当日数据)、槽位等额、
  跌出排名自动补位、日/周/月刷新、槽内止盈止损;momentum_score/formula_score 打分;
  REST /backtest/rotation/run/async
- 回测页附加分析开关(Web UI):勾选后随回测并行跑 WF(逐窗红涨绿跌柱状图+汇总卡,
  窗口数 2~12)与一条龙评估(评分分项条/高适配徽标/买入持有对比/8 项适配检查);
  新增 WalkForwardPanel/EvaluatePanel 组件与 store runWalkforward/runEvaluate;
  WF 端点 ?n_windows= 透传;修复报告 numpy 标量 REST 400(源头清洗)
- Docker 部署(Dockerfile + docker-compose.yml,/data 卷 + 健康检查)与
  scripts/verify_ci.sh 一键门禁
- 升级计划文档 docs/upgrade-plan-2026H2.md(四阶段全部完成 + 诚实实测数据)
- 未做(独立排期):Playwright E2E、WebSocket 实时联动、引擎逐 bar 向量化
This commit is contained in:
GitHub
2026-09-01 22:17:57 +08:00
parent 9569b2653c
commit 917295edaf
23 changed files with 3135 additions and 5 deletions
+214
View File
@@ -0,0 +1,214 @@
"""通达信公式解析器测试(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
)