mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +08:00
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:
@@ -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
|
||||
@@ -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_masks,auto 将永远走逐 bar"
|
||||
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""AI 解读历史存储测试(llm_history_store.py,v1.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
|
||||
@@ -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
|
||||
@@ -0,0 +1,58 @@
|
||||
"""交易时段判断单元测试(realtime/session.py,v1.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
|
||||
@@ -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 下都被排除
|
||||
@@ -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 的 app(index.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 HTML(SPA 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.html(SPA 刷新场景)。"""
|
||||
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"
|
||||
@@ -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
|
||||
# 锯齿行情应至少出现一次 BUY(trades 为 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
|
||||
Reference in New Issue
Block a user