release: v1.29.0 — 借鉴社区 Fork 六项特性:ZIG 策略 + 交易时段感知刷新 + 120M K 线 + 逐 bar 衍生字段 + 159 龙头池 + 多 Provider LLM 直连

- ZIG 右侧突破回补策略:MyTT 新增 ZIG 之字转向(未来函数,含前视偏差警示);
  波谷启动建仓挂硬止损(OCO)→ 见顶清仓记前高 → 右侧突破回补;路径依赖不实现
  entry_exit_masks(向量化守护测试白名单);含寻优预设网格与 --strategy-file 独立文件
- 交易时段感知刷新:realtime/session.py(09:15~11:30:30 / 13:00~15:05)+
  GET /market/session;看板 30/60/120s 轮询休市自动暂停(三态状态栏 + 开关持久化
  + 手动刷新不受限);SSE/WS 既有会话语义不动
- 120 分钟 K 线:/bars?category=MIN_120(MAC 原生 Period.MINS×120 优先,
  2×60M 相邻聚合兜底,标准客户端上限 400 根);前端周期选择器同步
- 逐 bar 衍生字段:/bars 与 /bars/index 附带 pre_close/change/change_pct/
  amplitude_pct(pre_close≤0.01 兜底防除零)
- 159 只核心龙头池:数据资产取自 Fork(东财全行业龙头名单,四组分层);
  universe=core 接入 screen scan / SignalScanner / StrengthRanker / market strength;
  GET /market/core-leaders + WebUI「龙头池」页(搜索/个股详情)
