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
+193
View File
@@ -0,0 +1,193 @@
"""公式回测适配器 + REST 端点测试(三通道一致性)。"""
from __future__ import annotations
import time
import numpy as np
import pandas as pd
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from easy_tdx.backtest.formula_strategy import ( # noqa: E402
FormulaStrategyError,
attach_formula_columns,
pick_signal_columns,
run_formula_backtest,
)
from easy_tdx.formula import compile_formula # noqa: E402
def _df(n: int = 300, seed: int = 3, drift: float = 0.002) -> pd.DataFrame:
rng = np.random.default_rng(seed)
close = 10.0 * np.cumprod(1.0 + drift + rng.normal(0, 0.012, n))
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-01", periods=n, freq="B"),
"open": close * 0.999,
"high": close * 1.02,
"low": close * 0.98,
"close": close,
"vol": 1e6,
"amount": close * 1e6,
}
)
_MA_CROSS = "快: MA(C, 5);\n慢: MA(C, 20);\n买入: CROSS(快, 慢);\n卖出: CROSS(慢, 快);"
# ── attach / pick ─────────────────────────────────────────────────────────────
def test_attach_formula_columns():
df = _df(100)
enriched, result = attach_formula_columns(df, compile_formula(_MA_CROSS))
assert {"", "", "买入", "卖出"} <= set(enriched.columns)
assert len(enriched) == len(df)
assert df is not enriched # 副本,不污染原 df
def test_pick_signal_columns_by_hint_and_order():
_, result = attach_formula_columns(_df(60), compile_formula(_MA_CROSS))
buy, sell = pick_signal_columns(result)
assert (buy, sell) == ("买入", "卖出") # 名称提示(买/卖)优先
_, r2 = attach_formula_columns(_df(60), compile_formula("A: C > MA(C, 5); B: C < MA(C, 5);"))
buy2, sell2 = pick_signal_columns(r2)
assert (buy2, sell2) == ("A", "B") # 无提示时按声明顺序
buy3, _ = pick_signal_columns(r2, buy_col="B")
assert buy3 == "B" # 显式指定优先
def test_pick_requires_signal():
from easy_tdx.formula import FormulaResult
result = FormulaResult(columns={"x": np.ones(5)}, values=["x"])
with pytest.raises(FormulaStrategyError, match="布尔信号"):
pick_signal_columns(result)
# ── run_formula_backtest ──────────────────────────────────────────────────────
def test_run_formula_backtest_full_report():
out = run_formula_backtest(_df(300), _MA_CROSS)
assert out["performance"]["total_trades"] >= 1
assert out["formula"]["buy_col"] == "买入"
assert out["formula"]["sell_col"] == "卖出"
assert out["grade"]["grade"] in ("S", "A", "B", "C", "D")
assert 0 <= out["score"]["total"] <= 100
assert "trades" in out and "equity_curve" in out
def test_run_formula_backtest_no_sell_col_holds():
"""只有买入列 → 买入后持有到末尾(1 笔完成交易=0 卖出,持仓中)。"""
out = run_formula_backtest(_df(200, drift=0.004), "买入: CROSS(MA(C,3), MA(C,30));")
assert out["formula"]["sell_col"] is None
assert out["performance"]["total_return"] > 0
def test_run_formula_backtest_accepts_compiled():
compiled = compile_formula(_MA_CROSS)
out = run_formula_backtest(_df(200), compiled)
assert out["formula"]["buy_col"] == "买入"
def test_run_formula_backtest_rejects_no_signal():
with pytest.raises(ValueError, match="布尔信号"):
run_formula_backtest(_df(60), "数值: MA(C, 5);")
def test_run_formula_backtest_json_serializable():
import json
out = run_formula_backtest(_df(150), _MA_CROSS)
json.dumps(out, default=str)
# ── REST 端点 ─────────────────────────────────────────────────────────────────
def _client() -> TestClient:
from easy_tdx.web import create_app
return TestClient(create_app())
def _ohlcv(n: int = 200) -> list[dict[str, object]]:
df = _df(n)
df["datetime"] = df["datetime"].dt.strftime("%Y-%m-%d")
return json_records(df)
def json_records(df: pd.DataFrame) -> list[dict[str, object]]:
import json
return json.loads(df.to_json(orient="records", force_ascii=False))
def test_rest_formula_validate_ok_and_error():
client = _client()
r = client.post(
"/api/v1/formula/validate", json={"text": "金叉: CROSS(MA(C,5), MA(C,20)); 强度: MA(C,5);"}
)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["signals"] == ["金叉"]
assert body["values"] == ["强度"]
r2 = client.post("/api/v1/formula/validate", json={"text": "A := ;"})
assert r2.status_code == 200
assert r2.json()["ok"] is False
assert r2.json()["error"]
def test_rest_formula_compute_inline_ohlcv():
client = _client()
r = client.post(
"/api/v1/formula/compute",
json={"text": "买入: C > REF(C, 1); 值: MA(C, 5);", "ohlcv": _ohlcv(100), "tail": 5},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["signals"] == ["买入"]
assert "last_row" in body and "" in body["last_row"]
assert len(body["recent"]) == 5
def test_rest_formula_backtest_async_task():
client = _client()
r = client.post(
"/api/v1/formula/backtest/run/async",
json={"text": _MA_CROSS, "ohlcv": _ohlcv(300), "cash": 100000.0},
)
assert r.status_code == 202, r.text
task_id = r.json()["task_id"]
for _ in range(200):
st = client.get(f"/api/v1/backtest/tasks/{task_id}").json()
if st["status"] in ("done", "failed"):
break
time.sleep(0.05)
assert st["status"] == "done", st.get("error")
result = st["result"]
assert result["formula"]["buy_col"] == "买入"
assert result["performance"]["total_trades"] >= 1
def test_rest_formula_screen_async_task():
client = _client()
# 两份不同行情:A 上涨(末根 C>REF(C,1) 大概率真)、B 构造末根下跌
up = _ohlcv(120)
r = client.post(
"/api/v1/formula/screen/run/async",
json={"text": "买入: C > REF(C, 1);", "symbols": ["SH:600519"], "ohlcv": up[:0]},
)
# symbols 路径需要行情连接——离线环境预期 400/500(无 mock client
# 这里只验证请求校验(symbols 非空)不炸
assert r.status_code in (400, 500, 202)