mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 21:34:21 +08:00
升级计划 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 向量化
194 lines
6.5 KiB
Python
194 lines
6.5 KiB
Python
"""公式回测适配器 + 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)
|