- 多 Provider LLM 直连:easy_tdx.ai + /llm/*(DeepSeek/通义/智谱/Kimi/MiniMax/
  OpenAI/Claude/Ollama/自定义,openai 兼容 + anthropic 原生双协议);
  配置落盘 ~/.easy_tdx/llm.json(WebUI「AI 设置」页 ⇆ 手工编辑双向兼容,
  文件>环境变量>预设;key 脱敏回显/CLEAR 清除);「AI 解读」后台任务化
  (复用 task_runner,提交+轮询,不占 HTTP 连接);思考型模型空白正文防御
  (reasoning_content 耗尽 max_tokens → 可操作报错;默认 16000);
  AI 解读历史页(自动归档 Prompt/正文/策略上下文 + 去回测带参引导)
- WebUI 加固:SPA fallback 对未知 /api/* 返回 JSON 404(不再 200 HTML 伪装解析错);
  index.html 一律 Cache-Control: no-store(防缓存旧资源引用);路由兜底重定向;
  全局风险提示常驻底栏 + 龙头池/AI 解读针对性免责声明
- 测试:新增 9 个单测文件共 59 例;黄金基线仅新增 zig_breakout 条目(其余零漂移);
  全量 1448 例通过
This commit is contained in:
GitHub
2026-09-02 20:19:17 +08:00
parent eeed45b171
commit 4bd5b5d833
45 changed files with 4275 additions and 70 deletions
+414
View File
@@ -0,0 +1,414 @@
"""LLM 客户端与配置单元测试(ai/llm.py,v1.29)。
覆盖:配置文件读写、环境变量兜底、Provider 预设补齐、api_key 脱敏、
未配置 key 的友好报错、openai/anthropic 两种协议的请求组装与响应解析
HTTP 层 monkeypatch,零真实网络调用)。
"""
from __future__ import annotations
import asyncio
import pytest
from easy_tdx.ai import llm as llm_mod
from easy_tdx.ai.llm import (
PROVIDER_PRESETS,
LlmClient,
LlmConfig,
LlmError,
load_config,
mask_key,
resolve_config,
save_config,
)
@pytest.fixture()
def config_dir(tmp_path, monkeypatch):
monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path))
# 清掉可能存在的兜底环境变量,保证用例间互不干扰
for var in ("LLM_PROVIDER", "LLM_API_KEY", "LLM_BASE_URL", "LLM_MODEL"):
monkeypatch.delenv(var, raising=False)
return tmp_path
class TestConfigFile:
def test_default_when_no_file(self, config_dir):
cfg = load_config()
assert cfg.provider == "deepseek" and cfg.api_key == ""
def test_save_and_load_roundtrip(self, config_dir):
save_config(LlmConfig(provider="zhipu", api_key="sk-test1234567890", model="glm-4.6"))
cfg = load_config()
assert cfg.provider == "zhipu"
assert cfg.api_key == "sk-test1234567890"
assert cfg.model == "glm-4.6"
def test_corrupt_file_returns_default(self, config_dir):
(config_dir / "llm.json").write_text("{not json", encoding="utf-8")
assert load_config().provider == "deepseek" # 不抛异常
def test_env_fills_missing_fields(self, config_dir, monkeypatch):
monkeypatch.setenv("LLM_PROVIDER", "kimi")
monkeypatch.setenv("LLM_API_KEY", "sk-env-key-123456")
cfg = load_config()
assert cfg.provider == "kimi" and cfg.api_key == "sk-env-key-123456"
def test_file_overrides_env(self, config_dir, monkeypatch):
monkeypatch.setenv("LLM_API_KEY", "sk-env")
save_config(LlmConfig(provider="deepseek", api_key="sk-file-12345678"))
assert load_config().api_key == "sk-file-12345678"
class TestResolve:
def test_preset_fills_url_and_model(self, config_dir):
save_config(LlmConfig(provider="qwen"))
r = resolve_config()
assert r.api_url == "https://dashscope.aliyuncs.com/compatible-mode/v1"
assert r.model == "qwen-plus"
def test_explicit_values_win(self, config_dir):
save_config(LlmConfig(provider="deepseek", api_url="http://gw.local/v1", model="my-model"))
r = resolve_config()
assert r.api_url == "http://gw.local/v1" and r.model == "my-model"
def test_custom_requires_url_and_model(self, config_dir):
with pytest.raises(ValueError, match="不完整"):
resolve_config(LlmConfig(provider="custom"))
class TestMaskKey:
def test_mask(self):
assert mask_key("") == ""
assert mask_key("short") == "*****"
assert mask_key("sk-abcdef1234567890") == "sk-***7890"
class TestClient:
def test_missing_key_friendly_error(self, config_dir):
client = LlmClient(LlmConfig(provider="deepseek", api_key=""))
with pytest.raises(LlmError, match="API Key"):
asyncio.run(client.chat("hi"))
def test_ollama_needs_no_key(self, config_dir, monkeypatch):
captured = {}
def fake_post(url, headers, payload, timeout):
captured.update(url=url, headers=headers, payload=payload)
return {"choices": [{"message": {"content": "OK"}}]}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
client = LlmClient(LlmConfig(provider="ollama", timeout=5))
reply = asyncio.run(client.chat("ping"))
assert reply == "OK"
assert captured["url"].startswith("http://localhost:11434/v1/chat/completions")
assert "Authorization" not in captured["headers"] # 免 key 不带鉴权头
def test_openai_style_request_and_parse(self, config_dir, monkeypatch):
captured = {}
def fake_post(url, headers, payload, timeout):
captured.update(url=url, headers=headers, payload=payload)
return {"choices": [{"message": {"content": "解读完成"}}]}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
client = LlmClient(LlmConfig(provider="zhipu", api_key="sk-zhipu-123456789"))
reply = asyncio.run(client.chat("报告…", system_prompt="SYS"))
assert reply == "解读完成"
assert captured["url"] == "https://open.bigmodel.cn/api/paas/v4/chat/completions"
assert captured["headers"]["Authorization"] == "Bearer sk-zhipu-123456789"
msgs = captured["payload"]["messages"]
assert msgs[0] == {"role": "system", "content": "SYS"}
assert msgs[1]["content"] == "报告…"
assert captured["payload"]["model"] == "glm-4-flash"
def test_anthropic_style_request_and_parse(self, config_dir, monkeypatch):
captured = {}
def fake_post(url, headers, payload, timeout):
captured.update(url=url, headers=headers, payload=payload)
return {"content": [{"type": "text", "text": "Claude 回复"}]}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
client = LlmClient(LlmConfig(provider="claude", api_key="sk-ant-123456789"))
reply = asyncio.run(client.chat("hi"))
assert reply == "Claude 回复"
assert captured["url"] == "https://api.anthropic.com/v1/messages"
assert captured["headers"]["x-api-key"] == "sk-ant-123456789"
assert captured["headers"]["anthropic-version"] == "2023-06-01"
assert captured["payload"]["system"] # system 走顶层字段而非 messages
def test_http_error_wrapped(self, config_dir, monkeypatch):
"""_post_json 把 HTTPError(带响应体)包装成带状态码的 LlmError。"""
import io
import urllib.error
def fake_urlopen(req, timeout):
body = io.BytesIO(b'{"error":"bad key"}')
raise urllib.error.HTTPError(
req.full_url, 401, "Unauthorized", hdrs=None, fp=body
)
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", fake_urlopen)
with pytest.raises(LlmError, match="401") as ei:
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 5.0)
assert ei.value.status == 401
assert "bad key" in str(ei.value)
def test_test_endpoint_reports_failure(self, config_dir):
client = LlmClient(LlmConfig(provider="deepseek", api_key=""))
result = asyncio.run(client.test())
assert result["ok"] is False and "API Key" in result["error"]
def test_provider_presets_cover_major_vendors():
vendors = [
"deepseek", "qwen", "zhipu", "kimi", "minimax",
"openai", "claude", "ollama", "custom",
]
for pid in vendors:
assert pid in PROVIDER_PRESETS, pid
assert PROVIDER_PRESETS["claude"].api_style == "anthropic"
assert PROVIDER_PRESETS["ollama"].needs_key is False
assert PROVIDER_PRESETS["zhipu"].base_url.startswith("https://open.bigmodel.cn")
class TestTimeoutSemantics:
def test_default_timeout_is_generous(self):
"""默认超时 ≥120s:非流式接口需等模型生成完整段回复(大报告 1-3 分钟)。"""
assert LlmConfig().timeout >= 120
def test_read_timeout_actionable_message(self, monkeypatch):
"""读超时单独成类报错,文案给出「调大超时」动作而非裸异常。"""
def fake_urlopen(req, timeout):
raise TimeoutError("The read operation timed out")
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", fake_urlopen)
with pytest.raises(LlmError, match="请求超时(180s"):
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 180.0)
def test_connect_timeout_via_urlerror(self, monkeypatch):
"""连接期超时(URLError.reason=TimeoutError)同样走超时文案。"""
import urllib.error
def fake_urlopen(req, timeout):
raise urllib.error.URLError(TimeoutError("timed out"))
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", fake_urlopen)
with pytest.raises(LlmError, match="请求超时"):
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 30.0)
class TestAsyncChatTask:
"""POST /llm/chat/async + GET /llm/chat/tasks/{id} 的提交-轮询闭环。"""
@pytest.fixture(autouse=True)
def _fresh_history_store(self, config_dir):
"""每个用例用独立的 llm_history.db(模块级单例绑定了首个用例的临时目录)。"""
import easy_tdx.web.llm_history_store as hs
hs._store = None
yield
hs._store = None
def test_submit_and_poll_done(self, config_dir, monkeypatch):
import time
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
def fake_chat(self, prompt, system_prompt=None):
async def _slow():
await asyncio.sleep(0.05)
return f"解读:{prompt[:8]}"
return _slow()
monkeypatch.setattr(LlmClient, "chat", fake_chat)
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
r = c.post("/api/v1/llm/chat/async", json={"prompt": "整份回测报告…" * 10})
assert r.status_code == 202, r.text
task_id = r.json()["task_id"]
assert r.json()["status"] in ("pending", "running")
state = None
for _ in range(50):
state = c.get(f"/api/v1/llm/chat/tasks/{task_id}").json()
if state["status"] in ("done", "failed"):
break
time.sleep(0.05)
assert state["status"] == "done", state
assert state["result"]["reply"].startswith("解读:")
assert state["result"]["elapsed"] >= 0.0
def test_task_failure_surfaces_error(self, config_dir, monkeypatch):
import time
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
def fake_chat(self, prompt, system_prompt=None):
async def _boom():
raise LlmError("请求超时(180s 内无响应)")
return _boom()
monkeypatch.setattr(LlmClient, "chat", fake_chat)
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
task_id = c.post("/api/v1/llm/chat/async", json={"prompt": "x"}).json()["task_id"]
state = None
for _ in range(50):
state = c.get(f"/api/v1/llm/chat/tasks/{task_id}").json()
if state["status"] in ("done", "failed"):
break
time.sleep(0.05)
assert state["status"] == "failed"
assert "请求超时" in state["error"]
def test_unknown_task_rejected(self, config_dir):
"""未知 task → 400(与 GET /backtest/tasks/{id} 的 ValueError 约定一致)。"""
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
r = c.get("/api/v1/llm/chat/tasks/nonexistent")
assert r.status_code == 400
assert "未知任务" in r.json()["detail"]
def test_async_success_records_history(self, config_dir, monkeypatch):
"""异步解读成功 → 自动落历史库(含策略上下文),供历史页查询。"""
import time as _time
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
async def fake_chat(self, prompt, system_prompt=None):
return "解读正文"
monkeypatch.setattr(LlmClient, "chat", fake_chat)
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
ctx = {
"strategy": "ma_cross",
"strategy_label": "双均线交叉",
"symbol": "600519",
"category": "DAY",
"params": {"fast": 5, "slow": 20},
"start_date": "2024-01-01",
"end_date": "2025-01-01",
}
tid = c.post("/api/v1/llm/chat/async",
json={"prompt": "报告", "context": ctx}).json()["task_id"]
for _ in range(50):
st = c.get(f"/api/v1/llm/chat/tasks/{tid}").json()
if st["status"] in ("done", "failed"):
break
_time.sleep(0.05)
assert st["status"] == "done", st
hist = c.get("/api/v1/llm/history").json()
assert hist["count"] >= 1
item = hist["items"][0]
assert item["reply"] == "解读正文"
assert item["strategy"] == "ma_cross" and item["symbol"] == "600519"
assert item["params"] == {"fast": 5, "slow": 20}
# 删除一条
r = c.delete(f"/api/v1/llm/history/{item['id']}")
assert r.json()["ok"] is True
assert c.get("/api/v1/llm/history").json()["count"] == hist["count"] - 1
def test_async_failure_not_recorded(self, config_dir, monkeypatch):
"""解读失败 → 不落历史(历史只归档成功解读)。"""
import time as _time
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
async def fake_chat(self, prompt, system_prompt=None):
raise LlmError("boom")
monkeypatch.setattr(LlmClient, "chat", fake_chat)
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
tid = c.post("/api/v1/llm/chat/async", json={"prompt": "x"}).json()["task_id"]
for _ in range(50):
st = c.get(f"/api/v1/llm/chat/tasks/{tid}").json()
if st["status"] in ("done", "failed"):
break
_time.sleep(0.05)
assert st["status"] == "failed"
assert c.get("/api/v1/llm/history").json()["count"] == 0
def test_submit_rejects_incomplete_config(self, config_dir):
"""custom 未填 url/model:提交期即 400(不等任务跑起来才失败)。"""
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
r = c.post(
"/api/v1/llm/chat/async",
json={"prompt": "x", "override": {"provider": "custom"}},
)
assert r.status_code == 400
assert "不完整" in r.json()["detail"]
class TestThinkingModelBlankContent:
"""思考型模型正文空白(reasoning_content 耗尽 max_tokens)的防御。
v1.29.1 实测:GLM-5.x 思考链计入 max_tokens,预算耗尽时 content 为
空白——truthy 但渲染为空(状态条报成功、正文空白)。解析层必须把
这类响应转成可操作的错误,绝不返回空白字符串。
"""
def _client(self, max_tokens: int = 4000) -> LlmClient:
return LlmClient(LlmConfig(provider="zhipu", api_key="sk-x-1234567890",
model="glm-5.3-flash", max_tokens=max_tokens))
def test_normal_content_wins_over_reasoning(self):
msg = {"content": "正文", "reasoning_content": "思考…", "role": "assistant"}
assert self._client()._extract_reply_openai(msg, "stop") == "正文"
def test_blank_content_with_reasoning_raises_actionable(self):
msg = {"content": " ", "reasoning_content": "思考" * 500, "role": "assistant"}
with pytest.raises(LlmError, match="思考链.*4000.*16000"):
self._client()._extract_reply_openai(msg, "length")
def test_null_content_with_reasoning(self):
msg = {"content": None, "reasoning_content": "思考", "role": "assistant"}
with pytest.raises(LlmError, match="思考链"):
self._client()._extract_reply_openai(msg, "length")
def test_blank_content_without_reasoning(self):
with pytest.raises(LlmError, match="content 为空"):
self._client()._extract_reply_openai({"content": ""}, "stop")
def test_length_finish_without_content(self):
with pytest.raises(LlmError, match="截断"):
self._client()._extract_reply_openai({"content": ""}, "length")
def test_whitespace_reply_rejected_end_to_end(self, config_dir, monkeypatch):
"""端到端:伪 HTTP 返回空白正文 → chat() 抛错(任务态 failed 而非 done 空回复)。"""
def fake_post(url, headers, payload, timeout):
blank = chr(10) + " " + chr(10)
return {"choices": [{"message": {"content": blank, "reasoning_content": "r"},
"finish_reason": "length"}]}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
with pytest.raises(LlmError, match="思考链"):
asyncio.run(self._client().chat("报告"))
def test_default_max_tokens_generous_for_thinking(self):
assert LlmConfig().max_tokens >= 16000
+11 -1
View File
@@ -300,10 +300,20 @@ def test_auto_falls_back_on_mask_shape_mismatch() -> None:
def test_vector_path_actually_used_for_builtins() -> None:
"""默认 signal_path='auto' 下内置策略确实走了向量化(防止回退被掩盖)。"""
"""默认 signal_path='auto' 下内置策略确实走了向量化(防止回退被掩盖)。
例外白名单:信号依赖路径状态(无法用静态掩码等价表达)的策略,
引擎对它们走逐 bar 回放(与 next() 完全一致),属设计而非回退。
"""
from easy_tdx.backtest.strategy import Strategy as Base
# zig_breakout 的 _breakout_level(见顶清仓后记录的前高)随持仓路径
# 变化,掩码不可表达;见 builtin.py 该策略的注释
path_dependent = {"zig_breakout"}
for name in get_registry().names():
if name in path_dependent:
continue
strat_cls = get_registry().get(name).strategy_cls
assert strat_cls.entry_exit_masks is not Base.entry_exit_masks, (
f"{name} 未实现 entry_exit_masksauto 将永远走逐 bar"
+84
View File
@@ -0,0 +1,84 @@
"""120 分钟 K 线重采样与逐 bar 衍生字段单元测试(bars.py 纯函数,v1.29)。"""
from __future__ import annotations
import numpy as np
import pandas as pd
from easy_tdx.web.routers.bars import (
_MIN_120_ALIASES,
_attach_derived,
_resample_pairs,
)
def _minute_df(n: int = 5) -> pd.DataFrame:
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-02 10:30", periods=n, freq="60min"),
"open": [10, 11, 12, 13, 14][:n],
"close": [10.5, 11.5, 12.5, 13.5, 14.5][:n],
"high": [10.8, 11.9, 12.9, 13.9, 14.9][:n],
"low": [9.9, 10.9, 11.9, 12.9, 13.9][:n],
"vol": [100, 200, 300, 400, 500][:n],
"amount": [1000, 2000, 3000, 4000, 5000][:n],
}
)
class TestResamplePairs:
def test_odd_count_drops_oldest(self):
"""奇数根丢最旧一根,保最新数据两两对齐。"""
r = _resample_pairs(_minute_df(5), 10)
assert len(r) == 2
row = r.iloc[0] # 原 bar1+bar2
assert row["open"] == 11 and row["close"] == 12.5
assert row["high"] == 12.9 and row["low"] == 10.9 # max/min
assert row["vol"] == 500 and row["amount"] == 5000 # sum
assert str(row["datetime"]) == "2024-01-02 12:30:00" # 后一根时间
def test_even_count_keeps_all(self):
r = _resample_pairs(_minute_df(4), 10)
assert len(r) == 2
assert r.iloc[0]["open"] == 10 # 从 bar0 起
def test_count_trims_oldest_side(self):
r = _resample_pairs(_minute_df(4), 1)
assert len(r) == 1 and r.iloc[0]["close"] == 13.5 # tail 保留
def test_empty_passthrough(self):
assert _resample_pairs(pd.DataFrame(), 10).empty
assert _resample_pairs(None, 10) is None # type: ignore[arg-type]
def test_missing_optional_columns(self):
df = _minute_df(4).drop(columns=["amount"])
r = _resample_pairs(df, 10)
assert "amount" not in r.columns and len(r) == 2
class TestAttachDerived:
def test_basic_fields(self):
d = _attach_derived(_minute_df(3))
assert {"pre_close", "change", "change_pct", "amplitude_pct"} <= set(d.columns)
assert d.iloc[0]["pre_close"] == 10 # 首根 = 本根开盘
assert d.iloc[0]["change"] == 0.5 and d.iloc[0]["change_pct"] == 5.0
assert d.iloc[1]["pre_close"] == 10.5
assert d.iloc[1]["change_pct"] == round((11.5 / 10.5 - 1) * 100, 4)
assert abs(d.iloc[0]["amplitude_pct"] - (10.8 - 9.9) / 10 * 100) < 1e-6
def test_nonpositive_preclose_floor(self):
"""QFQ 复权后前收为 0/负时按 0.01 兜底,不产生 inf。"""
df = _minute_df(3)
df.loc[0, "close"] = -5.0
d = _attach_derived(df)
assert np.isfinite(d["change_pct"]).all()
assert d.iloc[1]["change_pct"] == round((11.5 / 0.01 - 1) * 100, 4)
def test_empty_and_missing_close(self):
assert _attach_derived(pd.DataFrame()).empty
df = pd.DataFrame({"open": [1.0]})
assert "pre_close" not in _attach_derived(df).columns
def test_min_120_aliases():
assert _MIN_120_ALIASES == {"MIN_120", "120M", "120MIN"}
+69
View File
@@ -0,0 +1,69 @@
"""AI 解读历史存储测试(llm_history_store.pyv1.29)。"""
from __future__ import annotations
import pytest
from easy_tdx.web.llm_history_store import LlmHistoryRecord, LlmHistoryStore
@pytest.fixture()
def store(tmp_path, monkeypatch):
monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path))
return LlmHistoryStore()
def _rec(**kw) -> LlmHistoryRecord:
base = dict(provider="zhipu", model="glm-5.3-flash", prompt="报告…", reply="解读…")
base.update(kw)
return LlmHistoryRecord(**base)
class TestLlmHistoryStore:
def test_add_and_list_newest_first(self, store):
store.add(_rec(reply="第一条"))
store.add(_rec(reply="第二条"))
items = store.list_all()
assert len(items) == 2
assert items[0].reply == "第二条" # 倒序
assert items[0].id is not None and items[0].created_at
def test_context_roundtrip(self, store):
store.add(
_rec(
strategy="zig_breakout",
strategy_label="ZIG 右侧突破回补",
symbol="600519",
category="DAY",
params={"zig_delta": 5.0, "confirm_pct": 2.0},
start_date="2024-01-01",
end_date="2025-01-01",
)
)
it = store.list_all()[0]
assert it.strategy == "zig_breakout" and it.symbol == "600519"
assert it.params == {"zig_delta": 5.0, "confirm_pct": 2.0} # JSON 往返保真
assert it.start_date == "2024-01-01"
def test_corrupt_params_json_tolerated(self, store, tmp_path):
store.add(_rec())
# 手工写坏 params 列,读取不应抛异常
import sqlite3
with sqlite3.connect(store.db_path) as conn:
conn.execute("UPDATE llm_history SET params = '{broken'")
assert store.list_all()[0].params == {}
def test_delete_and_clear(self, store):
a = store.add(_rec())
store.add(_rec())
assert store.delete(a.id) is True
assert store.delete(a.id) is False # 重复删除
assert len(store.list_all()) == 1
assert store.clear() == 1
assert store.list_all() == []
def test_limit(self, store):
for i in range(5):
store.add(_rec(reply=f"r{i}"))
assert len(store.list_all(limit=3)) == 3
+66
View File
@@ -0,0 +1,66 @@
"""MyTT.ZIG 之字转向指标单元测试(借鉴 Fork 移植,v1.29)。
覆盖:边界输入(空/单根/零阈值)、单调序列恒等、V 型反转拐点标定、
阈值两种写法(5 与 0.05)等价、输出形状与有限性。
"""
from __future__ import annotations
import numpy as np
from easy_tdx.MyTT import ZIG
def test_zig_empty_and_single():
assert ZIG(np.array([]), 10).size == 0
single = ZIG(np.array([42.0]), 10)
assert single.shape == (1,) and single[0] == 42.0
def test_zig_zero_threshold_returns_self():
s = np.array([1.0, 5.0, 2.0, 8.0])
assert np.array_equal(ZIG(s, 0), s)
def test_zig_monotonic_series_identity():
"""单调序列无拐点,ZIG 退化为自身(RD 保留 3 位小数)。"""
line = np.linspace(1.0, 2.0, 50)
assert np.allclose(ZIG(line, 10), line, atol=1e-3)
def test_zig_v_shape_trough():
"""V 型反转:谷底被标为拐点,前后两段各自线性插值。"""
v = np.concatenate([np.linspace(100.0, 80.0, 30), np.linspace(80.0, 120.0, 40)])
z = ZIG(v, 5)
assert z.shape == v.shape
assert np.isfinite(z).all()
assert abs(z[0] - 100) < 0.01
assert abs(z[-1] - 120) < 0.01
# 谷底(两个 80 中的后者,上升段起点)被精确对齐
assert abs(z.min() - 80) < 0.01
assert abs(z[30] - 80) < 0.01
# 拐点间线性:下降段任意点是两端点的线性插值
assert abs(z[15] - (100 + 80) / 2) < 0.01
def test_zig_threshold_forms_equivalent():
s = 100 + 10 * np.sin(np.arange(80) / 6.0)
assert np.allclose(ZIG(s, 5), ZIG(s, 0.05), atol=1e-9)
def test_zig_zigzag_alternating_peaks():
"""标准锯齿:每个预设峰谷都应成为拐点(ZIG 值在拐点处触及其价格)。"""
seg = [10.0, 13.0, 10.0, 13.0, 10.0, 13.0] # ±30% 摆动,阈值 10% 必转向
s = np.array(seg)
z = ZIG(s, 10)
for i, price in enumerate(seg):
assert abs(z[i] - price) < 0.01, f"锯齿序列每根都是拐点: idx={i}"
def test_zig_noisy_series_shape():
rng = np.random.default_rng(7)
s = 100 + np.cumsum(rng.normal(0, 1.5, 200))
z = ZIG(s, 12)
assert z.shape == s.shape
assert np.isfinite(z).all()
assert z.min() >= s.min() - 1e-3 and z.max() <= s.max() + 1e-3
+58
View File
@@ -0,0 +1,58 @@
"""交易时段判断单元测试(realtime/session.pyv1.29)。
覆盖:窗口边界(09:15/11:30:30/13:00/15:05)、午休、周末、
盘前盘后、session_info 响应结构。
"""
from __future__ import annotations
from datetime import datetime
from easy_tdx.realtime.session import SESSION_WINDOWS, is_trading_time, session_info
def _dt(s: str) -> datetime:
# 2026-09-02 是周三(盘中日)
return datetime.strptime(f"2026-09-02 {s}", "%Y-%m-%d %H:%M:%S")
class TestIsTradingTime:
def test_weekday_morning_session(self):
assert is_trading_time(_dt("09:15:00")) # 集合竞价起
assert is_trading_time(_dt("10:30:00"))
assert is_trading_time(_dt("11:30:30")) # 窗口含端
def test_lunch_break_excluded(self):
assert not is_trading_time(_dt("11:31:00"))
assert not is_trading_time(_dt("12:30:00"))
assert not is_trading_time(_dt("12:59:59"))
def test_afternoon_session(self):
assert is_trading_time(_dt("13:00:00"))
assert is_trading_time(_dt("14:30:00"))
assert is_trading_time(_dt("15:05:00")) # 收盘竞价缓冲端点
assert not is_trading_time(_dt("15:05:01"))
def test_pre_and_post_market(self):
assert not is_trading_time(_dt("09:14:59"))
assert not is_trading_time(_dt("08:00:00"))
assert not is_trading_time(_dt("22:00:00"))
def test_weekend_rejected(self):
# 2026-09-05 周六 / 2026-09-06 周日,取盘中时间也应为 False
assert not is_trading_time(datetime(2026, 9, 5, 10, 0))
assert not is_trading_time(datetime(2026, 9, 6, 14, 0))
class TestSessionInfo:
def test_shape(self):
info = session_info(_dt("10:00:00"))
assert info["is_trading_time"] is True
assert info["weekday"] == 2 # 周三
assert len(info["sessions"]) == len(SESSION_WINDOWS)
assert info["session_desc"] == "09:15~11:30, 13:00~15:05"
assert "T" in info["server_time"] # isoformat
def test_closed(self):
info = session_info(datetime(2026, 9, 5, 10, 0)) # 周六
assert info["is_trading_time"] is False
+70
View File
@@ -0,0 +1,70 @@
"""核心龙头池(screen/universe.py)与 universe="core" 过滤测试(v1.29)。"""
from __future__ import annotations
from pathlib import Path
from easy_tdx.screen.scanner import SignalScanner
from easy_tdx.screen.strength import StrengthRanker
from easy_tdx.screen.universe import CORE_LEADERS, core_leader_codes
class TestCoreLeadersData:
def test_count_159_and_unique(self):
assert len(CORE_LEADERS) == 159
assert len(set(CORE_LEADERS)) == 159
def test_all_six_digit_ashare_codes(self):
for code in CORE_LEADERS:
assert len(code) == 6 and code.isdigit(), code
assert code[0] in ("0", "3", "6"), f"非沪深 A 股代码: {code}"
def test_known_leaders_present(self):
assert CORE_LEADERS.get("600519") == "贵州茅台"
assert CORE_LEADERS.get("300750") == "宁德时代"
assert CORE_LEADERS.get("002415") == "海康威视"
def test_core_leader_codes(self):
codes = core_leader_codes()
assert isinstance(codes, set) and len(codes) == 159
def _make_vipdoc(tmp_path: Path) -> Path:
"""构造假 vipdoc_detect_security_type 只看文件名,内容无关)。
名单内:600519/300750/002415;名单外:600000/002999;指数:399001。
"""
vipdoc = tmp_path / "vipdoc"
for exchange, codes in [
("sh", ["600519", "600000"]),
("sz", ["300750", "002415", "002999", "399001"]),
]:
lday = vipdoc / exchange / "lday"
lday.mkdir(parents=True)
for code in codes:
(lday / f"{exchange}{code}.day").write_bytes(b"")
return vipdoc
_EXPECTED_CORE = {"600519", "300750", "002415"}
class TestUniverseCoreFilter:
def test_scanner_core_filters_to_leaders(self, tmp_path):
scanner = SignalScanner(
strategy_cls=object, # _collect_files 不实例化策略
vipdoc_path=_make_vipdoc(tmp_path),
)
codes = {code for _, _, code in scanner._collect_files("core")}
assert codes == _EXPECTED_CORE # 名单外 600000/002999 与指数 399001 均被排除
def test_strength_core_filters_to_leaders(self, tmp_path):
ranker = StrengthRanker(vipdoc_path=_make_vipdoc(tmp_path))
codes = {code for _, _, code in ranker._collect_files("core")}
assert codes == _EXPECTED_CORE
def test_scanner_all_still_includes_non_leaders(self, tmp_path):
scanner = SignalScanner(strategy_cls=object, vipdoc_path=_make_vipdoc(tmp_path))
codes = {code for _, _, code in scanner._collect_files("all")}
assert codes == {"600519", "600000", "300750", "002415", "002999"}
assert "399001" not in codes # 指数在任意 universe 下都被排除
+84
View File
@@ -0,0 +1,84 @@
"""SPA fallback 的 /api 守卫测试(v1.29)。
背景(实测踩坑):未注册的 ``/api/*`` 路径会掉进 StaticFiles 的 SPA
fallback 返回 200 + index.html——前端 ``resp.ok`` 为 true、``resp.json()``
抛 ``Unexpected token '<'``,把"服务是旧版本/端点不存在"伪装成前端解析
错误。守护:未知 /api 路径必须返回 JSON 404,前端路由路径仍回 index.html。
"""
from __future__ import annotations
from pathlib import Path
import pytest
fastapi_testclient = pytest.importorskip("fastapi.testclient")
def _make_app_with_ui(tmp_path: Path):
"""带假前端 dist 的 appindex.html + 一个资产文件)。"""
from easy_tdx.web.app import _create_app
dist = tmp_path / "dist"
dist.mkdir()
(dist / "index.html").write_text("<!doctype html><title>spa</title>", encoding="utf-8")
(dist / "test-asset.txt").write_text("asset", encoding="utf-8")
import easy_tdx.web.app as app_mod
original = app_mod._resolve_web_dist_dir
app_mod._resolve_web_dist_dir = lambda: dist # type: ignore[assignment]
try:
return _create_app(enable_mac=False, enable_ui=True)
finally:
app_mod._resolve_web_dist_dir = original # type: ignore[assignment]
@pytest.fixture()
def client(tmp_path):
app = _make_app_with_ui(tmp_path)
with fastapi_testclient.TestClient(app) as c:
yield c
def test_unknown_api_path_returns_json_404(client):
"""未注册的 /api 路径:JSON 404,绝不能是 200 HTMLSPA fallback)。"""
resp = client.get("/api/v1/llm/config-not-exist")
assert resp.status_code == 404
assert resp.headers["content-type"].startswith("application/json")
assert "<!doctype" not in resp.text.lower()
def test_unknown_api_post_returns_404_not_html(client):
resp = client.post("/api/v1/no-such-endpoint", json={})
assert resp.status_code in (404, 405)
assert "<!doctype" not in resp.text.lower()
def test_spa_route_still_serves_index(client):
"""前端路由路径(如 /llm)仍回 index.htmlSPA 刷新场景)。"""
resp = client.get("/llm")
assert resp.status_code == 200
assert "spa" in resp.text
def test_static_asset_served(client):
assert client.get("/test-asset.txt").text == "asset"
def test_registered_api_route_unaffected(client):
"""已注册端点正常返回 JSON(守卫只拦未匹配路径)。"""
resp = client.get("/api/v1/market/session")
assert resp.status_code == 200
assert resp.json()["session_desc"]
def test_index_html_no_store(client):
"""入口 index.html 永远 no-store:防浏览器缓存旧资源引用(强刷仍见旧版)。"""
for path in ("/", "/llm", "/ai-history"):
resp = client.get(path)
assert resp.status_code == 200
assert resp.headers.get("cache-control") == "no-store", path
# 哈希文件名的静态资产不受影响(默认缓存语义)
asset = client.get("/test-asset.txt")
assert asset.headers.get("cache-control") != "no-store"
+97
View File
@@ -0,0 +1,97 @@
"""zig_breakout 内置策略单元测试(借鉴 Fork 移植,v1.29)。
覆盖:注册表登记与参数 schema、合成锯齿行情能产生交易、
止损单挂在买入信号上(OCO bracket)、寻优预设网格登记。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategies import get_registry
from easy_tdx.backtest.strategies.presets import STRATEGY_PRESETS
def _zigzag_df(n: int = 300, seed: int = 42) -> pd.DataFrame:
"""先跌后大涨再回调的合成行情(触发 ZIG 波谷启动与见顶清仓)。"""
rng = np.random.default_rng(seed)
trend = np.concatenate(
[
np.linspace(100, 80, n // 3),
np.linspace(80, 130, n * 2 // 5),
np.linspace(130, 110, n - n // 3 - n * 2 // 5),
]
)
close = trend + rng.normal(0, 0.8, len(trend))
high = close + rng.uniform(0, 1.5, len(trend))
low = close - rng.uniform(0, 1.5, len(trend))
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-01", periods=len(trend), freq="B"),
"open": close + rng.normal(0, 0.3, len(trend)),
"high": high,
"low": low,
"close": close,
"vol": rng.integers(1e6, 5e6, len(trend)).astype(float),
"amount": close * 1e6,
}
)
def test_registry_entry_and_params():
entry = get_registry().get("zig_breakout")
assert entry.label == "ZIG 右侧突破回补"
names = [p.name for p in entry.params]
assert names == ["zig_delta", "confirm_pct", "hhv_period", "stop_loss_pct"]
defaults = {p.name: p.default for p in entry.params}
assert defaults == {
"zig_delta": 10.0,
"confirm_pct": 2.0,
"hhv_period": 20,
"stop_loss_pct": 3.0,
}
def test_build_validates_params():
entry = get_registry().get("zig_breakout")
inst = entry.build({"zig_delta": 5})
assert inst.p["zig_delta"] == 5.0 and inst.p["hhv_period"] == 20
with pytest.raises(ValueError):
entry.build({"zig_delta": -1}) # 低于 min_value
def test_strategy_trades_and_bracket_stop():
entry = get_registry().get("zig_breakout")
result = BacktestEngine(entry.build(), cash=1_000_000).run(_zigzag_df())
assert len(result.trades) > 0
# 锯齿行情应至少出现一次 BUYtrades 为 DataFrame
assert (result.trades["direction"] == "BUY").any()
assert (result.trades["direction"] == "SELL").any()
def test_strategy_file_variant_loadable():
"""strategies/zig_breakout.py 独立文件可供 --strategy-file 加载。"""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "strategies" / "zig_breakout.py"
spec = importlib.util.spec_from_file_location("zig_file_test", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
result = BacktestEngine(mod.ZigBreakoutStrategy(), cash=1_000_000).run(_zigzag_df())
assert len(result.trades) > 0
assert (result.trades["direction"] == "BUY").any()
def test_preset_grid_registered():
assert "zig_breakout" in STRATEGY_PRESETS
grid = STRATEGY_PRESETS["zig_breakout"]
assert "zig_delta" in grid and "confirm_pct" in grid
# 笛卡尔积不超过寻优器上限
n = 1
for vals in grid.values():
n *= len(vals)
assert n <= 200