From a9dabe468239eaab2892c1ca53359c8bd3087b58 Mon Sep 17 00:00:00 2001 From: GitHub Date: Wed, 2 Sep 2026 20:23:27 +0800 Subject: [PATCH] =?UTF-8?q?docs+style:=20README=20=E9=9A=86=E9=87=8D?= =?UTF-8?q?=E4=BB=8B=E7=BB=8D=20v1.29=20AI=20=E6=B7=B1=E5=BA=A6=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=EF=BC=88=E8=AE=A4=E7=9F=A5=E8=87=AA=E7=94=B1=E5=8F=99?= =?UTF-8?q?=E4=BA=8B=EF=BC=89=EF=BC=9Bruff=20format=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +++++++++- src/easy_tdx/ai/llm.py | 3 +-- src/easy_tdx/realtime/session.py | 3 +-- src/easy_tdx/web/routers/bars.py | 6 ++++- src/easy_tdx/web/routers/llm.py | 22 ++++++++++------- tests/unit/test_ai_llm.py | 42 +++++++++++++++++++++++--------- 6 files changed, 63 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b45f3c0..7193534 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,21 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普 **34个技术指标**(MACD、KDJ、RSI、BOLL……连”捉妖大师”和”30日乖离率信号”都给你算好)开箱即用。 **缠论分析**(笔、中枢、买卖点、背驰)一键出结果——你不再需要手画分型、猜线段。 -**内置回测引擎**——写个策略文件,一行命令跑回测,18 个经典策略自带,多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。**防过拟合验证链**(v1.25 新增)——Walk-Forward 七窗样本外验证(每窗独立开仓)、训练/验证/测试三段适配性体检(8 项可解释检查 + 「高适配」标记)、0-100 综合评分、多 seed 晋级门槛、买入持有基准对比,回测页勾选即出报告——「回测好」升级为「样本外也好」。**深度风险报告**(v1.28 新增)——绩效指标扩到 25 项(新增 Ulcer 指数、95% 日 VaR/CVaR、SQN 系统质量数、最大连胜/连亏);基准对比从超额收益一个数升级为 α/β/信息比率/跟踪误差四件套(「涨的时候跟不跟得上大盘、跌的时候抗不抗跌」一眼可读);策略一行带移动止损与百分比止损止盈(`self.buy(trail_stop=0.08)` 涨得越高止损线跟得越高,OCO 任一触发即失效);引擎行为由黄金测试锁定——19 个内置策略的指标基线进 CI,撮合/费率逻辑任何静默漂移当场爆红。 +**内置回测引擎**——写个策略文件,一行命令跑回测,19 个经典策略自带,多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。**防过拟合验证链**(v1.25 新增)——Walk-Forward 七窗样本外验证(每窗独立开仓)、训练/验证/测试三段适配性体检(8 项可解释检查 + 「高适配」标记)、0-100 综合评分、多 seed 晋级门槛、买入持有基准对比,回测页勾选即出报告——「回测好」升级为「样本外也好」。**深度风险报告**(v1.28 新增)——绩效指标扩到 25 项(新增 Ulcer 指数、95% 日 VaR/CVaR、SQN 系统质量数、最大连胜/连亏);基准对比从超额收益一个数升级为 α/β/信息比率/跟踪误差四件套(「涨的时候跟不跟得上大盘、跌的时候抗不抗跌」一眼可读);策略一行带移动止损与百分比止损止盈(`self.buy(trail_stop=0.08)` 涨得越高止损线跟得越高,OCO 任一触发即失效);引擎行为由黄金测试锁定——19 个内置策略的指标基线进 CI,撮合/费率逻辑任何静默漂移当场爆红。 **行情终端 Web UI**(v1.23 重大升级)——`easy-tdx serve` 一条命令,浏览器秒变专业看盘终端:**市场看板**(五大指数实时推送 + 迷你分时、涨跌统计、四维情绪雷达、全市场涨跌分布直方图、涨停雷达、行业/概念热冷榜、涨幅/跌幅/成交额/换手四联排行榜、两市异动雷达)、**自选行情**(输入 6 位代码即加,全表 SSE 实时刷新、行内迷你分时)、**个股详情弹窗**(五档盘口 + 1/3/5 日分时 + 带 MA/BOLL/MACD/KDJ/RSI 指标切换的日 K,一键加自选、一键全策略寻优)、**板块下钻**(行业/概念弹窗看板块走势 + 成分股涨跌榜直达个股)。实时推送采用 SSE 单循环轮询 fan-out 架构(盘中 8 秒一拍,无人订阅自动休眠),自选持久化 SQLite。展示层设计对标专业终端(暗色、红涨绿跌、高信息密度),数据全部来自通达信协议直连——不花一分钱。 +**🤖 AI 深度辅助:让 AI 读报告、陪你复盘(v1.29 重大升级)**——量化工具最大的门槛从来不是取数,而是**看不懂报告**:25 项绩效指标、Walk-Forward 逐窗、α/β/信息比率、S-D 评级……对普通人就是天书。v1.28.1 的「AI 解读 Prompt 导出」要把提示词复制出去手动粘贴;v1.29 起升级为**配置你自己的模型、一键直接解读**: + +- **AI 设置页**——9 家 Provider 开箱即用(DeepSeek / 通义千问 / **智谱 GLM** / Kimi / MiniMax / OpenAI / Claude / Ollama 本地免 Key / 自定义任意兼容网关),API 地址与模型可改,连通性一键测试。配置落盘 `~/.easy_tdx/llm.json`,环境变量可兜底——**你的 Key 只属于你**,本工具不代管、不代理、不经手第三方。 +- **✨ 一键直接解读**——回测页把整份报告(配置 + 25 项指标 + WF + 评估 + 评级 + 成交摘要)组装成提示词,点击后作为**后台任务**发给你配置的模型,前端轮询进度、实时显示已耗时,不占连接、刷新页面不丢结果。模型回来的是「做了十几年量化的老手朋友」口吻的通俗解读、改进建议与 0-10 信心分——**把读报告这件事外包给 AI,你只做决策**。 +- **AI 解读历史**——每次解读自动归档(提问 + 正文 + 当时的策略/参数/标的/区间),随时翻旧账对比「同一个策略上次 AI 怎么说」;每条带「→ 去回测(带参数)」一键复现当时场景,看完解读直接回去调参。 +- **为 AI 时代做的工程加固**——思考型模型(GLM-5.x / DeepSeek-R1 等)思考链计入 token 预算导致正文空白?显式拦截并给出可操作提示;模型慢?读超时可调、后台任务不超时;解读内容一律标注「AI 生成,仅供参考,不构成投资建议」。 + +**这一步的意义**:取数自由(v1.0)→ 回测自由(v1.17)→ 防过拟合(v1.25)→ **认知自由(v1.29)**。前几步解决「数据与工具的不平等」,这一步解决「解读能力的不平等」——机构有研究员写晨报,你现在有自己的 AI 分析师。仍是 MIT 开源、零注册、零平台费,唯一成本是你自己模型的调用费(用 Ollama 本地模型连这个都是零)。 + +**v1.29 同场加映**(借鉴社区 Fork [swimmingaaron/easy_tdx](https://github.com/swimmingaaron/easy_tdx) 的好想法,逐项甄别后移植):**ZIG 右侧突破回补策略**(MyTT 新增之字转向指标,波谷建仓带硬止损 + 见顶记前高 + 右侧突破回补,对冲未来函数的前视偏差);**交易时段感知刷新**(看板轮询休市自动暂停、三态状态栏、手动刷新不受限,不再半夜空转刷数据);**120 分钟 K 线**(MAC 原生两级降级获取)与**逐 bar 涨跌幅/振幅字段**(`pre_close`/`change_pct`/`amplitude_pct` 直接返回);**159 只核心龙头池**(东财全行业龙头名单 → `universe=core` 一键只扫龙头,配「龙头池」页面;名单仅为扫描范围,不构成任何推荐);全站**风险提示与免责声明**常驻。WebUI 工程加固:未知 API 路径返回 JSON 404 而非 HTML、入口页 `no-store` 防缓存旧版——更新即所见。 + 行情终端 Web UI(v1.23):市场看板 / 自选行情 / 个股与板块详情 **回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。v1.28.1 起新手与 AI 辅助两连击:三个报告框内置**名词解释折叠帮助**(33 个词条讲清每项指标是什么、怎么算、怎么看,默认收起点击展开,重点粗体、阈值橙色、细节细体);回测报告一键导出 **AI 解读 Prompt**——把配置 + 25 项指标 + WF 逐窗 + 一条龙评估 + 评级 + 成交摘要组装成结构化提示词,复制发给 ChatGPT / Claude / DeepSeek / 豆包,即可获得「老手朋友」口吻的通俗解读、改进建议与 **0-10 信心分**(附「该不该执行」行动刻度)。 diff --git a/src/easy_tdx/ai/llm.py b/src/easy_tdx/ai/llm.py index a1a826e..1ab0f4d 100644 --- a/src/easy_tdx/ai/llm.py +++ b/src/easy_tdx/ai/llm.py @@ -201,8 +201,7 @@ def resolve_config(cfg: LlmConfig | None = None) -> LlmConfig: c.model = preset.default_model if not c.api_url or not c.model: raise ValueError( - f"LLM 配置不完整:provider={c.provider} 缺少 api_url 或 model," - "请在 AI 设置中补全" + f"LLM 配置不完整:provider={c.provider} 缺少 api_url 或 model,请在 AI 设置中补全" ) return c diff --git a/src/easy_tdx/realtime/session.py b/src/easy_tdx/realtime/session.py index cb679ad..7391ec7 100644 --- a/src/easy_tdx/realtime/session.py +++ b/src/easy_tdx/realtime/session.py @@ -66,8 +66,7 @@ def session_info(now: datetime | None = None, *, tz: tzinfo | None = None) -> di return { "is_trading_time": is_trading_time(t), "sessions": [ - {"start": s.strftime("%H:%M"), "end": e.strftime("%H:%M")} - for s, e in SESSION_WINDOWS + {"start": s.strftime("%H:%M"), "end": e.strftime("%H:%M")} for s, e in SESSION_WINDOWS ], "session_desc": SESSION_DESC, "server_time": t.isoformat(timespec="seconds"), diff --git a/src/easy_tdx/web/routers/bars.py b/src/easy_tdx/web/routers/bars.py index 0a3af49..d484118 100644 --- a/src/easy_tdx/web/routers/bars.py +++ b/src/easy_tdx/web/routers/bars.py @@ -206,7 +206,11 @@ async def _fetch_120m( _MAX_BARS_PER_FETCH // 2, ) df = await client.get_security_bars( - market_from_str(market), code, category_from_str("MIN_60"), start, fetch_n, + market_from_str(market), + code, + category_from_str("MIN_60"), + start, + fetch_n, bar_time=bar_time, ) return _resample_pairs(df, count) diff --git a/src/easy_tdx/web/routers/llm.py b/src/easy_tdx/web/routers/llm.py index 0960ef8..ef85627 100644 --- a/src/easy_tdx/web/routers/llm.py +++ b/src/easy_tdx/web/routers/llm.py @@ -65,12 +65,8 @@ class LlmChatRequest(BaseModel): prompt: str = Field(..., min_length=1, max_length=200_000) system_prompt: str | None = Field(None, description="None = 用配置里的默认系统提示") - override: LlmConfigUpdate | None = Field( - None, description="临时覆盖配置(不落盘,仅本次调用)" - ) - context: LlmChatContext | None = Field( - None, description="策略上下文(随成功解读一并落历史库)" - ) + override: LlmConfigUpdate | None = Field(None, description="临时覆盖配置(不落盘,仅本次调用)") + context: LlmChatContext | None = Field(None, description="策略上下文(随成功解读一并落历史库)") def _merge_api_key(submitted: str, current: str) -> str: @@ -153,7 +149,11 @@ async def update_llm_config(req: LlmConfigUpdate) -> dict[str, Any]: def _record_history( - provider: str, model: str, prompt: str, reply: str, elapsed: float, + provider: str, + model: str, + prompt: str, + reply: str, + elapsed: float, ctx: LlmChatContext | None, ) -> None: """成功解读旁路落库(llm_history.db)。失败只记日志,不影响解读结果。""" @@ -217,8 +217,12 @@ async def llm_chat_async(req: LlmChatRequest) -> TaskSubmitResponse: _record_history( client.config.provider, client.config.model, req.prompt, reply, elapsed, req.context ) - return {"reply": reply, "model": client.config.model, "provider": client.config.provider, - "elapsed": elapsed} + return { + "reply": reply, + "model": client.config.model, + "provider": client.config.provider, + "elapsed": elapsed, + } runner = get_runner() task_id = runner.submit(_run, description=desc) diff --git a/tests/unit/test_ai_llm.py b/tests/unit/test_ai_llm.py index af6ede4..4cb3500 100644 --- a/tests/unit/test_ai_llm.py +++ b/tests/unit/test_ai_llm.py @@ -146,9 +146,7 @@ class TestClient: 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 - ) + 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: @@ -164,8 +162,15 @@ class TestClient: def test_provider_presets_cover_major_vendors(): vendors = [ - "deepseek", "qwen", "zhipu", "kimi", "minimax", - "openai", "claude", "ollama", "custom", + "deepseek", + "qwen", + "zhipu", + "kimi", + "minimax", + "openai", + "claude", + "ollama", + "custom", ] for pid in vendors: assert pid in PROVIDER_PRESETS, pid @@ -224,6 +229,7 @@ class TestAsyncChatTask: async def _slow(): await asyncio.sleep(0.05) return f"解读:{prompt[:8]}" + return _slow() monkeypatch.setattr(LlmClient, "chat", fake_chat) @@ -254,6 +260,7 @@ class TestAsyncChatTask: def fake_chat(self, prompt, system_prompt=None): async def _boom(): raise LlmError("请求超时(180s 内无响应)") + return _boom() monkeypatch.setattr(LlmClient, "chat", fake_chat) @@ -304,8 +311,9 @@ class TestAsyncChatTask: "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"] + 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"): @@ -373,8 +381,14 @@ class TestThinkingModelBlankContent: """ 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)) + 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"} @@ -403,8 +417,14 @@ class TestThinkingModelBlankContent: def fake_post(url, headers, payload, timeout): blank = chr(10) + " " + chr(10) - return {"choices": [{"message": {"content": blank, "reasoning_content": "r"}, - "finish_reason": "length"}]} + return { + "choices": [ + { + "message": {"content": blank, "reasoning_content": "r"}, + "finish_reason": "length", + } + ] + } monkeypatch.setattr(llm_mod, "_post_json", fake_post) with pytest.raises(LlmError, match="思考链"):