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
+21
View File
@@ -2,6 +2,27 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.29.0] — 2026-09-02
**借鉴社区 Fork[swimmingaaron/easy_tdx](https://github.com/swimmingaaron/easy_tdx))的六项实用特性**——该 Fork 自 v1.20.12 分叉后独立演化出一批好想法,本轮逐项甄别后移植其精华(剥离其单文件前端/平行后端层/硬编码个人路径等不可维护部分):ZIG 策略、交易时段感知刷新、120 分钟 K 线、逐 bar 衍生字段、159 只核心龙头池、多 Provider LLM 直连。
### 新增
- **ZIG 右侧突破回补策略**`zig_breakout`)——`MyTT` 新增 `ZIG` 之字转向指标(未来函数,拐点回溯标出;实现自 Fork 移植并补前视偏差警示文档)。策略逻辑:ZIG 波谷启动全仓买入(挂 `stop_loss_pct` 硬止损,OCO 由引擎逐 bar 监控)→ 见顶清仓并记录 HHV(N) 前高 → 收盘突破前高×(1+确认比例%) 右侧回补。ZIG 的前视偏差用「止损保护 + 右侧确认进场」两层对冲而非消除,策略 docstring 明示回测信号有前视性。因 `_breakout_level` 随持仓路径变化,不实现 `entry_exit_masks`(引擎自动走逐 bar 回放,向量化守护测试白名单放行)。同时登记寻优预设网格(zig_delta×confirm_pct=12 点)与独立策略文件 `strategies/zig_breakout.py`(供 `--strategy-file` 离线扫描)。
- **交易时段感知的仪表盘自动刷新**——新增共享模块 `realtime/session.py``is_trading_time()`(窗口 09:15~11:30:30 / 13:00~15:05,含集合竞价与收盘竞价缓冲,午休排除,周一至五)+ `GET /market/session`。WebUI 市场看板的 30/60/120s 三档轮询在休市时自动暂停(状态栏三态:交易中/休市已暂停/全天候模式),每分钟重估跨边界即时切换;「仅交易时段自动刷新」开关 localStorage 持久化,手动刷新按钮不受限。后端 SSE/WS 推送本就带时段过滤(feed `_DEFAULT_SESSIONS` / streamer 降频),本次不改其既有语义。
- **120 分钟 K 线**`/bars?category=MIN_120`,别名 `120M`/`120MIN`)——协议无此枚举,路由层特判:优先 MAC 原生 `Period.MINS × times=120`;失败则取 2 倍 60M 相邻两根聚合(open=first / high=max / low=min / close=last / vol·amount=sum,时间取后一根,奇数根丢最旧保最新);标准 TdxClient 回退路径受单次 800 根限制最多合成 400 根。前端周期选择器(单标的回测 / 组合回测)新增 `MIN_120` 选项。
- **K 线逐 bar 衍生字段**——`/bars``/bars/index` 每根 bar 附带 `pre_close`(前收,首根退化为本根开盘)、`change``change_pct``amplitude_pct`(振幅%),前端无需重算;`pre_close ≤ 0.01` 按 0.01 兜底(QFQ 复权后早期价格可能为 0/负)。
- **159 只核心龙头池**`screen/universe.py`,数据资产取自 Fork 按东方财富全行业龙头名单整理的 `CORE_UNIVERSE`,剥离其缓存/个人路径实现)——四组分层(全球第一/国内第一/科技细分/行业冠军),`universe="core"` 接入 `screen scan` CLI、`SignalScanner``StrengthRanker`(离线 .day 扫描按名单过滤,约 3 秒扫完龙头池),`/market/strength` API 同步支持;另暴露 `GET /market/core-leaders`
- **全局风险提示与免责声明(Web UI)**——App 外壳底部新增常驻提示栏,覆盖全部页面(行情 / 回测 / 选股扫描 / AI 解读统一口径:"仅供量化研究与学习,不构成任何投资建议或个股推荐;历史表现不代表未来,股市有风险,据此操作风险自负")。龙头池页另加显著说明块:讲清名单含义(按东财公开资料整理的**扫描范围筛选清单**,仅描述行业地位的客观事实)与用途(`universe=core`),明确"不构成任何形式的个股推荐/买入建议/投资顾问服务,不对据此操作承担责任"。AI 解读正文(回测弹窗与历史页)均随附"AI 生成内容可能出错,仅供参考,不构成投资建议"提示。
- **AI 解读历史 + 龙头池页面(Web UI 导航新增「AI 解读历史」「龙头池」)**——每次成功的「直接解读」自动归档到 `~/.easy_tdx/llm_history.db`SQLite`llm_history_store`):提问 Prompt、解读正文、模型/耗时与当时的策略上下文(策略/参数/标的/周期/日期区间)。历史页按时间倒序展开查看,每条带「→ 去回测(带参数)」一键跳回回测页复现场景(复用寻优页的 query 预填链路)、查看提问 Prompt、删除/清空;API 为 `GET/DELETE /llm/history`。「龙头池」页展示 159 只核心龙头(搜索过滤 + 点击进个股详情,即 `universe=core` 同一名单)。另为前端路由表加兜底重定向:未注册路径(如把 API 路径当页面访问)回看板而非渲染空白。
- **多 Provider LLM 直连 + WebUI「AI 设置」页**——新增 `easy_tdx.ai` 模块与 `/llm/*` 路由。Provider 预设 9 家:DeepSeek / 通义千问 / 智谱 GLMbigmodel.cn/ Kimi / MiniMax / OpenAI / ClaudeAnthropic 原生协议)/ Ollama(本地免 Key)/ 自定义(任意 OpenAI 兼容网关),base_url 与模型均可覆盖。配置落盘 `~/.easy_tdx/llm.json`(随 `EASY_TDX_CONFIG_DIR`),WebUI 表单与手工编辑同一份文件、双向兼容;字段级优先级 = 文件 > 环境变量(`LLM_PROVIDER`/`LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL`> 预设默认。APIGET/PUT `/llm/config`(key 脱敏回显,回传脱敏串不覆盖真 key)、POST `/llm/test`(连通性+延迟)、POST `/llm/chat`。回测页「🤖 AI 解读」在模型已配置时新增「✨ 直接解读」——把组装好的报告 Prompt 提交为**后台任务**(接入与回测同一套 `task_runner`:4 线程池 + SQLite 持久化),前端短轮询 `GET /llm/chat/tasks/{task_id}` 取结果(`POST /llm/chat/async`,202),长耗时模型调用不占 HTTP 连接、断线重连后仍可查询,按钮实时显示已耗时;配置不完整在提交期即报 400,网络/鉴权/超时错误体现在任务态 `error`(读超时文案给出「调大超时」动作,默认超时 180s 可调至 600s)。未配置模型时保持导出 Prompt 手动路径。**思考型模型空白正文防御**(实测:GLM-5.x 的 `reasoning_content` 思考链计入 max_tokens,4000 预算被整份报告的思考耗尽后 `content` 为空白——truthy 但渲染为空,状态条报成功而正文空白):解析层对空白正文显式拦截——有思考链时报「调大 Max Tokens」的可操作错误(含当前值与 finish_reason),无思考链按格式错误上报,绝不返回空串;max_tokens 默认 4000→16000(上限即目标,按实际生成计费),前端再拦一道纯空白。零第三方依赖(标准库 urllib + `asyncio.to_thread`)。
### 测试
- 新增 6 个单测文件共 51 例:`test_mytt_zig.py`(ZIG 边界/单调/V 型/锯齿/阈值双写法)、`test_zig_strategy.py`(注册/参数校验/引擎成交/独立文件加载/预设网格)、`test_realtime_session.py`(窗口边界/午休/周末/session_info)、`test_bars_min120_derived.py`(重采样聚合/裁剪/缺列、衍生字段/兜底)、`test_screen_universe_core.py`(名单 159 只唯一性/已知龙头/core 过滤准确性)、`test_ai_llm.py`(配置文件↔环境变量优先级/脱敏/双协议请求组装/HTTP 错误包装,HTTP 层 monkeypatch 零真实网络)。
- 黄金基线 `tests/golden/backtest_metrics.json` 重新生成:仅新增 zig_breakout 条目(5 笔交易),其余策略零漂移。
- 新增 `test_llm_history_store.py`(6 例:倒序/上下文 JSON 往返/坏数据容忍/删除清空/limit)与异步解读自动落历史 + 失败不落库的 API 级测试。
## [1.28.2] — 2026-09-02 ## [1.28.2] — 2026-09-02
**修复指数/个股 K 线 vol 字段的三类协议语义错误**[#64](https://github.com/handsomejustin/easy_tdx/issues/64))——通达信服务端 K 线记录的第一个 4 字节字段(一直被当作成交量透传)的语义随周期/品种变化,此前原样返回错误数据。本轮通过逐字节拆包原始报文 + 新浪实时行情/东方财富分钟 K 三方交叉验证锁定规律后,在协议解析层(`GetIndexBarsCmd` / `GetSecurityBarsCmd``parse_response`,同步/异步客户端共用)统一修正。 **修复指数/个股 K 线 vol 字段的三类协议语义错误**[#64](https://github.com/handsomejustin/easy_tdx/issues/64))——通达信服务端 K 线记录的第一个 4 字节字段(一直被当作成交量透传)的语义随周期/品种变化,此前原样返回错误数据。本轮通过逐字节拆包原始报文 + 新浪实时行情/东方财富分钟 K 三方交叉验证锁定规律后,在协议解析层(`GetIndexBarsCmd` / `GetSecurityBarsCmd``parse_response`,同步/异步客户端共用)统一修正。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.28.2" version = "1.29.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+113
View File
@@ -571,4 +571,117 @@ def FSL(CLOSE, VOL, CAPITAL): # 分水岭指标:多空趋势强弱分界(SW
return RD(SWL), RD(SWS) return RD(SWL), RD(SWS)
def ZIG(S, X=35): # 之字转向指标(未来函数):S为价格序列,X为转向阈值百分比(如10表示10%)
"""之字转向指标 (ZigZag) — 经典未来函数。
当价格从前一个极值点反向变动超过 X% 时确立波峰/波谷拐点并转向,
拐点之间线性插值,返回与 S 等长的拟合序列。
注意:拐点只有在**其后**的走势确认了转向才会回溯标出,序列中波峰/
波谷位置含有未来信息。把 ZIG 拐点直接当买卖信号回测会严重高估收益
(前视偏差);如需使用,必须配合右侧确认或止损保护(参见内置策略
``zig_breakout`` 的做法)。
Args:
S: 价格序列(通常为 CLOSE)
X: 转向阈值百分比。10 表示 10%;也可传小数形式 0.1(以 1.0 为界
自动区分,故阈值本身小于 1% 时请用小数形式)
Returns:
np.ndarray: 与 S 等长的 ZIG 之字转向插值序列
"""
S = np.asarray(S, dtype=float)
n = len(S)
if n == 0:
return np.array([], dtype=float)
if n == 1:
return S.copy()
x = float(X) / 100.0 if float(X) > 1.0 else float(X)
if x <= 0:
return S.copy()
ZIG_STATE_START = 0
ZIG_STATE_RISE = 1
ZIG_STATE_FALL = 2
peer_i = 0
candidate_i = None
peers = [0]
state = ZIG_STATE_START
for scan_i in range(1, n):
if scan_i == n - 1:
# 扫描到序列尾部:未确立的候选极值按当前方向收尾
if candidate_i is None:
peers.append(scan_i)
else:
if state == ZIG_STATE_RISE:
if S[scan_i] >= S[candidate_i]:
peers.append(scan_i)
else:
peers.append(candidate_i)
if candidate_i != scan_i:
peers.append(scan_i)
elif state == ZIG_STATE_FALL:
if S[scan_i] <= S[candidate_i]:
peers.append(scan_i)
else:
peers.append(candidate_i)
if candidate_i != scan_i:
peers.append(scan_i)
else:
peers.append(scan_i)
break
if state == ZIG_STATE_START:
if S[peer_i] != 0:
if S[scan_i] >= S[peer_i] * (1.0 + x):
candidate_i = scan_i
state = ZIG_STATE_RISE
elif S[scan_i] <= S[peer_i] * (1.0 - x):
candidate_i = scan_i
state = ZIG_STATE_FALL
elif state == ZIG_STATE_RISE:
if S[scan_i] >= S[candidate_i]:
candidate_i = scan_i
elif S[candidate_i] != 0 and S[scan_i] <= S[candidate_i] * (1.0 - x):
peer_i = candidate_i
peers.append(peer_i)
state = ZIG_STATE_FALL
candidate_i = scan_i
elif state == ZIG_STATE_FALL:
if S[scan_i] <= S[candidate_i]:
candidate_i = scan_i
elif S[candidate_i] != 0 and S[scan_i] >= S[candidate_i] * (1.0 + x):
peer_i = candidate_i
peers.append(peer_i)
state = ZIG_STATE_RISE
candidate_i = scan_i
# 去除重复拐点并确保末端对齐
clean_peers = []
for p in peers:
if not clean_peers or p != clean_peers[-1]:
clean_peers.append(p)
if clean_peers[-1] != n - 1:
clean_peers.append(n - 1)
# 拐点间线性插值
z = np.zeros(n, dtype=float)
for i in range(len(clean_peers) - 1):
p_start = clean_peers[i]
p_end = clean_peers[i + 1]
v_start = S[p_start]
v_end = S[p_end]
if p_end == p_start:
z[p_start] = v_start
else:
slope = (v_end - v_start) / (p_end - p_start)
for j in range(p_end - p_start + 1):
z[p_start + j] = v_start + slope * j
return RD(z)
# 望大家能提交更多指标和函数 https://github.com/mpquant/MyTT # 望大家能提交更多指标和函数 https://github.com/mpquant/MyTT
+1
View File
@@ -110,6 +110,7 @@ def FSL(
VOL: npt.ArrayLike, VOL: npt.ArrayLike,
CAPITAL: float, CAPITAL: float,
) -> tuple[NDArray, NDArray]: ... ) -> tuple[NDArray, NDArray]: ...
def ZIG(S: npt.ArrayLike, X: float = ...) -> NDArray: ...
# ── Utility Functions ──────────────────────────────────────────────────────── # ── Utility Functions ────────────────────────────────────────────────────────
+35
View File
@@ -0,0 +1,35 @@
"""LLM 客户端与配置(多 Provider,WebUI 与配置文件双向兼容)。
借鉴社区 Forkswimmingaaron/easy_tdx)的极简 LLM 客户端思路并扩展:
国产主流 Provider 预设(DeepSeek/通义千问/智谱/Kimi/MiniMax+ OpenAI/
Claude/Ollama + 完全自定义,统一收敛到两种线上协议(openai 兼容 /
anthropic 原生)。
配置来源(字段级优先级,WebUI 与配置文件天然兼容)::
~/.easy_tdx/llm.json 字段值 > 环境变量 > Provider 预设默认值
WebUI 保存 = 写这个 JSON 文件;手工编辑文件 = 下次请求即生效。环境变量
``LLM_PROVIDER`` / ``LLM_API_KEY`` / ``LLM_BASE_URL`` / ``LLM_MODEL``
与常见工具惯例一致,仅在文件缺字段时兜底。
"""
from easy_tdx.ai.llm import (
PROVIDER_PRESETS,
LlmClient,
LlmConfig,
load_config,
mask_key,
resolve_config,
save_config,
)
__all__ = [
"PROVIDER_PRESETS",
"LlmClient",
"LlmConfig",
"load_config",
"mask_key",
"resolve_config",
"save_config",
]
+401
View File
@@ -0,0 +1,401 @@
"""多 Provider LLM 客户端:配置解析 + HTTP 调用。
零第三方依赖:HTTP 走标准库 urllib(经 ``asyncio.to_thread`` 异步化),
FastAPI 路由可直接 ``await``。
Provider 预设(``api_style``):
=========== ======== ============================================== ==================
provider 协议 base_url 默认模型
=========== ======== ============================================== ==================
deepseek openai https://api.deepseek.com/v1 deepseek-chat
qwen openai https://dashscope.aliyuncs.com/compatible-mode qwen-plus
/v1
zhipu openai https://open.bigmodel.cn/api/paas/v4 glm-4-flash
kimi openai https://api.moonshot.cn/v1 moonshot-v1-8k
minimax openai https://api.minimaxi.chat/v1 MiniMax-Text-01
openai openai https://api.openai.com/v1 gpt-4o-mini
claude anthropic https://api.anthropic.com/v1 claude-sonnet-4-5
ollama openai http://localhost:11434/v1 qwen2.5:7b
custom openai (用户填写) (用户填写)
=========== ======== ============================================== ==================
预设的 base_url/默认模型只是初始填充值——WebUI 或 JSON 文件里均可覆盖
(自定义网关/代理场景直接改 url 即可)。
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from typing import Any
__all__ = [
"PROVIDER_PRESETS",
"LlmClient",
"LlmConfig",
"load_config",
"mask_key",
"resolve_config",
"save_config",
]
logger = logging.getLogger(__name__)
#: 配置文件名(落在 EASY_TDX_CONFIG_DIR,与 watchlist/strategies 同目录)。
LLM_CONFIG_FILENAME = "llm.json"
@dataclass
class ProviderPreset:
"""单个 Provider 的展示信息与默认填充值。"""
id: str
label: str
base_url: str
default_model: str
api_style: str = "openai" # "openai" | "anthropic"
needs_key: bool = True # ollama 本地服务无需 key
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"label": self.label,
"base_url": self.base_url,
"default_model": self.default_model,
"api_style": self.api_style,
"needs_key": self.needs_key,
}
#: Provider 预设表(WebUI 下拉框数据源 + 未配置字段的兜底默认值)。
PROVIDER_PRESETS: dict[str, ProviderPreset] = {
p.id: p
for p in (
ProviderPreset("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat"),
ProviderPreset(
"qwen",
"通义千问 Qwen",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"qwen-plus",
),
ProviderPreset("zhipu", "智谱 GLM", "https://open.bigmodel.cn/api/paas/v4", "glm-4-flash"),
ProviderPreset("kimi", "Kimi (月之暗面)", "https://api.moonshot.cn/v1", "moonshot-v1-8k"),
ProviderPreset("minimax", "MiniMax", "https://api.minimaxi.chat/v1", "MiniMax-Text-01"),
ProviderPreset("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4o-mini"),
ProviderPreset(
"claude",
"Claude (Anthropic)",
"https://api.anthropic.com/v1",
"claude-sonnet-4-5",
api_style="anthropic",
),
ProviderPreset(
"ollama",
"Ollama(本地)",
"http://localhost:11434/v1",
"qwen2.5:7b",
needs_key=False,
),
ProviderPreset("custom", "自定义(OpenAI 兼容)", "", ""),
)
}
@dataclass
class LlmConfig:
"""LLM 调用配置(WebUI 表单与 llm.json 的公共结构)。"""
provider: str = "deepseek"
api_url: str = "" # 留空 = 用预设 base_url
api_key: str = ""
model: str = "" # 留空 = 用预设默认模型
temperature: float = 0.3
# max_tokens 是"上限"而非目标(按实际生成计费):思考型模型的思考链
# 计入该预算,4000 会被整份报告的思考轻易耗尽导致正文空白,默认给足
timeout: float = 180.0
max_tokens: int = 16000
system_prompt: str = field(
default="你是一位严谨的 A 股量化投研分析师,基于给定的数据客观分析,"
"不确定的内容明确说明,不构成投资建议。"
)
def to_dict(self, *, mask_api_key: bool = False) -> dict[str, Any]:
d = asdict(self)
if mask_api_key:
d["api_key"] = mask_key(self.api_key)
return d
# ── 配置读写(文件 > 环境变量 > 预设) ────────────────────────────────────────
def config_path() -> Path:
"""配置文件路径(``$EASY_TDX_CONFIG_DIR/llm.json``,默认 ``~/.easy_tdx``)。"""
base = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
return base / LLM_CONFIG_FILENAME
def _read_config_file() -> dict[str, Any]:
"""直读 llm.json(无缓存——手工编辑即时生效)。损坏/不存在返回空 dict。"""
p = config_path()
if not p.is_file():
return {}
try:
data = json.loads(p.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError) as exc:
logger.warning("读取 LLM 配置失败 %s: %s", p, exc)
return {}
def load_config() -> LlmConfig:
"""加载配置:llm.json 显式字段 > 环境变量兜底(未填字段仍为空,调用时再取预设)。"""
data = _read_config_file()
env_url = os.environ.get("LLM_BASE_URL", "")
cfg = LlmConfig(
provider=str(data.get("provider") or os.environ.get("LLM_PROVIDER", "") or "deepseek"),
api_url=str(data.get("api_url") or env_url or ""),
api_key=str(data.get("api_key") or os.environ.get("LLM_API_KEY", "") or ""),
model=str(data.get("model") or os.environ.get("LLM_MODEL", "") or ""),
temperature=float(data.get("temperature", 0.3)),
max_tokens=int(data.get("max_tokens", 16000)),
timeout=float(data.get("timeout", 180.0)),
system_prompt=str(data.get("system_prompt", "") or LlmConfig.system_prompt),
)
return cfg
def save_config(cfg: LlmConfig) -> Path:
"""写入 llm.json(WebUI 保存入口;目录惰性创建)。"""
p = config_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(
json.dumps(cfg.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8", newline="\n"
)
return p
def resolve_config(cfg: LlmConfig | None = None) -> LlmConfig:
"""把配置的空字段用 Provider 预设补齐,得到可直接调用的完整配置。
- ``api_url`` 空 → 预设 ``base_url``
- ``model`` 空 → 预设 ``default_model``
- provider 无预设(拼错)→ 按 custom 处理,url/model 必须已填。
Raises:
ValueError: 补齐后仍缺 api_url 或 modelcustom 未填全)。
"""
c = replace(cfg or load_config())
preset = PROVIDER_PRESETS.get(c.provider, PROVIDER_PRESETS["custom"])
if not c.api_url:
c.api_url = preset.base_url
if not c.model:
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 设置中补全"
)
return c
def mask_key(key: str) -> str:
"""API Key 脱敏展示:保头 3 尾 4,中间打码(短 key 全打码)。"""
if not key:
return ""
if len(key) <= 8:
return "*" * len(key)
return f"{key[:3]}***{key[-4:]}"
# ── HTTP 客户端(标准库实现) ─────────────────────────────────────────────────
class LlmError(RuntimeError):
"""LLM 调用失败(网络/鉴权/响应格式)。"""
def __init__(self, message: str, *, status: int | None = None) -> None:
super().__init__(message)
self.status = status
def _post_json(
url: str, headers: dict[str, str], payload: dict[str, Any], timeout: float
) -> dict[str, Any]:
"""同步 POST JSON(在线程池里跑),返回解析后的 JSON。
urllib 默认带 ``User-Agent: Python-urllib``,部分网关拒绝——显式带 UA。
超时单独成类报错:非流式 chat 接口要等模型**整段回复生成完**才回包,
大 Prompt(如整份回测报告解读)生成 1-3 分钟很正常,读超时≠网络故障,
报错必须把「调大超时」这个动作说清楚(v1.29.1 实测踩坑)。
"""
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"User-Agent": "easy-tdx/llm", "Content-Type": "application/json", **headers},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:500]
raise LlmError(f"LLM API HTTP {exc.code}: {body}", status=exc.code) from exc
except urllib.error.URLError as exc:
if isinstance(exc.reason, TimeoutError):
raise LlmError(_timeout_message(timeout)) from exc
raise LlmError(f"LLM API 网络错误: {exc.reason}") from exc
except TimeoutError as exc:
raise LlmError(_timeout_message(timeout)) from exc
except json.JSONDecodeError as exc:
raise LlmError(f"LLM API 响应不是合法 JSON: {exc}") from exc
def _timeout_message(timeout: float) -> str:
return (
f"请求超时({timeout:.0f}s 内无响应)——非流式接口需等模型生成完整段回复,"
"大报告解读 1-3 分钟属正常。可在「AI 设置」调大「超时(秒)」,"
"或换生成更快的模型后重试"
)
class LlmClient:
"""单次配置快照的 LLM 调用客户端(无连接状态,可随时重建)。"""
def __init__(self, cfg: LlmConfig | None = None) -> None:
self._cfg = resolve_config(cfg)
@property
def config(self) -> LlmConfig:
return self._cfg
async def chat(self, prompt: str, system_prompt: str | None = None) -> str:
"""发一轮对话,返回模型回复文本。
Args:
prompt: 用户消息(如回测报告组装成的解读 Prompt)。
system_prompt: 系统提示,None = 用配置里的默认。
Raises:
LlmError: 网络/鉴权/格式错误(含未配置 api_key 的场景)。
"""
cfg = self._cfg
preset = PROVIDER_PRESETS.get(cfg.provider, PROVIDER_PRESETS["custom"])
if preset.needs_key and not cfg.api_key:
raise LlmError(
f"未配置 {preset.label} 的 API Key——请在 WebUI「AI 设置」页"
"或 ~/.easy_tdx/llm.json 中填写(或设置 LLM_API_KEY 环境变量)"
)
system = system_prompt if system_prompt is not None else cfg.system_prompt
return await asyncio.to_thread(self._chat_sync, prompt, system, preset.api_style)
# -- 同步实现(to_thread 里跑) --------------------------------------------
def _chat_sync(self, prompt: str, system: str, api_style: str) -> str:
if api_style == "anthropic":
return self._chat_anthropic(prompt, system)
return self._chat_openai(prompt, system)
def _chat_openai(self, prompt: str, system: str) -> str:
cfg = self._cfg
headers = {"Authorization": f"Bearer {cfg.api_key}"} if cfg.api_key else {}
payload = {
"model": cfg.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"temperature": cfg.temperature,
"max_tokens": cfg.max_tokens,
}
url = f"{cfg.api_url.rstrip('/')}/chat/completions"
data = _post_json(url, headers, payload, cfg.timeout)
try:
message = data["choices"][0]["message"]
finish = str(data["choices"][0].get("finish_reason") or "")
return self._extract_reply_openai(message, finish)
except LlmError:
raise
except (KeyError, IndexError, TypeError) as exc:
raw = json.dumps(data, ensure_ascii=False)[:300]
raise LlmError(f"LLM 响应格式异常: {raw}") from exc
def _extract_reply_openai(self, message: dict[str, Any], finish: str) -> str:
"""从 OpenAI 兼容响应的 message 里提取正文,处理思考型模型的空白正文。
思考型模型(GLM-5.x / DeepSeek-R1 / o 系列等)的 ``reasoning_content``
计入 max_tokens:预算被思考链耗尽时 ``content`` 为空白——truthy 但
渲染为空(v1.29.1 实测:状态条报成功、正文空白)。这里显式拦截:
空白正文一律报可操作的错误(提示调大 max_tokens),绝不返回空串。
"""
content = message.get("content")
text = str(content) if content is not None else ""
if text.strip():
return text
reasoning = message.get("reasoning_content") or message.get("reasoning")
if reasoning:
raise LlmError(
f"模型只返回了思考链(reasoning_content {len(str(reasoning))} 字),"
f"未生成正文——max_tokens={self._cfg.max_tokens} 大概率被思考耗尽"
f"finish_reason={finish or 'unknown'})。"
"请在「AI 设置」把 Max Tokens 调大(思考型模型建议 ≥16000)后重试"
)
if finish == "length":
raise LlmError(
"模型输出被 max_tokens 截断且无正文,请在「AI 设置」调大 Max Tokens 后重试"
)
raw = json.dumps(message, ensure_ascii=False)[:300]
raise LlmError(f"LLM 响应 message.content 为空: {raw}")
def _chat_anthropic(self, prompt: str, system: str) -> str:
cfg = self._cfg
headers = {
"x-api-key": cfg.api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": cfg.model,
"max_tokens": cfg.max_tokens,
"temperature": cfg.temperature,
"system": system,
"messages": [{"role": "user", "content": prompt}],
}
url = f"{cfg.api_url.rstrip('/')}/messages"
data = _post_json(url, headers, payload, cfg.timeout)
try:
blocks = data["content"]
return "".join(str(b.get("text", "")) for b in blocks if b.get("type") == "text")
except (KeyError, TypeError) as exc:
raw = json.dumps(data, ensure_ascii=False)[:300]
raise LlmError(f"LLM 响应格式异常: {raw}") from exc
async def test(self) -> dict[str, Any]:
"""连通性测试:发一句极短 ping,返回 ok/延迟/样例回复。"""
t0 = time.perf_counter()
try:
reply = await self.chat(
"请只回复两个字:OK", system_prompt="You are a connectivity probe."
)
return {
"ok": True,
"latency_ms": round((time.perf_counter() - t0) * 1000),
"model": self._cfg.model,
"provider": self._cfg.provider,
"reply": reply.strip()[:100],
}
except LlmError as exc:
return {
"ok": False,
"latency_ms": round((time.perf_counter() - t0) * 1000),
"model": self._cfg.model,
"provider": self._cfg.provider,
"error": str(exc),
}
+112
View File
@@ -30,6 +30,7 @@ from easy_tdx.MyTT import (
EMA, EMA,
EMV, EMV,
FSL, FSL,
HHV,
KDJ, KDJ,
KTN, KTN,
MA, MA,
@@ -38,6 +39,7 @@ from easy_tdx.MyTT import (
TAQ, TAQ,
TRIX, TRIX,
WR, WR,
ZIG,
) )
__all__: list[str] = [] # 注册副作用即可,无需导出符号 __all__: list[str] = [] # 注册副作用即可,无需导出符号
@@ -691,3 +693,113 @@ class FslStrategy(ParametrizedStrategy):
def entry_exit_masks(self) -> tuple[Any, Any]: def entry_exit_masks(self) -> tuple[Any, Any]:
"""与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。"""
return self.gold, self.dead return self.gold, self.dead
# ── ZIG 右侧突破回补 ─────────────────────────────────────────────────────────
@register_strategy(
name="zig_breakout",
label="ZIG 右侧突破回补",
description=(
"ZIG 向上启动(波谷确认)全仓买入;ZIG 见顶回落清仓并记录 N 日最高点,"
"其后收盘突破前高×(1+确认比例) 时右侧回补。两路径买入均带硬止损,"
"对冲 ZIG 波谷确认的前视偏差(未来函数,实盘信号会滞后)。"
),
)
class ZigBreakoutStrategy(ParametrizedStrategy):
"""ZIG 右侧突破回补(Re-entry on Breakout + 硬止损保护)。
ZIG 是未来函数:波峰/波谷只有在其后走势确认转向才回溯标出,回测里
"波谷启动"信号天然偷看未来。本策略用两层保护缓解而非消除该偏差:
1. 买入即挂 ``stop_loss_pct`` 硬止损(引擎逐 bar 监控,跌破自动平仓),
假波谷不至于深套;
2. 卖出后不追 ZIG 新波谷,而是等价格**右侧突破**前高确认_pct 再回补,
"猜底"换成"确认后进场"
交易逻辑::
空仓 + ZIG 上行 → 全仓买入(带止损)
持仓 + ZIG 下行(见顶) → 全仓卖出,记录 HHV(high, N) 为前高
空仓 + 收盘 ≥ 前高×(1+确认) → 右侧回补(带止损)
注意:``_breakout_level`` 随持仓路径变化,信号不可向量化,故不实现
``entry_exit_masks``——引擎自动走逐 bar 回放路径(与 next() 完全一致)。
"""
params = [
Param(
"zig_delta",
float,
default=10.0,
min_value=0.5,
max_value=50.0,
label="ZIG转向阈值%",
),
Param(
"confirm_pct",
float,
default=2.0,
min_value=0.1,
max_value=20.0,
label="突破确认比例%",
),
Param("hhv_period", int, default=20, min_value=5, max_value=120, label="前高周期"),
Param(
"stop_loss_pct",
float,
default=3.0,
min_value=0.0,
max_value=30.0,
label="硬止损%",
),
]
def init(self) -> None:
self.zig = self.I(ZIG, self.data.close, self.p["zig_delta"])
self.hhv = self.I(HHV, self.data.high, self.p["hhv_period"])
# 见顶清仓时记录的前高(0 = 未记录,等待首次建仓-见顶周期)
self._breakout_level: float = 0.0
def next(self) -> None:
i = self._bar_index
if i == 0:
return
cur_close = float(self.data.close[0])
cur_zig = float(self.zig[i])
prev_zig = float(self.zig[i - 1])
cur_pos = self.position["size"]
# 持仓:ZIG 见顶回落 → 清仓,并记录突破位(HHV 含未来 bar 已确认的高点)
if cur_pos > 0 and cur_zig < prev_zig:
self._breakout_level = float(self.hhv[i])
self.sell(size=0)
return
if cur_pos == 0:
# 路径 1:ZIG 向上启动(波谷确认)→ 初始建仓
if cur_zig > prev_zig:
self._breakout_level = 0.0
self._buy_with_stop()
return
# 路径 2:右侧突破前高 → 回补(洗盘结束、主升确立)
if self._breakout_level > 0:
threshold = self._breakout_level * (1.0 + self.p["confirm_pct"] / 100.0)
if cur_close >= threshold:
self._breakout_level = 0.0
self._buy_with_stop()
def _buy_with_stop(self) -> None:
"""市价全仓买入并按 ``stop_loss_pct`` 挂硬止损(0 = 不挂)。
市价单(price=None)由引擎在下一根开盘成交,与本地其他内置策略
口径一致,避免信号 bar 收盘价成交的前视味道。
"""
pct = self.p["stop_loss_pct"] / 100.0
if pct > 0:
self.buy(size=0, stop_loss_pct=pct)
else:
self.buy(size=0)
@@ -98,6 +98,12 @@ STRATEGY_PRESETS: dict[str, dict[str, list[Any]]] = {
# capital 仅作粗档扫描(1千万/1亿/10亿股),覆盖小盘→大盘 # capital 仅作粗档扫描(1千万/1亿/10亿股),覆盖小盘→大盘
"capital": [1e7, 1e8, 1e9, 1e10], "capital": [1e7, 1e8, 1e9, 1e10],
}, # 4 }, # 4
# ── 之字转向类 ───────────────────────────────────────────────────────────
"zig_breakout": {
# 转向阈值×确认比例 = 4×3;hhv/止损用默认(HHV20 / 3%
"zig_delta": [5.0, 8.0, 10.0, 15.0],
"confirm_pct": [1.0, 2.0, 3.0],
}, # 12
} }
+4
View File
@@ -23,6 +23,7 @@ from easy_tdx.realtime.engine import (
RealtimeStrategy, RealtimeStrategy,
) )
from easy_tdx.realtime.feed import RealtimeDataFeed from easy_tdx.realtime.feed import RealtimeDataFeed
from easy_tdx.realtime.session import SESSION_WINDOWS, is_trading_time, session_info
__all__ = [ __all__ = [
"EventBus", "EventBus",
@@ -31,4 +32,7 @@ __all__ = [
"MarketEvent", "MarketEvent",
"RealtimeDataFeed", "RealtimeDataFeed",
"RealtimeStrategy", "RealtimeStrategy",
"SESSION_WINDOWS",
"is_trading_time",
"session_info",
] ]
+75
View File
@@ -0,0 +1,75 @@
"""A 股交易时段判断(共享工具)。
已有的两处会话过滤各自私有、口径不一:
- :mod:`easy_tdx.realtime.feed` 的 ``_DEFAULT_SESSIONS``09:15-11:30 / 13:00-15:00
WS 按需轮询用,收盘竞价不拉);
- :mod:`easy_tdx.web.quote_streamer` 的 ``_is_trading_hours``09:10-15:10 连续窗,
SSE 快照轮询用,午休也降频拉收盘价快照)。
本模块提供第三个口径——**WebUI 仪表盘自动刷新用的"有效行情时段"**
在 feed 的窗口基础上,早盘前移到 09:15(集合竞价有行情),尾盘后移到
15:05(收盘集合竞价 15:00-15:03 仍有成交),午休排除。前端在此时段内
做 15-30s 轮询,之外暂停自动刷新(手动刷新不受限)。
不改动上述两处既有语义,避免影响它们的测试与行为。
"""
from __future__ import annotations
from datetime import datetime, time, tzinfo
from typing import Any
__all__ = ["SESSION_WINDOWS", "SESSION_DESC", "is_trading_time", "session_info"]
#: 有效行情时段(本地时间)。窗口 = (start, end),含两端。
#: - 早盘 09:15:00-11:30:3009:15 起集合竞价可看,11:30:30 容纳尾单撮合散点;
#: - 午盘 13:00:00-15:05:0015:00-15:03 为收盘集合竞价,留 2 分钟余量。
SESSION_WINDOWS: tuple[tuple[time, time], ...] = (
(time(9, 15, 0), time(11, 30, 30)),
(time(13, 0, 0), time(15, 5, 0)),
)
#: 展示用时段描述(前端状态栏 / API 响应)。
SESSION_DESC = "09:15~11:30, 13:00~15:05"
def is_trading_time(now: datetime | None = None, *, tz: tzinfo | None = None) -> bool:
"""判断当前是否处于 A 股有效行情时段(周一至周五,午休与深夜除外)。
只做"星期 + 时分"判断,不含法定节假日日历——节假日全天处于闭市
窗口外时前端轮询暂停是安全方向(误刷新无副作用,漏刷新才是问题,
而节假日行情本就不动,手动刷新始终可用)。
Args:
now: 待判断时间,None = 取本地当前时间。
tz: 未传 ``now`` 时使用的时区,None = 系统本地时区。
Returns:
True = 盘中(含集合竞价缓冲窗)。
"""
t = now or datetime.now(tz=tz)
if t.weekday() >= 5: # 周六/周日
return False
for start, end in SESSION_WINDOWS:
if start <= t.time() <= end:
return True
return False
def session_info(now: datetime | None = None, *, tz: tzinfo | None = None) -> dict[str, Any]:
"""构建 /market/session 响应体:时段判断 + 窗口描述 + 服务器时间。
前端以本地判断为主(每 15s 重估),本接口用于校准服务器侧视角。
"""
t = now or datetime.now(tz=tz)
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
],
"session_desc": SESSION_DESC,
"server_time": t.isoformat(timespec="seconds"),
"weekday": t.weekday(),
}
+3
View File
@@ -24,6 +24,7 @@ from easy_tdx.screen.strength import ( # noqa: F401
StrengthRanker, StrengthRanker,
StrengthResult, StrengthResult,
) )
from easy_tdx.screen.universe import CORE_LEADERS, CORE_LEADERS_DESC # noqa: F401
__all__ = [ __all__ = [
"SignalScanner", "SignalScanner",
@@ -31,4 +32,6 @@ __all__ = [
"StrengthRanker", "StrengthRanker",
"StrengthResult", "StrengthResult",
"STRENGTH_PRESETS", "STRENGTH_PRESETS",
"CORE_LEADERS",
"CORE_LEADERS_DESC",
] ]
+1 -1
View File
@@ -35,7 +35,7 @@ def screen() -> None:
@click.option( @click.option(
"--universe", "--universe",
default="all", default="all",
help="股票范围: all/sh/sz/<文件路径>(默认 all", help="股票范围: all/sh/sz/core/<文件路径>(默认 allcore=159只核心龙头池",
) )
@click.option("--vipdoc", default=None, help="离线数据目录(默认自动检测)") @click.option("--vipdoc", default=None, help="离线数据目录(默认自动检测)")
@click.option("--cash", default=100_000.0, type=float, help="初始资金") @click.option("--cash", default=100_000.0, type=float, help="初始资金")
+15 -3
View File
@@ -108,6 +108,7 @@ class SignalScanner:
- "all": 沪深全部 A 股(默认) - "all": 沪深全部 A 股(默认)
- "sh": 仅上海 - "sh": 仅上海
- "sz": 仅深圳 - "sz": 仅深圳
- "core": 核心龙头池 159 只(跨沪深按名单过滤)
- 文件路径: 每行一个 "市场 代码"(如 "SZ 000001" - 文件路径: 每行一个 "市场 代码"(如 "SZ 000001"
progress_callback: 进度回调函数(current, total, filename) progress_callback: 进度回调函数(current, total, filename)
workers: 并发工作进程数 workers: 并发工作进程数
@@ -357,13 +358,20 @@ class SignalScanner:
""" """
# 确定要扫描的交易所目录 # 确定要扫描的交易所目录
exchanges: list[str] = [] exchanges: list[str] = []
if universe in ("all", "sz"): if universe in ("all", "sz", "core"):
exchanges.append("sz") exchanges.append("sz")
if universe in ("all", "sh"): if universe in ("all", "sh", "core"):
exchanges.append("sh") exchanges.append("sh")
# 核心龙头池:跨沪深按名单过滤(见 screen/universe.py
core_codes: set[str] | None = None
if universe == "core":
from easy_tdx.screen.universe import core_leader_codes
core_codes = core_leader_codes()
# 从文件列表模式读取 # 从文件列表模式读取
if universe not in ("all", "sh", "sz"): if universe not in ("all", "sh", "sz", "core"):
return self._collect_from_file(universe) return self._collect_from_file(universe)
# 扫描目录 # 扫描目录
@@ -383,6 +391,10 @@ class SignalScanner:
if sec_type not in _A_STOCK_TYPES: if sec_type not in _A_STOCK_TYPES:
continue continue
# 核心龙头池模式:只保留名单内的代码
if core_codes is not None and code not in core_codes:
continue
market = exchange.upper() market = exchange.upper()
files.append((filepath, market, code)) files.append((filepath, market, code))
+13 -4
View File
@@ -199,7 +199,7 @@ class StrengthRanker:
"""扫描全市场并返回强势股排名。 """扫描全市场并返回强势股排名。
Args: Args:
universe: all/sh/sz/<文件路径> universe: all/sh/sz/core/<文件路径>core=159 只核心龙头池)
top_n: 返回前 N 名,0=全部 top_n: 返回前 N 名,0=全部
workers: 并发进程数(0=串行,4-8 推荐) workers: 并发进程数(0=串行,4-8 推荐)
progress_callback: 回调(current, total, name) progress_callback: 回调(current, total, name)
@@ -229,13 +229,20 @@ class StrengthRanker:
def _collect_files(self, universe: str) -> list[tuple[Path, str, str]]: def _collect_files(self, universe: str) -> list[tuple[Path, str, str]]:
"""收集 A 股 .day 文件列表(复用 scanner 的逻辑)。""" """收集 A 股 .day 文件列表(复用 scanner 的逻辑)。"""
exchanges: list[str] = [] exchanges: list[str] = []
if universe in ("all", "sz"): if universe in ("all", "sz", "core"):
exchanges.append("sz") exchanges.append("sz")
if universe in ("all", "sh"): if universe in ("all", "sh", "core"):
exchanges.append("sh") exchanges.append("sh")
# 核心龙头池:跨沪深按名单过滤(与 scanner 同一名单)
core_codes: set[str] | None = None
if universe == "core":
from easy_tdx.screen.universe import core_leader_codes
core_codes = core_leader_codes()
# 从文件列表模式读取 # 从文件列表模式读取
if universe not in ("all", "sh", "sz"): if universe not in ("all", "sh", "sz", "core"):
return self._collect_from_file(universe) return self._collect_from_file(universe)
files: list[tuple[Path, str, str]] = [] files: list[tuple[Path, str, str]] = []
@@ -247,6 +254,8 @@ class StrengthRanker:
if _detect_security_type(filepath.name) not in _A_STOCK_TYPES: if _detect_security_type(filepath.name) not in _A_STOCK_TYPES:
continue continue
code = filepath.name.lower()[2:8] code = filepath.name.lower()[2:8]
if core_codes is not None and code not in core_codes:
continue
files.append((filepath, exchange.upper(), code)) files.append((filepath, exchange.upper(), code))
return files return files
+190
View File
@@ -0,0 +1,190 @@
"""扫描股票池(universe)定义。
核心龙头池 ``CORE_LEADERS``:159 只,按东方财富全行业龙头名单整理,
涵盖全球第一、国内第一、科技细分龙头与约 40 个行业冠军,四组分层。
来源:社区 Forkswimmingaaron/easy_tdx)按东财名单维护的数据资产,
剥离其个人路径/缓存实现后仅保留静态名单。
接入点:
- :class:`easy_tdx.screen.scanner.SignalScanner` / :class:`easy_tdx.screen.strength.StrengthRanker`
的 ``universe="core"``(离线 .day 扫描按名单过滤,约 3 秒);
- ``GET /api/v1/market/core-leaders``WebUI 展示/导出)。
"""
from __future__ import annotations
__all__ = ["CORE_LEADERS", "CORE_LEADERS_DESC", "core_leader_codes"]
CORE_LEADERS_DESC = "核心龙头池 159 只(东财全行业龙头名单)"
# 分组注释保留名单的层次语义;code → 简称。dict 保序(插入序即展示序)。
CORE_LEADERS: dict[str, str] = {
# 全球第一 / 国际领跑龙头
"002475": "立讯精密",
"002415": "海康威视",
"000725": "京东方A",
"603160": "汇顶科技",
"600745": "闻泰科技",
"002241": "歌尔股份",
"300628": "亿联网络",
"300207": "欣旺达",
"600309": "万华化学",
"300015": "爱尔眼科",
"000661": "长春高新",
"601888": "中国中免",
"601766": "中国中车",
"002050": "三花智控",
"688063": "派能科技",
"688008": "澜起科技",
"600563": "法拉电子",
"688256": "寒武纪",
"600900": "长江电力",
"603993": "洛阳钼业",
"601138": "工业富联",
"300450": "先导智能",
"000338": "潍柴动力",
"601088": "中国神华",
"002714": "牧原股份",
"600660": "福耀玻璃",
"300274": "阳光电源",
"600438": "通威股份",
"002812": "恩捷股份",
"002709": "天赐材料",
"600436": "片仔癀",
"600519": "贵州茅台",
"603288": "海天味业",
"600885": "宏发股份",
"688363": "华熙生物",
"002001": "新和成",
"603260": "合盛硅业",
"600941": "中国移动",
"601728": "中国电信",
# 国内第一 / 行业领军
"000063": "中兴通讯",
"600703": "三安光电",
"600588": "用友网络",
"002230": "科大讯飞",
"601360": "三六零",
"300454": "深信服",
"603019": "中科曙光",
"002410": "广联达",
"002008": "大族激光",
"002371": "北方华创",
"002841": "视源股份",
"300014": "亿纬锂能",
"002439": "启明星辰",
"002916": "深南电路",
"600845": "宝信软件",
"603659": "璞泰来",
"002152": "广电运通",
"300017": "网宿科技",
"000997": "新大陆",
"002396": "星网锐捷",
"002153": "石基信息",
"300271": "华宇软件",
"002405": "四维图新",
"002583": "海能达",
"000050": "深天马A",
"002281": "光迅科技",
"002463": "沪电股份",
# 细分科技与半导体芯片龙头
"300782": "卓胜微",
"300750": "宁德时代",
"000977": "浪潮信息",
"603501": "韦尔股份",
"002938": "鹏鼎控股",
"002600": "领益智造",
"688111": "金山办公",
"300433": "蓝思科技",
"603986": "兆易创新",
"600183": "生益科技",
"300383": "光环新网",
"600536": "中国软件",
"601231": "环旭电子",
"600584": "长电科技",
"300308": "中际旭创",
"603290": "斯达半导",
"300661": "圣邦股份",
"300373": "扬杰科技",
"300666": "江丰电子",
"002236": "大华股份",
"300623": "捷捷微电",
"300349": "金卡智能",
"002079": "苏州固锝",
"603688": "石英股份",
"002119": "康强电子",
"603005": "晶方科技",
"688002": "睿创微纳",
"688099": "晶晨股份",
"002185": "华天科技",
"600460": "士兰微",
"300474": "景嘉微",
"300567": "精测电子",
"300054": "鼎龙股份",
"300398": "飞凯材料",
"300327": "中颖电子",
# 核心大行业与细分龙头
"000858": "五粮液",
"600809": "山西汾酒",
"601100": "恒立液压",
"603638": "艾迪精密",
"000876": "新希望",
"300999": "金龙鱼",
"000895": "双汇发展",
"300059": "东方财富",
"300033": "同花顺",
"600030": "中信证券",
"601318": "中国平安",
"601628": "中国人寿",
"601601": "中国太保",
"000001": "平安银行",
"600036": "招商银行",
"002142": "宁波银行",
"000002": "万科A",
"600048": "保利发展",
"601012": "隆基绿能",
"601636": "旗滨集团",
"002129": "TCL中环",
"300595": "欧普康视",
"600763": "通策医疗",
"000333": "美的集团",
"000651": "格力电器",
"600690": "海尔智家",
"002032": "苏泊尔",
"600887": "伊利股份",
"002460": "赣锋锂业",
"002466": "天齐锂业",
"300122": "智飞生物",
"002007": "华兰生物",
"300142": "沃森生物",
"600276": "恒瑞医药",
"000513": "丽珠集团",
"002271": "东方雨虹",
"002352": "顺丰控股",
"600233": "圆通速递",
"000830": "鲁西化工",
"600426": "华鲁恒升",
"002594": "比亚迪",
"601633": "长城汽车",
"600031": "三一重工",
"000157": "中联重科",
"000425": "徐工机械",
"688981": "中芯国际",
"600547": "山东黄金",
"000975": "山金国际",
"600988": "赤峰黄金",
"600585": "海螺水泥",
"000100": "TCL科技",
"300003": "乐普医疗",
"601668": "中国建筑",
"601390": "中国中铁",
"603799": "华友钴业",
"601899": "紫金矿业",
"002421": "达实智能",
"300223": "北京君正",
}
def core_leader_codes() -> set[str]:
"""核心龙头池 6 位代码集合(scanner 过滤用)。"""
return set(CORE_LEADERS)
+31 -5
View File
@@ -293,6 +293,7 @@ def _create_app(
from easy_tdx.web.routers.finance import router as finance_router from easy_tdx.web.routers.finance import router as finance_router
from easy_tdx.web.routers.formula import router as formula_router from easy_tdx.web.routers.formula import router as formula_router
from easy_tdx.web.routers.indicator import router as indicator_router from easy_tdx.web.routers.indicator import router as indicator_router
from easy_tdx.web.routers.llm import router as llm_router
from easy_tdx.web.routers.mac_data import router as mac_data_router from easy_tdx.web.routers.mac_data import router as mac_data_router
from easy_tdx.web.routers.mac_quotes import router as mac_quotes_router from easy_tdx.web.routers.mac_quotes import router as mac_quotes_router
from easy_tdx.web.routers.market import router as market_router from easy_tdx.web.routers.market import router as market_router
@@ -328,6 +329,8 @@ def _create_app(
app.include_router(strategies_router, prefix="/api/v1") app.include_router(strategies_router, prefix="/api/v1")
# 服务器设置路由(列出/测速/切换 TDX host) # 服务器设置路由(列出/测速/切换 TDX host)
app.include_router(server_router, prefix="/api/v1") app.include_router(server_router, prefix="/api/v1")
# LLM 配置与对话路由(AI 设置页 / AI 解读直连,无行情依赖)
app.include_router(llm_router, prefix="/api/v1")
# 自选股路由(SQLite 持久化,纯 CRUD,不依赖行情连接) # 自选股路由(SQLite 持久化,纯 CRUD,不依赖行情连接)
app.include_router(watchlist_router, prefix="/api/v1") app.include_router(watchlist_router, prefix="/api/v1")
# 实时行情 SSE 路由(依赖 lifespan 里的 QuoteStreamer # 实时行情 SSE 路由(依赖 lifespan 里的 QuoteStreamer
@@ -366,19 +369,42 @@ def _create_app(
from starlette.responses import FileResponse from starlette.responses import FileResponse
class SPAStaticFiles(StaticFiles): class SPAStaticFiles(StaticFiles):
"""StaticFiles + SPA fallback404 时返回 index.html。""" """StaticFiles + SPA fallback404 时返回 index.html。
例外:未匹配的 ``/api/*`` 路径返回 JSON 404 而非 index.html——
SPA fallback 对 API 请求返回 200 HTML 会把"端点不存在/服务是
旧版本"伪装成前端 JSON 解析错误(``Unexpected token '<'``),
且前端 ``resp.ok`` 为 true 连错误分支都不走(v1.29 实测踩坑)。
index.html 一律带 ``Cache-Control: no-store``JS/CSS 是哈希
文件名可以长缓存,但入口 HTML 被缓存会让用户刷新后仍加载旧
资源引用(v1.29 实测:修复已上线、用户强刷仍看到旧版渲染)。
"""
async def get_response(self, path: str, scope): # type: ignore[no-untyped-def] async def get_response(self, path: str, scope): # type: ignore[no-untyped-def]
try: try:
return await super().get_response(path, scope) resp = await super().get_response(path, scope)
except Exception: except Exception:
# 任何 404(路径非文件)都返回 index.html,让前端路由处理。 # 任何 404(路径非文件)都返回 index.html,让前端路由处理。
# 仅对 GET 请求生效;API 路径 (/api/v1/*) 已在前面注册, # 仅对 GET 请求生效;已注册的 API 路由在路由表命中,不会
# 不会走到这里 # 走到这里——但**未注册**的 /api 路径(如服务加载了旧版
# 本、或端点拼写错)会掉进本 fallback,必须放行 404。
# 注:Windows 下 Starlette 传入的 path 是反斜杠形式,先归一化。
norm_path = path.replace("\\", "/").lstrip("/")
is_api = norm_path == "api" or norm_path.startswith("api/")
if is_api or scope.get("method", "GET") != "GET":
raise
index = _Path(str(self.directory)) / "index.html" index = _Path(str(self.directory)) / "index.html"
if index.is_file(): if index.is_file():
return FileResponse(str(index)) return FileResponse(str(index), headers=_INDEX_HEADERS)
raise raise
# StaticFiles(html=True) 命中目录默认页("/" → index.html)时
# 同样补 no-store,保证入口 HTML 永远取最新
if getattr(resp, "path", "").endswith("index.html"):
resp.headers.update(_INDEX_HEADERS)
return resp
_INDEX_HEADERS = {"Cache-Control": "no-store"}
app.mount("/", SPAStaticFiles(directory=str(dist_dir), html=True), name="web-ui") app.mount("/", SPAStaticFiles(directory=str(dist_dir), html=True), name="web-ui")
logger.info("Web UI mounted from %s (SPA fallback enabled)", dist_dir) logger.info("Web UI mounted from %s (SPA fallback enabled)", dist_dir)
+194
View File
@@ -0,0 +1,194 @@
"""AI 解读历史持久化(Web UI「AI 解读历史」页的数据后端)。
设计对齐 :mod:`easy_tdx.web.watchlist_store`
- 单文件 SQLite,落在统一配置目录(``~/.easy_tdx/llm_history.db``
随 ``EASY_TDX_CONFIG_DIR`` 环境变量走)。
- 短连接 + 写锁串行,跨线程安全(FastAPI 线程池 / task_runner 工作线程内调用)。
- 每次成功的 AI 解读记一条:Prompt(提问上下文)+ 解读正文 + 模型信息 +
策略上下文(策略/参数/标的/周期/日期范围)——策略上下文供历史页
「去回测」一键带参跳转引导。
历史写入属旁路语义:失败不影响解读任务本身(调用方 try/except 兜底)。
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
__all__ = ["LlmHistoryRecord", "LlmHistoryStore", "get_llm_history_store"]
_write_lock = threading.Lock()
def _config_dir() -> Path:
return Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@dataclass
class LlmHistoryRecord:
"""一次成功 AI 解读的完整记录。"""
provider: str
model: str
prompt: str
reply: str
elapsed: float = 0.0
# 策略上下文(「去回测」引导用;手工调用 API 可全部缺省)
strategy: str = ""
strategy_label: str = ""
symbol: str = "" # 6 位代码(与回测页 code 一致)
category: str = ""
params: dict[str, Any] = field(default_factory=dict)
start_date: str = ""
end_date: str = ""
id: int | None = None
created_at: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"created_at": self.created_at,
"provider": self.provider,
"model": self.model,
"prompt": self.prompt,
"reply": self.reply,
"elapsed": self.elapsed,
"strategy": self.strategy,
"strategy_label": self.strategy_label,
"symbol": self.symbol,
"category": self.category,
"params": self.params,
"start_date": self.start_date,
"end_date": self.end_date,
}
class LlmHistoryStore:
"""AI 解读历史 SQLite 存储。单例由 :func:`get_llm_history_store` 提供。"""
_SCHEMA = """
CREATE TABLE IF NOT EXISTS llm_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
reply TEXT NOT NULL DEFAULT '',
elapsed REAL NOT NULL DEFAULT 0,
strategy TEXT NOT NULL DEFAULT '',
strategy_label TEXT NOT NULL DEFAULT '',
symbol TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
params TEXT NOT NULL DEFAULT '{}',
start_date TEXT NOT NULL DEFAULT '',
end_date TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_llm_history_created ON llm_history(created_at DESC);
"""
def __init__(self, db_path: Path | None = None) -> None:
self.db_path = db_path or (_config_dir() / "llm_history.db")
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as conn:
conn.executescript(self._SCHEMA)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def add(self, rec: LlmHistoryRecord) -> LlmHistoryRecord:
"""追加一条记录,返回带 id/created_at 的落库结果。"""
rec.created_at = rec.created_at or _now_iso()
with _write_lock, self._connect() as conn:
cur = conn.execute(
"INSERT INTO llm_history (created_at, provider, model, prompt, reply, elapsed,"
" strategy, strategy_label, symbol, category, params, start_date, end_date)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
rec.created_at,
rec.provider,
rec.model,
rec.prompt,
rec.reply,
rec.elapsed,
rec.strategy,
rec.strategy_label,
rec.symbol,
rec.category,
json.dumps(rec.params, ensure_ascii=False),
rec.start_date,
rec.end_date,
),
)
rec.id = int(cur.lastrowid)
return rec
def list_all(self, limit: int = 50) -> list[LlmHistoryRecord]:
"""按时间倒序列最近 N 条。"""
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM llm_history ORDER BY id DESC LIMIT ?", (int(limit),)
).fetchall()
return [self._row_to_record(r) for r in rows]
def delete(self, record_id: int) -> bool:
"""删除一条;返回是否确实删除。"""
with _write_lock, self._connect() as conn:
cur = conn.execute("DELETE FROM llm_history WHERE id = ?", (int(record_id),))
return cur.rowcount > 0
def clear(self) -> int:
"""清空全部历史;返回删除条数。"""
with _write_lock, self._connect() as conn:
cur = conn.execute("DELETE FROM llm_history")
return cur.rowcount
@staticmethod
def _row_to_record(r: sqlite3.Row) -> LlmHistoryRecord:
try:
params = json.loads(r["params"] or "{}")
except json.JSONDecodeError:
params = {}
return LlmHistoryRecord(
id=int(r["id"]),
created_at=r["created_at"],
provider=r["provider"],
model=r["model"],
prompt=r["prompt"],
reply=r["reply"],
elapsed=float(r["elapsed"] or 0),
strategy=r["strategy"],
strategy_label=r["strategy_label"],
symbol=r["symbol"],
category=r["category"],
params=params if isinstance(params, dict) else {},
start_date=r["start_date"],
end_date=r["end_date"],
)
_store: LlmHistoryStore | None = None
_store_lock = threading.Lock()
def get_llm_history_store() -> LlmHistoryStore:
"""全局单例(首次调用惰性建库)。"""
global _store # noqa: PLW0603 — 模块级单例
if _store is None:
with _store_lock:
if _store is None:
_store = LlmHistoryStore()
return _store
+161 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
import numpy as np
import pandas as pd import pandas as pd
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@@ -26,6 +27,11 @@ router = APIRouter(tags=["bars"])
# 规整后保持的列顺序(匹配旧 SecurityBar 输出契约) # 规整后保持的列顺序(匹配旧 SecurityBar 输出契约)
_NORMAL_COLS = ["open", "close", "high", "low", "vol", "amount"] _NORMAL_COLS = ["open", "close", "high", "low", "vol", "amount"]
# 120 分钟线的 category 别名(协议无此枚举,路由层特判)
_MIN_120_ALIASES = frozenset({"MIN_120", "120M", "120MIN"})
# 标准 TdxClient 单次取数上限(60M×2 重采样路径的抓取上限)
_MAX_BARS_PER_FETCH = 800
def _df_resp(df: Any) -> DataFrameResponse: def _df_resp(df: Any) -> DataFrameResponse:
return DataFrameResponse.from_dataframe(df) return DataFrameResponse.from_dataframe(df)
@@ -71,13 +77,151 @@ def _normalize_mac_df(df: pd.DataFrame, daily_plus: bool) -> pd.DataFrame:
return out[cols] return out[cols]
def _resample_pairs(df: pd.DataFrame, count: int) -> pd.DataFrame:
"""相邻两根分钟 bar 聚合成一根(60M×2 → 120M)。
分组规则:从最新端对齐两两配对(奇数根丢最旧一根,保最新数据),
聚合口径 open=first / high=max / low=min / close=last / vol·amount=sum
时间列取配对中后一根。要求 df 按时间升序、含 datetime 列。
Args:
df: 已规整的 60M DataFrame(升序,datetime 列)。
count: 目标 120M 根数(超出的旧数据裁掉)。
Returns:
重采样后的 DataFrame;输入为空时原样返回。
"""
if df is None or df.empty:
return df
out = df.reset_index(drop=True)
if len(out) % 2:
out = out.iloc[1:].reset_index(drop=True) # 丢最旧一根,两两对齐
group = np.arange(len(out)) // 2
agg: dict[str, str] = {"datetime": "last"}
for col, how in (
("open", "first"),
("high", "max"),
("low", "min"),
("close", "last"),
("vol", "sum"),
("amount", "sum"),
):
if col in out.columns:
agg[col] = how
res = out.assign(_g=group).groupby("_g").agg(agg).reset_index(drop=True)
if len(res) > count:
res = res.tail(count).reset_index(drop=True)
return res
def _attach_derived(df: pd.DataFrame) -> pd.DataFrame:
"""每根 bar 附带衍生字段:pre_close / change / change_pct / amplitude_pct。
- ``pre_close``:前一根收盘;首根退化为本根开盘(涨跌记 0)。
- ``change_pct``(close/pre_close - 1)×100。
- ``amplitude_pct``(high - low)/pre_close×100。
- pre_close ≤ 0.01 时按 0.01 兜底(复权后首段价格可能为 0/负,
除零保护;QFQ 负价兜底场景见 /bars 文档)。
"""
if df is None or df.empty or "close" not in df.columns:
return df
out = df.reset_index(drop=True).copy()
close = pd.to_numeric(out["close"], errors="coerce")
pre = close.shift(1)
if "open" in out.columns:
pre = pre.fillna(pd.to_numeric(out["open"], errors="coerce"))
safe_pre = pre.where(pre > 0.01, 0.01)
out["pre_close"] = pre
out["change"] = (close - pre).round(4)
out["change_pct"] = ((close / safe_pre - 1.0) * 100).round(4)
if "high" in out.columns and "low" in out.columns:
high = pd.to_numeric(out["high"], errors="coerce")
low = pd.to_numeric(out["low"], errors="coerce")
out["amplitude_pct"] = ((high - low) / safe_pre * 100).round(4)
return out
async def _fetch_120m(
market: str,
code: str,
start: int,
count: int,
adjust: str,
bar_time: str,
mac_client: Any,
client: Any,
) -> pd.DataFrame:
"""120 分钟 K 线:MAC 原生 times=120 优先,2×60M 重采样兜底。"""
market_value = market_value_from_str(market)
if mac_client is not None:
from easy_tdx.mac.enums import Period
# 1) MAC 原生多分钟线(Period.MINS + times=120
try:
df = await mac_client.get_stock_kline(
market_value,
code,
Period.MINS,
start,
count,
120,
adjust=adjust_from_str(adjust),
bar_time=bar_time,
)
if df is not None and not df.empty:
return _normalize_mac_df(df, daily_plus=False)
_logger.info("/bars MIN_120 原生路径返回空,转 60M 重采样 (%s%s)", market, code)
except Exception as exc: # noqa: BLE001 — 原生不可用时降级,不中断
_logger.warning(
"/bars MIN_120 原生获取失败,转 60M 重采样 (%s%s): %s", market, code, exc
)
# 2) MAC 60M×2 重采样(自动分页,可一次取足 count×2)
try:
df = await mac_client.get_stock_kline(
market_value,
code,
Period.MIN_60,
start,
count * 2,
1,
adjust=adjust_from_str(adjust),
bar_time=bar_time,
)
res = _resample_pairs(_normalize_mac_df(df, daily_plus=False), count)
if res is not None and not res.empty:
return res
except Exception as exc: # noqa: BLE001
_logger.warning("/bars MIN_120 60M重采样(MAC)失败 (%s%s): %s", market, code, exc)
# 3) 标准 TdxClient 60M×2(无 MAC;单次上限 800 根 → 最多 400 根 120M
fetch_n = min(count * 2, _MAX_BARS_PER_FETCH)
if fetch_n < count * 2:
_logger.info(
"/bars MIN_120 回退路径单次上限 %d 根 60M,最多合成 %d 根 120M",
_MAX_BARS_PER_FETCH,
_MAX_BARS_PER_FETCH // 2,
)
df = await client.get_security_bars(
market_from_str(market), code, category_from_str("MIN_60"), start, fetch_n,
bar_time=bar_time,
)
return _resample_pairs(df, count)
@router.get("/bars", response_model=DataFrameResponse) @router.get("/bars", response_model=DataFrameResponse)
async def security_bars( async def security_bars(
market: str = Query(..., description="市场: SZ, SH, BJ"), market: str = Query(..., description="市场: SZ, SH, BJ"),
code: str = Query(..., min_length=6, max_length=6), code: str = Query(..., min_length=6, max_length=6),
category: str = Query( category: str = Query(
"DAY", "DAY",
description="K线周期: MIN_1, MIN_5, MIN_15, MIN_30, MIN_60, DAY, WEEK, MONTH, YEAR", description=(
"K线周期: MIN_1, MIN_5, MIN_15, MIN_30, MIN_60, MIN_120(120分钟), "
"DAY, WEEK, MONTH, SEASON, YEAR"
),
), ),
start: int = Query(0, ge=0), start: int = Query(0, ge=0),
count: int = Query(800, ge=1, le=800), count: int = Query(800, ge=1, le=800),
@@ -96,9 +240,21 @@ async def security_bars(
MAC 主机未连接时自动回退 AsyncTdxClient.get_security_bars(无复权,adjust 参数忽略)。 MAC 主机未连接时自动回退 AsyncTdxClient.get_security_bars(无复权,adjust 参数忽略)。
输出契约与旧版一致:日线返回 ``date`` 列,分钟线返回 ``datetime`` 列。 输出契约与旧版一致:日线返回 ``date`` 列,分钟线返回 ``datetime`` 列。
``category=MIN_120`` 为 120 分钟线:MAC 原生 ``Period.MINS × times=120``
优先,失败则取 2 倍 60M 数据相邻两根聚合(open=first/high=max/low=min/
close=last/vol·amount=sum),标准客户端回退路径最多合成 400 根。
每根 bar 附带衍生字段:``pre_close``(前收,首根=本根开盘)、``change``、
``change_pct``、``amplitude_pct``(振幅%)。pre_close ≤ 0.01 时按 0.01
兜底(QFQ 复权后早期价格可能为 0/负)。
vol 单位:分钟线/日线 = 成交量(股);周/月/季/年线服务端原样返回真实 vol 单位:分钟线/日线 = 成交量(股);周/月/季/年线服务端原样返回真实
成交量/100,回退路径(标准 TdxClient)已 ×100 还原为股。 成交量/100,回退路径(标准 TdxClient)已 ×100 还原为股。
""" """
if category.upper() in _MIN_120_ALIASES:
df = await _fetch_120m(market, code, start, count, adjust, bar_time, mac_client, client)
return _df_resp(_attach_derived(df))
cat = category_from_str(category) cat = category_from_str(category)
if mac_client is not None: if mac_client is not None:
period, times = period_times_from_category(cat) period, times = period_times_from_category(cat)
@@ -123,7 +279,7 @@ async def security_bars(
df = await client.get_security_bars( df = await client.get_security_bars(
market_from_str(market), code, cat, start, count, bar_time=bar_time market_from_str(market), code, cat, start, count, bar_time=bar_time
) )
return _df_resp(df) return _df_resp(_attach_derived(df))
@router.get("/bars/index", response_model=DataFrameResponse) @router.get("/bars/index", response_model=DataFrameResponse)
@@ -143,11 +299,13 @@ async def index_bars(
vol 单位:日线/周线/月线/季线/年线 = 成交量(手)(周及以上周期服务端 vol 单位:日线/周线/月线/季线/年线 = 成交量(手)(周及以上周期服务端
原样返回真实成交量/100,已 ×100 还原);**分钟线协议不提供成交量** 原样返回真实成交量/100,已 ×100 还原);**分钟线协议不提供成交量**
(报文中该字段实为成交额/100),vol 为 ``null``,请勿当作成交量使用。 (报文中该字段实为成交额/100),vol 为 ``null``,请勿当作成交量使用。
每根 bar 同样附带 ``pre_close/change/change_pct/amplitude_pct`` 衍生字段。
""" """
df = await client.get_index_bars( df = await client.get_index_bars(
market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time
) )
return _df_resp(df) return _df_resp(_attach_derived(df))
@router.get("/minute", response_model=DataFrameResponse) @router.get("/minute", response_model=DataFrameResponse)
+273
View File
@@ -0,0 +1,273 @@
"""LLM 配置与对话路由(WebUI「AI 设置」页 + AI 解读直连)。"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel, Field
from easy_tdx.ai.llm import (
PROVIDER_PRESETS,
LlmClient,
LlmConfig,
LlmError,
config_path,
load_config,
mask_key,
resolve_config,
save_config,
)
from easy_tdx.web.backtest_schemas import TaskStateResponse, TaskSubmitResponse
from easy_tdx.web.task_runner import get_runner
logger = logging.getLogger(__name__)
router = APIRouter(tags=["llm"])
class LlmConfigUpdate(BaseModel):
"""PUT /llm/config 请求体。
``api_key`` 缺省或等于当前脱敏回显值时保留原 key——前端把脱敏串原样
回传不会把真 key 冲掉;只有填了新值才覆盖。
"""
provider: str = Field("deepseek", description="Provider id(见 GET /llm/config providers")
api_url: str = Field("", description="API 地址,空 = 用该 Provider 预设")
api_key: str = Field("", description="API Key(留空/传回脱敏串 = 不修改已存 key)")
model: str = Field("", description="模型名,空 = 用该 Provider 默认模型")
temperature: float = Field(0.3, ge=0.0, le=2.0)
max_tokens: int = Field(
16000, ge=64, le=128_000, description="输出上限;思考型模型的思考链计入此预算,建议 ≥16000"
)
timeout: float = Field(180.0, ge=5.0, le=600.0, description="读超时;报告解读建议 ≥120")
system_prompt: str = ""
class LlmChatContext(BaseModel):
"""AI 解读附带的策略上下文(历史页「去回测」引导用,全部可缺省)。"""
strategy: str = Field("", description="策略注册表 key(如 ma_cross")
strategy_label: str = Field("", description="策略中文名")
symbol: str = Field("", description="6 位标的代码")
category: str = Field("", description="K 线周期")
params: dict[str, Any] = Field(default_factory=dict, description="策略参数")
start_date: str = ""
end_date: str = ""
class LlmChatRequest(BaseModel):
"""POST /llm/chat(/async) 请求体(如把 AI 解读 Prompt 直接发给已配置的 LLM)。"""
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="策略上下文(随成功解读一并落历史库)"
)
def _merge_api_key(submitted: str, current: str) -> str:
"""按表单语义合并 api_key:留空/回传脱敏串 = 沿用;CLEAR = 清除;其余 = 覆盖。
前端把脱敏串原样回传不会把真 key 冲掉;显式填 ``CLEAR`` 可移除已存
key(否则换 Provider 时旧 key 会残留且 UI 无清除入口)。
"""
key = submitted.strip()
if not key or key == mask_key(current):
return current
if key.upper() == "CLEAR":
return ""
return key
def _override_config(override: LlmConfigUpdate | None) -> LlmConfig | None:
"""请求体临时配置 → LlmConfig(不落盘)。api_key 走 _merge_api_key 语义。"""
if override is None:
return None
current = load_config()
key = _merge_api_key(override.api_key, current.api_key)
return LlmConfig(
provider=override.provider,
api_url=override.api_url.strip(),
api_key=key,
model=override.model.strip(),
temperature=override.temperature,
max_tokens=override.max_tokens,
timeout=override.timeout,
)
@router.get("/llm/config")
async def get_llm_config() -> dict[str, Any]:
"""当前 LLM 配置(key 脱敏)+ Provider 预设表 + 配置文件路径。"""
cfg = load_config()
preset = PROVIDER_PRESETS.get(cfg.provider, PROVIDER_PRESETS["custom"])
try:
resolved = resolve_config(cfg)
missing: list[str] = []
if preset.needs_key and not cfg.api_key:
missing.append("api_key")
except ValueError as exc:
resolved = cfg # type: ignore[assignment]
missing = [str(exc)]
return {
"config": cfg.to_dict(mask_api_key=True),
"providers": [p.to_dict() for p in PROVIDER_PRESETS.values()],
"configured": not missing,
"missing": missing,
"config_path": str(config_path()),
"resolved": {"api_url": resolved.api_url, "model": resolved.model},
}
@router.put("/llm/config")
async def update_llm_config(req: LlmConfigUpdate) -> dict[str, Any]:
"""保存 LLM 配置到 llm.json(WebUI 与手工编辑同一份文件,双向兼容)。"""
if req.provider not in PROVIDER_PRESETS:
valid = ", ".join(PROVIDER_PRESETS)
raise ValueError(f"未知 provider '{req.provider}',可选: {valid}")
current = load_config()
new_key = _merge_api_key(req.api_key, current.api_key)
cfg = LlmConfig(
provider=req.provider,
api_url=req.api_url.strip(),
api_key=new_key,
model=req.model.strip(),
temperature=req.temperature,
max_tokens=req.max_tokens,
timeout=req.timeout,
system_prompt=req.system_prompt or current.system_prompt,
)
path = save_config(cfg)
logger.info("LLM 配置已保存: provider=%s model=%s (%s)", cfg.provider, cfg.model, path)
return {"ok": True, "config_path": str(path), "config": cfg.to_dict(mask_api_key=True)}
def _record_history(
provider: str, model: str, prompt: str, reply: str, elapsed: float,
ctx: LlmChatContext | None,
) -> None:
"""成功解读旁路落库(llm_history.db)。失败只记日志,不影响解读结果。"""
from easy_tdx.web.llm_history_store import LlmHistoryRecord, get_llm_history_store
try:
get_llm_history_store().add(
LlmHistoryRecord(
provider=provider,
model=model,
prompt=prompt,
reply=reply,
elapsed=elapsed,
**(ctx.model_dump() if ctx else {}),
)
)
except Exception: # noqa: BLE001 — 历史属旁路语义
logger.exception("AI 解读历史落库失败(不影响解读结果)")
@router.post("/llm/test")
async def test_llm(override: LlmConfigUpdate | None = None) -> dict[str, Any]:
"""连通性测试:用已保存配置(或请求体内临时配置)发一句极短 ping。"""
return await LlmClient(_override_config(override)).test()
@router.post("/llm/chat")
async def llm_chat(req: LlmChatRequest) -> dict[str, Any]:
"""一轮 LLM 对话:把 prompt(如回测报告解读 Prompt)发给已配置的模型。"""
client = LlmClient(_override_config(req.override))
try:
t0 = time.perf_counter()
reply = await client.chat(req.prompt, system_prompt=req.system_prompt)
except LlmError as exc:
# 全局 ValueError 处理器 → 400 {error, detail},前端 formatError 可读展示
raise ValueError(str(exc)) from exc
elapsed = round(time.perf_counter() - t0, 1)
_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}
@router.post("/llm/chat/async", response_model=TaskSubmitResponse, status_code=202)
async def llm_chat_async(req: LlmChatRequest) -> TaskSubmitResponse:
"""提交 AI 解读后台任务(长耗时模型调用不占住 HTTP 连接)。
大报告解读 1-3 分钟,同步 HTTP 等待对代理/浏览器都不友好;这里接入
与回测同一套任务执行器(``task_runner``4 线程池 + SQLite 持久化),
前端短轮询 ``GET /llm/chat/tasks/{task_id}`` 取状态,断线重连后仍可
查询。配置不完整(缺 url/model)在提交时即报 400;网络/鉴权/超时
类错误发生在任务内,体现在 TaskState.error。
"""
client = LlmClient(_override_config(req.override)) # 提交期即校验配置
desc = f"AI 解读 | {client.config.provider} · {client.config.model} | {len(req.prompt)}"
def _run() -> dict[str, Any]:
t0 = time.perf_counter()
reply = asyncio.run(client.chat(req.prompt, system_prompt=req.system_prompt))
elapsed = round(time.perf_counter() - t0, 1)
_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}
runner = get_runner()
task_id = runner.submit(_run, description=desc)
state = runner.get(task_id)
status: Any = state.status if state.status in ("pending", "running") else "running"
return TaskSubmitResponse(task_id=task_id, status=status)
@router.get("/llm/chat/tasks/{task_id}", response_model=TaskStateResponse)
async def llm_chat_task(task_id: str) -> TaskStateResponse:
"""查询 AI 解读任务状态(与回测任务同一存储,语义化路径别名)。"""
runner = get_runner()
try:
state = runner.get(task_id)
except KeyError as exc:
raise ValueError(str(exc)) from exc
return TaskStateResponse(
task_id=state.task_id,
status=state.status,
result=state.result,
error=state.error,
description=state.description,
elapsed=(state.finished_at or time.time()) - (state.started_at or state.created_at),
)
@router.get("/llm/history")
async def list_llm_history(limit: int = 50) -> dict[str, Any]:
"""AI 解读历史(时间倒序)。每条含 Prompt、解读正文与策略上下文。"""
from easy_tdx.web.llm_history_store import get_llm_history_store
items = get_llm_history_store().list_all(limit=min(max(limit, 1), 200))
return {"items": [r.to_dict() for r in items], "count": len(items)}
@router.delete("/llm/history/{record_id}")
async def delete_llm_history(record_id: int) -> dict[str, Any]:
"""删除一条历史记录。"""
from easy_tdx.web.llm_history_store import get_llm_history_store
if not get_llm_history_store().delete(record_id):
raise ValueError(f"历史记录 {record_id} 不存在")
return {"ok": True}
@router.delete("/llm/history")
async def clear_llm_history() -> dict[str, Any]:
"""清空全部历史记录。"""
from easy_tdx.web.llm_history_store import get_llm_history_store
deleted = get_llm_history_store().clear()
return {"ok": True, "deleted": deleted}
+29 -1
View File
@@ -76,6 +76,34 @@ async def market_stat(
return _df_response(df) return _df_response(df)
@router.get("/market/session")
async def market_session() -> dict[str, Any]:
"""A 股有效行情时段判断(供前端自动刷新门控校准)。
窗口 09:15~11:30、13:00~15:05(含集合竞价缓冲,午休除外),周一至周五。
节假日不做日历判断——盘外误判为盘中只会多拉一次快照,无副作用。
"""
from easy_tdx.realtime.session import session_info
return session_info()
@router.get("/market/core-leaders", response_model=DataFrameResponse)
async def core_leaders() -> DataFrameResponse:
"""核心龙头池(159 只,按东方财富全行业龙头名单整理)。
数据资产供前端展示/导出;扫描场景走 ``universe="core"``screen scan
与 /market/strength 均支持)。
"""
from easy_tdx.screen.universe import CORE_LEADERS
rows = [
{"code": code, "name": name, "market": "SH" if code.startswith(("6", "9")) else "SZ"}
for code, name in CORE_LEADERS.items()
]
return DataFrameResponse(data=rows, count=len(rows))
@router.get("/fund-flow", response_model=DataFrameResponse) @router.get("/fund-flow", response_model=DataFrameResponse)
async def fund_flow( async def fund_flow(
market: str = Query(..., description="市场: SZ, SH"), market: str = Query(..., description="市场: SZ, SH"),
@@ -117,7 +145,7 @@ async def market_strength(
w60: float | None = Query(None, description="自定义 60 日权重(覆盖预设)"), w60: float | None = Query(None, description="自定义 60 日权重(覆盖预设)"),
vol_adjusted: bool | None = Query(None, description="波动率惩罚开关(覆盖预设)"), vol_adjusted: bool | None = Query(None, description="波动率惩罚开关(覆盖预设)"),
top_n: int = Query(50, ge=1, le=5000, description="返回前 N 名"), top_n: int = Query(50, ge=1, le=5000, description="返回前 N 名"),
universe: str = Query("all", description="范围: all/sh/sz"), universe: str = Query("all", description="范围: all/sh/sz/corecore=核心龙头池159只)"),
min_listed_days: int = Query(65, ge=30, description="最小上市天数"), min_listed_days: int = Query(65, ge=30, description="最小上市天数"),
min_amount: float = Query(0.0, ge=0, description="最近 5 日日均成交额下限(元)"), min_amount: float = Query(0.0, ge=0, description="最近 5 日日均成交额下限(元)"),
vipdoc: str | None = Query(None, description="离线数据目录(默认自动检测)"), vipdoc: str | None = Query(None, description="离线数据目录(默认自动检测)"),
+1
View File
@@ -24,6 +24,7 @@ easy-tdx backtest SH 600519 --strategy-file strategies/macd_cross.py --cash 5000
| `bias_reversal.py` | 乖离率反转 | 反转 | 震荡回归 | | `bias_reversal.py` | 乖离率反转 | 反转 | 震荡回归 |
| `volume_price.py` | 量价配合 | 综合判断 | 放量突破 | | `volume_price.py` | 量价配合 | 综合判断 | 放量突破 |
| `obv_trend.py` | OBV 能量潮趋势 | 量价趋势 | 资金持续流入的上升趋势 | | `obv_trend.py` | OBV 能量潮趋势 | 量价趋势 | 资金持续流入的上升趋势 |
| `zig_breakout.py` | ZIG 右侧突破回补 | 右侧突破/波段 | 波谷启动建仓,见顶卖出后右侧突破前高回补(带硬止损;ZIG 为未来函数,回测信号有前视性) |
## 编写自定义策略 ## 编写自定义策略
+83
View File
@@ -0,0 +1,83 @@
"""ZIG 右侧突破回补策略(Re-entry on Breakout + 硬止损保护)。
交易逻辑
--------
1. **空仓**:ZIG 向上启动(底部波谷确认)→ 全仓买入建仓,挂硬止损(默认 3%)。
2. **持仓**:ZIG 见顶回落 → 全仓卖出,并记录 N 日最高价为 breakout_level。
3. **空仓等待回补**:收盘价突破 breakout_level × (1 + confirm_pct/100)
→ 右侧突破确认,洗盘结束主升确立,全仓买入回补(同样带硬止损)。
4. **风控保护**:买入后未见顶但跌破止损线,由引擎自动触发止损平仓,
对冲 ZIG 波谷确认的前视偏差(ZIG 是未来函数,拐点回溯标出)。
注意:与内置注册表中的 ``zig_breakout````easy_tdx.backtest.strategies``
同一套逻辑;本文件供 ``--strategy-file`` 离线扫描(``easy-tdx screen scan``
使用,参数硬编码为默认档。
用法::
easy-tdx backtest SZ 300223 --strategy-file strategies/zig_breakout.py --table
easy-tdx screen scan --strategy strategies/zig_breakout.py --universe core
"""
from easy_tdx.backtest import Strategy
from easy_tdx.MyTT import HHV, ZIG
class ZigBreakoutStrategy(Strategy):
"""ZIG 右侧突破回补策略(含硬止损保护)。"""
def __init__(
self,
zig_delta: float = 10.0,
confirm_pct: float = 2.0,
hhv_period: int = 20,
stop_loss_pct: float = 3.0,
) -> None:
super().__init__()
self.zig_delta = zig_delta
self.confirm_pct = confirm_pct
self.hhv_period = hhv_period
self.stop_loss_pct = stop_loss_pct
def init(self) -> None:
self.zig = self.I(ZIG, self.data.close, self.zig_delta)
self.hhv = self.I(HHV, self.data.high, self.hhv_period)
self._breakout_level: float = 0.0
def next(self) -> None:
i = self._bar_index
if i == 0:
return
cur_close = float(self.data.close[0])
cur_zig = float(self.zig[i])
prev_zig = float(self.zig[i - 1])
cur_pos = self.position["size"]
# 持仓:ZIG 见顶 → 全仓卖出,记录突破位
if cur_pos > 0 and cur_zig < prev_zig:
self._breakout_level = float(self.hhv[i])
self.sell(size=0)
return
# 空仓:两种买入路径(均带硬止损)
if cur_pos == 0:
# 路径 1:ZIG 向上启动(底部波谷确认)→ 初始建仓
if cur_zig > prev_zig:
self._breakout_level = 0.0
self._buy_with_stop()
return
# 路径 2:右侧突破前高 → 回补建仓(洗盘结束、主升确立)
if self._breakout_level > 0:
threshold = self._breakout_level * (1.0 + self.confirm_pct / 100.0)
if cur_close >= threshold:
self._breakout_level = 0.0
self._buy_with_stop()
def _buy_with_stop(self) -> None:
pct = self.stop_loss_pct / 100.0
if pct > 0:
self.buy(size=0, stop_loss_pct=pct)
else:
self.buy(size=0)
+13
View File
@@ -336,6 +336,19 @@
"ulcer_index": 0.0, "ulcer_index": 0.0,
"var_95": -0.0, "var_95": -0.0,
"win_rate": 0.0 "win_rate": 0.0
},
"zig_breakout": {
"cvar_95": 0.028842972139600086,
"max_consecutive_losses": 0,
"max_consecutive_wins": 5,
"max_drawdown": 0.08762079023098233,
"sharpe": 1.726724943367625,
"sqn": 3.2661255981598982,
"total_return": 0.881929152129431,
"total_trades": 5,
"ulcer_index": 0.032735452584468416,
"var_95": 0.021750558683001117,
"win_rate": 1.0
} }
} }
} }
+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: 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 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(): for name in get_registry().names():
if name in path_dependent:
continue
strat_cls = get_registry().get(name).strategy_cls strat_cls = get_registry().get(name).strategy_cls
assert strat_cls.entry_exit_masks is not Base.entry_exit_masks, ( assert strat_cls.entry_exit_masks is not Base.entry_exit_masks, (
f"{name} 未实现 entry_exit_masksauto 将永远走逐 bar" 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
+55 -29
View File
@@ -17,43 +17,69 @@ const sseLabel: Record<string, string> = {
<template> <template>
<div class="app"> <div class="app">
<aside class="sidebar"> <div class="app-row">
<div class="brand"> <aside class="sidebar">
<span class="brand-name">easy-tdx</span> <div class="brand">
<span class="brand-sub">行情终端</span> <span class="brand-name">easy-tdx</span>
</div> <span class="brand-sub">行情终端</span>
<nav class="side-nav"> </div>
<div class="nav-group">行情</div> <nav class="side-nav">
<RouterLink to="/" exact-active-class="active">市场看板</RouterLink> <div class="nav-group">行情</div>
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink> <RouterLink to="/" exact-active-class="active">市场看板</RouterLink>
<div class="nav-group">分析</div> <RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
<RouterLink to="/backtest" active-class="active">单标的回测</RouterLink> <RouterLink to="/leaders" active-class="active">龙头池</RouterLink>
<RouterLink to="/portfolio" active-class="active">组合回测</RouterLink> <div class="nav-group">分析</div>
<RouterLink to="/optimize" active-class="active">参数寻优</RouterLink> <RouterLink to="/backtest" active-class="active">单标的回测</RouterLink>
<RouterLink to="/compare" active-class="active">结果对比</RouterLink> <RouterLink to="/portfolio" active-class="active">组合回测</RouterLink>
<RouterLink to="/strategies" active-class="active">策略库</RouterLink> <RouterLink to="/optimize" active-class="active">参数寻优</RouterLink>
<RouterLink to="/signals" active-class="active">信号雷达</RouterLink> <RouterLink to="/compare" active-class="active">结果对比</RouterLink>
<div class="nav-group">系统</div> <RouterLink to="/strategies" active-class="active">策略库</RouterLink>
<RouterLink to="/settings" active-class="active">服务器设置</RouterLink> <RouterLink to="/signals" active-class="active">信号雷达</RouterLink>
</nav> <RouterLink to="/ai-history" active-class="active">AI 解读历史</RouterLink>
<div class="side-footer"> <div class="nav-group">系统</div>
<span class="dot" :class="quoteStore.status"></span> <RouterLink to="/settings" active-class="active">服务器设置</RouterLink>
<span class="sse-label">{{ sseLabel[quoteStore.status] ?? '离线' }}</span> <RouterLink to="/llm" active-class="active">AI 设置</RouterLink>
<span v-if="quoteStore.lastTs" class="sse-ts">{{ quoteStore.lastTs.slice(11, 19) }}</span> </nav>
<span v-if="quoteStore.quoteCount" class="sse-n">×{{ quoteStore.quoteCount }}</span> <div class="side-footer">
</div> <span class="dot" :class="quoteStore.status"></span>
</aside> <span class="sse-label">{{ sseLabel[quoteStore.status] ?? '离线' }}</span>
<main class="app-main"> <span v-if="quoteStore.lastTs" class="sse-ts">{{ quoteStore.lastTs.slice(11, 19) }}</span>
<RouterView /> <span v-if="quoteStore.quoteCount" class="sse-n">×{{ quoteStore.quoteCount }}</span>
</main> </div>
</aside>
<main class="app-main">
<RouterView />
</main>
</div>
<!-- 全局风险提示所有页面常驻行情/回测/个股相关内容均在此覆盖范围 -->
<footer class="global-disclaimer">
本工具输出行情数据 / 指标 / 回测 / 选股扫描 / AI 解读仅供量化研究与学习
不构成任何投资建议或个股推荐历史表现不代表未来股市有风险据此操作风险自负
</footer>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.app { .app {
display: flex; display: flex;
flex-direction: column;
height: 100vh; height: 100vh;
} }
.app-row {
flex: 1;
display: flex;
min-height: 0;
}
.global-disclaimer {
flex-shrink: 0;
padding: 5px 16px;
background: var(--bg-panel);
border-top: 1px solid var(--border);
font-size: 10.5px;
color: var(--text-dim);
text-align: center;
letter-spacing: 0.2px;
}
.sidebar { .sidebar {
width: 176px; width: 176px;
flex-shrink: 0; flex-shrink: 0;
+1
View File
@@ -55,6 +55,7 @@ const CATEGORY_LABELS: Record<Category, string> = {
MIN_15: '15 分钟', MIN_15: '15 分钟',
MIN_30: '30 分钟', MIN_30: '30 分钟',
MIN_60: '60 分钟', MIN_60: '60 分钟',
MIN_120: '120 分钟',
} }
const EXECUTION_LABELS: Record<ExecutionMode, string> = { const EXECUTION_LABELS: Record<ExecutionMode, string> = {
+142
View File
@@ -8,7 +8,15 @@ import type {
Bar, Bar,
BoardRow, BoardRow,
Category, Category,
CoreLeaderRow,
DataFrameResponse, DataFrameResponse,
LlmChatResponse,
LlmChatContext,
LlmHistoryResponse,
LlmConfigResponse,
LlmConfigUpdate,
LlmTestResult,
MarketSessionInfo,
MarketStat, MarketStat,
MinutePoint, MinutePoint,
MultiStrategyBacktestRequest, MultiStrategyBacktestRequest,
@@ -604,3 +612,137 @@ export async function removeWatchItem(market: string, code: string): Promise<voi
const resp = await fetch(`${BASE}/watchlist/${market}/${code}`, { method: 'DELETE' }) const resp = await fetch(`${BASE}/watchlist/${market}/${code}`, { method: 'DELETE' })
if (!resp.ok) await throwError(resp) if (!resp.ok) await throwError(resp)
} }
// ── 交易时段(Dashboard 自动刷新门控) ──────────────────────────────────────
/** 服务器侧交易时段判断(前端本地判断为主,本接口用于校准展示)。 */
export async function fetchMarketSession(): Promise<MarketSessionInfo> {
const resp = await fetch(`${BASE}/market/session`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as MarketSessionInfo
}
// ── LLM 配置与对话(AI 设置页 / AI 解读直连) ───────────────────────────────
/** 当前 LLM 配置(key 脱敏)+ Provider 预设表。 */
export async function fetchLlmConfig(): Promise<LlmConfigResponse> {
const resp = await fetch(`${BASE}/llm/config`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as LlmConfigResponse
}
/** 保存 LLM 配置(写入 ~/.easy_tdx/llm.json,与手工编辑同一份文件)。 */
export async function saveLlmConfig(req: LlmConfigUpdate): Promise<LlmConfigResponse> {
const resp = await fetch(`${BASE}/llm/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
await resp.json() // 丢弃 PUT 响应体,统一以 GET 回读为准(providers/missing 同步刷新)
return fetchLlmConfig()
}
/** 连通性测试(用已保存配置或请求体临时配置发一句 ping)。 */
export async function testLlm(override?: LlmConfigUpdate): Promise<LlmTestResult> {
const resp = await fetch(`${BASE}/llm/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(override ?? null),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as LlmTestResult
}
/** 一轮 LLM 对话(如把 AI 解读 Prompt 直接发给已配置的模型)。 */
export async function chatLlm(
prompt: string,
systemPrompt?: string | null,
): Promise<LlmChatResponse> {
const resp = await fetch(`${BASE}/llm/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, system_prompt: systemPrompt ?? null }),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as LlmChatResponse
}
/** 提交 AI 解读后台任务(长耗时模型调用不占 HTTP 连接),返回 task_id。 */
export async function submitLlmChatTask(
prompt: string,
context?: LlmChatContext | null,
systemPrompt?: string | null,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/llm/chat/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, system_prompt: systemPrompt ?? null, context: context ?? null }),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 查询 AI 解读任务状态(轮询用)。 */
export async function fetchLlmChatTask(taskId: string): Promise<TaskState> {
const resp = await fetch(`${BASE}/llm/chat/tasks/${taskId}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskState
}
/**
* 提交 AI 解读并轮询直到 done/failed。
*
* 大报告解读 1-3 分钟属正常:轮询间隔放宽到 1.5s(回测是 0.3s),
* 前端上限 20 分钟兜底(后端 LLM 读超时最大 600s,正常应先于此前返回)。
*/
export async function runLlmChatWithPolling(
prompt: string,
context?: LlmChatContext | null,
onPoll?: (state: TaskState) => void,
intervalMs = 1_500,
timeoutMs = 20 * 60_000,
): Promise<TaskState> {
const { task_id } = await submitLlmChatTask(prompt, context)
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchLlmChatTask(task_id)
onPoll?.(state)
if (state.status === 'done' || state.status === 'failed') return state
if (Date.now() - start > timeoutMs) {
throw new Error(`AI 解读任务超时(${timeoutMs / 1000}s),任务仍在后台运行,可稍后重试`)
}
await new Promise((r) => setTimeout(r, intervalMs))
}
}
// ── AI 解读历史 ──────────────────────────────────────────────────────────────
/** 列出 AI 解读历史(时间倒序,含 Prompt/正文/策略上下文)。 */
export async function fetchLlmHistory(limit = 50): Promise<LlmHistoryResponse> {
const resp = await fetch(`${BASE}/llm/history?limit=${limit}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as LlmHistoryResponse
}
/** 删除一条历史记录。 */
export async function deleteLlmHistory(id: number): Promise<void> {
const resp = await fetch(`${BASE}/llm/history/${id}`, { method: 'DELETE' })
if (!resp.ok) await throwError(resp)
}
/** 清空全部历史。 */
export async function clearLlmHistory(): Promise<number> {
const resp = await fetch(`${BASE}/llm/history`, { method: 'DELETE' })
if (!resp.ok) await throwError(resp)
return (await resp.json()).deleted as number
}
/** 核心龙头池(159 只)。 */
export async function fetchCoreLeaders(): Promise<CoreLeaderRow[]> {
const resp = await fetch(`${BASE}/market/core-leaders`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data as unknown as CoreLeaderRow[]
}
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
// 风险提示组件:prominent=true 用于语义重点页(龙头池/AI 解读历史等),
// 默认紧凑样式。文案可通过默认插槽整体替换。
withDefaults(defineProps<{ prominent?: boolean }>(), { prominent: false })
</script>
<template>
<div v-if="prominent" class="risk prominent">
<slot>
<strong> 风险提示</strong>
<p>
本工具提供的全部行情数据指标计算回测与扫描结果仅供量化研究与学习
不构成任何投资建议个股推荐或收益承诺历史表现不代表未来数据可能存在
缺失或错误股市有风险入市需谨慎据此操作风险自负
</p>
</slot>
</div>
<div v-else class="risk">
<slot>
仅供研究学习不构成投资建议股市有风险据此操作风险自负
</slot>
</div>
</template>
<style scoped>
.risk {
font-size: 11px;
color: var(--text-dim);
line-height: 1.6;
border-top: 1px solid var(--border);
padding-top: 6px;
margin-top: 10px;
}
.risk.prominent {
background: rgba(255, 193, 7, 0.07);
border: 1px solid rgba(255, 193, 7, 0.35);
border-radius: var(--radius);
padding: 10px 12px;
margin-top: 0;
}
.risk.prominent strong {
color: var(--warn, #ffc107);
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.risk.prominent p {
margin: 0;
color: var(--text-muted);
font-size: 12px;
line-height: 1.8;
}
</style>
+1 -1
View File
@@ -34,7 +34,7 @@ const error = ref('')
// loading 由父组件控制(回测/寻优时驱动),组件自身只暴露 loadBars // loading 由父组件控制(回测/寻优时驱动),组件自身只暴露 loadBars
const loading = ref(false) const loading = ref(false)
const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60'] const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60', 'MIN_120']
// 智能识别的市场(用于提示展示) // 智能识别的市场(用于提示展示)
const detectedMarket = computed(() => (code.value && /^\d{6}$/.test(code.value) const detectedMarket = computed(() => (code.value && /^\d{6}$/.test(code.value)
+12 -1
View File
@@ -2,7 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
import BacktestView from './views/BacktestView.vue' import BacktestView from './views/BacktestView.vue'
import CompareView from './views/CompareView.vue' import CompareView from './views/CompareView.vue'
import CoreLeadersView from './views/CoreLeadersView.vue'
import DashboardView from './views/DashboardView.vue' import DashboardView from './views/DashboardView.vue'
import LlmHistoryView from './views/LlmHistoryView.vue'
import LlmSettingsView from './views/LlmSettingsView.vue'
import OptimizeView from './views/OptimizeView.vue' import OptimizeView from './views/OptimizeView.vue'
import PortfolioView from './views/PortfolioView.vue' import PortfolioView from './views/PortfolioView.vue'
import ServerSettingsView from './views/ServerSettingsView.vue' import ServerSettingsView from './views/ServerSettingsView.vue'
@@ -12,7 +15,8 @@ import WatchlistView from './views/WatchlistView.vue'
// 行情终端:市场看板(/)+ 自选(/watchlist)。 // 行情终端:市场看板(/)+ 自选(/watchlist)。
// 分析工具:单标的回测(/backtest+ 组合回测(/portfolio+ 参数寻优(/optimize // 分析工具:单标的回测(/backtest+ 组合回测(/portfolio+ 参数寻优(/optimize
// + 结果对比(/compare+ 策略库(/strategies+ 信号雷达(/signals+ 设置(/settings)。 // + 结果对比(/compare+ 策略库(/strategies+ 信号雷达(/signals
// + 设置(/settings 服务器 / /llm AI 模型)。
const routes = [ const routes = [
{ path: '/', name: 'dashboard', component: DashboardView }, { path: '/', name: 'dashboard', component: DashboardView },
{ path: '/watchlist', name: 'watchlist', component: WatchlistView }, { path: '/watchlist', name: 'watchlist', component: WatchlistView },
@@ -23,6 +27,13 @@ const routes = [
{ path: '/strategies', name: 'strategies', component: StrategiesView }, { path: '/strategies', name: 'strategies', component: StrategiesView },
{ path: '/signals', name: 'signals', component: SignalRadarView }, { path: '/signals', name: 'signals', component: SignalRadarView },
{ path: '/settings', name: 'settings', component: ServerSettingsView }, { path: '/settings', name: 'settings', component: ServerSettingsView },
{ path: '/llm', name: 'llm', component: LlmSettingsView },
// AI 解读历史(每次「直接解读」自动归档)
{ path: '/ai-history', name: 'ai-history', component: LlmHistoryView },
// 核心龙头池(universe=core 的 159 只名单)
{ path: '/leaders', name: 'leaders', component: CoreLeadersView },
// 兜底:未注册路径(如把 API 路径当页面访问)回看板,不再渲染空白
{ path: '/:pathMatch(.*)*', redirect: '/' },
] ]
export const router = createRouter({ export const router = createRouter({
+128 -1
View File
@@ -50,7 +50,15 @@ export interface DataFrameResponse {
// ── 回测请求(POST /api/v1/backtest/run ───────────────────────────────────── // ── 回测请求(POST /api/v1/backtest/run ─────────────────────────────────────
export type ExecutionMode = 'next_open' | 'next_close' export type ExecutionMode = 'next_open' | 'next_close'
export type Category = 'DAY' | 'WEEK' | 'MONTH' | 'MIN_5' | 'MIN_15' | 'MIN_30' | 'MIN_60' export type Category =
| 'DAY'
| 'WEEK'
| 'MONTH'
| 'MIN_5'
| 'MIN_15'
| 'MIN_30'
| 'MIN_60'
| 'MIN_120'
export interface BacktestRequest { export interface BacktestRequest {
strategy: string strategy: string
@@ -152,6 +160,7 @@ export interface TaskState {
| SignalScanResult | SignalScanResult
| WalkForwardResult | WalkForwardResult
| EvaluateReport | EvaluateReport
| LlmChatResult
| null | null
error: string | null error: string | null
description: string description: string
@@ -635,3 +644,121 @@ export interface EvaluateReport {
benchmark: EvaluateBenchmarkReport benchmark: EvaluateBenchmarkReport
config: Record<string, unknown> config: Record<string, unknown>
} }
// ── 交易时段(GET /api/v1/market/session ───────────────────────────────────
export interface MarketSessionInfo {
is_trading_time: boolean
sessions: Array<{ start: string; end: string }>
session_desc: string
server_time: string
weekday: number
}
// ── LLM 配置与对话(GET/PUT /api/v1/llm/config 等) ──────────────────────────
export interface LlmProviderInfo {
id: string
label: string
base_url: string
default_model: string
api_style: 'openai' | 'anthropic'
needs_key: boolean
}
export interface LlmConfigInfo {
provider: string
/** 脱敏回显(sk-***abcd);提交时空串/原样回传 = 不修改已存 key */
api_key: string
api_url: string
model: string
temperature: number
max_tokens: number
timeout: number
system_prompt: string
}
export interface LlmConfigResponse {
config: LlmConfigInfo
providers: LlmProviderInfo[]
configured: boolean
missing: string[]
config_path: string
resolved: { api_url: string; model: string }
}
export interface LlmConfigUpdate {
provider: string
api_key?: string
api_url?: string
model?: string
temperature?: number
max_tokens?: number
timeout?: number
system_prompt?: string
}
export interface LlmTestResult {
ok: boolean
latency_ms: number
model: string
provider: string
reply?: string
error?: string
}
export interface LlmChatResponse {
reply: string
model: string
provider: string
}
/** AI 解读后台任务(POST /llm/chat/async)完成后的 result 结构。 */
export interface LlmChatResult {
reply: string
model: string
provider: string
elapsed: number
}
// ── AI 解读历史(GET /api/v1/llm/history) ───────────────────────────────────
/** 策略上下文(随解读落库,供「去回测」引导跳转)。 */
export interface LlmChatContext {
strategy: string
strategy_label: string
symbol: string
category: string
params: Record<string, number | string | boolean>
start_date: string
end_date: string
}
export interface LlmHistoryItem {
id: number
created_at: string
provider: string
model: string
prompt: string
reply: string
elapsed: number
strategy: string
strategy_label: string
symbol: string
category: string
params: Record<string, number | string | boolean>
start_date: string
end_date: string
}
export interface LlmHistoryResponse {
items: LlmHistoryItem[]
count: number
}
/** 核心龙头池条目(GET /api/v1/market/core-leaders)。 */
export interface CoreLeaderRow {
code: string
name: string
market: string
}
+110 -8
View File
@@ -15,11 +15,11 @@ import StrategyPicker from '../components/StrategyPicker.vue'
import SymbolPicker from '../components/SymbolPicker.vue' import SymbolPicker from '../components/SymbolPicker.vue'
import TradeTable from '../components/TradeTable.vue' import TradeTable from '../components/TradeTable.vue'
import WalkForwardPanel from '../components/WalkForwardPanel.vue' import WalkForwardPanel from '../components/WalkForwardPanel.vue'
import { formatError, saveStrategy } from '../api' import { formatError, saveStrategy, fetchLlmConfig, runLlmChatWithPolling } from '../api'
import { detectMarket } from '../market' import { detectMarket } from '../market'
import { GRADE_META, gradePerformance } from '../grading' import { GRADE_META, gradePerformance } from '../grading'
import { buildAiPrompt } from '../aiPrompt' import { buildAiPrompt } from '../aiPrompt'
import type { Category, ExecutionMode } from '../types' import type { Category, ExecutionMode, LlmChatResult } from '../types'
import { useBacktestStore } from '../stores/backtest' import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore() const store = useBacktestStore()
@@ -204,6 +204,13 @@ async function onSave() {
// ── AI 解读 Prompt(把当前报告组装成提示词,发给任意 LLM 解读)────────────── // ── AI 解读 Prompt(把当前报告组装成提示词,发给任意 LLM 解读)──────────────
const showAiModal = ref(false) const showAiModal = ref(false)
const aiMsg = ref('') const aiMsg = ref('')
// 直接解读(服务端 LLM 已配置时可用,配置见「AI 设置」页)
const llmReady = ref(false)
const llmLabel = ref('')
const aiRunning = ref(false)
const aiElapsed = ref(0)
const aiReply = ref('')
let aiTimer = 0
/** 实时组装:附加分析(WF/评估)跑完后内容自动变全 */ /** 实时组装:附加分析(WF/评估)跑完后内容自动变全 */
const aiPromptText = computed(() => { const aiPromptText = computed(() => {
@@ -230,7 +237,60 @@ const aiPromptText = computed(() => {
function openAiModal() { function openAiModal() {
aiMsg.value = '' aiMsg.value = ''
aiReply.value = ''
showAiModal.value = true showAiModal.value = true
// 打开时探测 LLM 是否已配置(失败静默——导出 Prompt 的老路径不依赖后端)
fetchLlmConfig()
.then((resp) => {
llmReady.value = resp.configured
const p = resp.providers.find((x) => x.id === resp.config.provider)
llmLabel.value = p ? `${p.label} · ${resp.resolved.model}` : resp.resolved.model
})
.catch(() => {
llmReady.value = false
})
}
/** 直接解读:把组装好的 Prompt 提交为后台任务并轮询(不占 HTTP 连接)。 */
async function runAiInterpret() {
if (!aiPromptText.value || aiRunning.value) return
aiRunning.value = true
aiMsg.value = ''
aiReply.value = ''
// 后台任务模式:模型生成 1-3 分钟正常——显示已耗时防误判卡死
aiElapsed.value = 0
aiTimer = window.setInterval(() => {
aiElapsed.value += 1
}, 1000)
try {
// 策略上下文随解读落历史库(AI 解读历史页「去回测」引导用)
const ctx = {
strategy: strategy.value,
strategy_label: strategyLabel.value,
symbol: code.value,
category: category.value,
params: { ...params.value },
start_date: startDate.value,
end_date: endDate.value,
}
const state = await runLlmChatWithPolling(aiPromptText.value, ctx)
// TaskState.result 是多任务类型联合,按 LLM 任务结构收窄
const r = state.result as LlmChatResult | null
// 后端已保证非空正文(空白正文会以 failed 上浮),前端再拦一道纯空白
if (state.status === 'done' && r?.reply?.trim()) {
aiReply.value = r.reply
aiMsg.value = `${r.provider} · ${r.model} 已解读(${aiElapsed.value}s`
} else if (state.status === 'done') {
aiMsg.value = '解读失败:模型返回了空正文(可能被 Max Tokens 截断),可在「AI 设置」调大后重试'
} else {
aiMsg.value = `解读失败:${state.error ?? '未知错误'}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
}
} catch (e) {
aiMsg.value = `解读失败:${formatError(e)}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
} finally {
window.clearInterval(aiTimer)
aiRunning.value = false
}
} }
async function copyAiPrompt() { async function copyAiPrompt() {
@@ -445,26 +505,45 @@ function downloadAiPrompt() {
<!-- AI 解读 Prompt 对话框 --> <!-- AI 解读 Prompt 对话框 -->
<div v-if="showAiModal" class="modal-overlay" @click.self="showAiModal = false"> <div v-if="showAiModal" class="modal-overlay" @click.self="showAiModal = false">
<div class="modal modal-wide"> <div class="modal modal-wide">
<h3>🤖 AI 解读 Prompt</h3> <h3>🤖 AI 解读</h3>
<p class="modal-desc"> <p class="modal-desc">
已把当前回测报告组装成提示词复制后发给任意 AI 助手ChatGPT / Claude / 已把当前回测报告组装成提示词
DeepSeek / 豆包即可获得针对性解读与改进建议 <template v-if="llmReady">
点击直接解读发送给已配置的模型{{ llmLabel }}
</template>
<template v-else>
AI 设置页配置模型后可一键直接解读也可
</template>
复制后发给任意 AI 助手ChatGPT / Claude / DeepSeek / 豆包
<template v-if="wfEnabled || evaluateEnabled"> <template v-if="wfEnabled || evaluateEnabled">
建议等附加分析跑完再复制Walk-Forward / 一条龙评估的数据会一并打包 建议等附加分析跑完再Walk-Forward / 一条龙评估的数据会一并打包
</template> </template>
</p> </p>
<textarea <textarea
:value="aiPromptText" :value="aiPromptText"
class="ai-prompt-area" class="ai-prompt-area"
:class="{ collapsed: !!aiReply }"
readonly readonly
rows="16" :rows="aiReply ? 6 : 16"
spellcheck="false" spellcheck="false"
></textarea> ></textarea>
<div v-if="aiReply" class="ai-reply">{{ aiReply }}</div>
<div v-if="aiReply" class="ai-note">
以上解读由 AI 模型生成可能存在错误或过时信息仅供参考不构成投资建议
</div>
<span v-if="aiMsg" class="ai-msg">{{ aiMsg }}</span> <span v-if="aiMsg" class="ai-msg">{{ aiMsg }}</span>
<div class="modal-actions"> <div class="modal-actions">
<button class="ghost" @click="showAiModal = false">关闭</button> <button class="ghost" @click="showAiModal = false">关闭</button>
<button class="ghost" @click="downloadAiPrompt"> 下载 .md</button> <button class="ghost" @click="downloadAiPrompt"> 下载 .md</button>
<button class="primary" @click="copyAiPrompt">复制 Prompt</button> <button class="ghost" @click="copyAiPrompt">复制 Prompt</button>
<button
v-if="llmReady"
class="primary"
:disabled="aiRunning || !aiPromptText"
@click="runAiInterpret"
>
{{ aiRunning ? `解读中… ${aiElapsed}s` : '✨ 直接解读' }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -724,8 +803,31 @@ function downloadAiPrompt() {
color: var(--text-muted); color: var(--text-muted);
resize: vertical; resize: vertical;
} }
/* 直接解读出结果后 Prompt 区收窄,把版面让给回复 */
.ai-prompt-area.collapsed {
max-height: 18vh;
}
.ai-reply {
margin-top: 8px;
max-height: 38vh;
overflow: auto;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 10px 12px;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.ai-msg { .ai-msg {
font-size: 12px; font-size: 12px;
color: var(--up); color: var(--up);
} }
.ai-note {
margin-top: 4px;
font-size: 11px;
color: var(--warn, #ffc107);
}
</style> </style>
+193
View File
@@ -0,0 +1,193 @@
<script setup lang="ts">
// 核心龙头池页:159 只核心龙头(东财全行业龙头名单,screen universe="core"
// 的同一份数据资产)。支持搜索过滤,点击行弹个股详情。
import { computed, onMounted, ref } from 'vue'
import { fetchCoreLeaders, formatError } from '../api'
import type { CoreLeaderRow } from '../types'
import RiskDisclaimer from '../components/RiskDisclaimer.vue'
import StockDialog from '../components/StockDialog.vue'
const leaders = ref<CoreLeaderRow[]>([])
const loading = ref(false)
const error = ref('')
const keyword = ref('')
const dialog = ref<{ market: string; code: string; name: string } | null>(null)
onMounted(load)
async function load() {
loading.value = true
error.value = ''
try {
leaders.value = await fetchCoreLeaders()
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
const filtered = computed(() => {
const k = keyword.value.trim().toLowerCase()
if (!k) return leaders.value
return leaders.value.filter(
(r) => r.code.includes(k) || r.name.toLowerCase().includes(k),
)
})
function openDialog(row: CoreLeaderRow) {
dialog.value = { market: row.market, code: row.code, name: row.name }
}
</script>
<template>
<div class="leaders-view">
<div class="toolbar">
<h2>核心龙头池 <span class="dim title-sub">{{ leaders.length }} · 东财全行业龙头名单</span></h2>
<input
v-model="keyword"
class="search"
type="text"
placeholder="搜代码 / 名称…"
spellcheck="false"
/>
</div>
<p class="hint">
<strong>这份名单是什么</strong>按东方财富公开的"全行业龙头股名单"整理的
<strong>选股扫描范围</strong>即离线扫描的 <code>universe="core"</code> 股票池
<code>easy-tdx screen scan --universe core</code>
<code>/market/strength?universe=core</code> 均按此过滤四组分层全球第一 / 国内第一 /
科技细分 / 行业冠军<strong>名单仅描述"这些公司在其行业内规模/市占率领先"这一客观事实
不代表任何买入价值判断</strong>龙头同样可能高估滞涨或衰退点击行查看个股详情
</p>
<RiskDisclaimer prominent>
<strong> 风险提示与免责声明</strong>
<p>
本页面展示的"核心龙头池"仅为<strong>策略扫描的股票范围筛选清单</strong>
<strong>不构成任何形式的个股推荐买入建议或投资顾问服务</strong>名单基于第三方公开
资料整理可能存在滞后遗漏或错误"行业龙头"是对历史经营地位的描述
不预示未来股价表现本工具及作者不对任何人依据本名单作出的投资行为及损失承担责任
投资有风险入市需谨慎据此操作风险自负
</p>
</RiskDisclaimer>
<div v-if="error" class="error-banner"> {{ error }}</div>
<div v-else-if="loading" class="empty">加载中</div>
<div v-else class="grid">
<div
v-for="r in filtered"
:key="r.market + r.code"
class="cell"
@click="openDialog(r)"
>
<span class="c-code mono dim">{{ r.code }}</span>
<span class="c-name">{{ r.name }}</span>
<span class="c-mkt mono dim">{{ r.market }}</span>
</div>
</div>
<div v-if="!loading && !error && !filtered.length" class="empty">无匹配结果</div>
<StockDialog
v-if="dialog"
:market="dialog.market"
:code="dialog.code"
:name="dialog.name"
@close="dialog = null"
/>
</div>
</template>
<style scoped>
.leaders-view {
height: 100%;
overflow-y: auto;
padding: 14px 16px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toolbar h2 {
font-size: 16px;
}
.title-sub {
font-weight: 400;
font-size: 12px;
}
.search {
width: 200px;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
}
.search:focus {
outline: none;
border-color: var(--accent);
}
.hint {
margin: 8px 0 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
}
.hint code {
font-family: var(--font-mono);
font-size: 11px;
}
.error-banner {
padding: 8px 12px;
background: rgba(244, 67, 54, 0.1);
border-radius: var(--radius);
font-size: 12px;
color: var(--red, #f44336);
}
.empty {
color: var(--text-dim);
padding: 40px;
text-align: center;
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 6px;
}
.cell {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12.5px;
cursor: pointer;
}
.cell:hover {
border-color: var(--accent);
}
.cell:hover .c-name {
color: var(--accent);
}
.c-code {
font-size: 11px;
flex-shrink: 0;
}
.c-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c-mkt {
font-size: 10px;
flex-shrink: 0;
}
.dim {
color: var(--text-dim);
}
</style>
+122 -10
View File
@@ -406,13 +406,40 @@ function rankExtra(r: RankRow): { text: string; cls: string } {
return { text: fmtPctSigned(pct), cls: dirClass(pct) } return { text: fmtPctSigned(pct), cls: dirClass(pct) }
} }
// ── 轮询调度 ───────────────────────────────────────────────────────────────── // ── 轮询调度(交易时段感知:休市自动暂停,手动刷新不受限) ────────────────────
let statTimer = 0 let statTimer = 0
let slowTimer = 0 let slowTimer = 0
let distTimer = 0 let distTimer = 0
let sessionTimer = 0
onMounted(() => { /** 仅交易时段自动刷新(localStorage 持久化;勾掉 = 全天候模式)。 */
const sessionGated = ref(localStorage.getItem('dash.sessionGated') !== '0')
function onSessionToggle() {
localStorage.setItem('dash.sessionGated', sessionGated.value ? '1' : '0')
}
/** 本地判断当前是否处于 A 股有效行情时段(09:15~11:30、13:00~15:05,周一至五)。 */
function isTradeSession(now = new Date()): boolean {
const day = now.getDay()
if (day === 0 || day === 6) return false
const m = now.getHours() * 60 + now.getMinutes()
return (m >= 555 && m <= 690) || (m >= 780 && m <= 905)
}
/** 会话状态(每分钟重估):gated 开关 × 是否盘中。 */
const inSession = ref(isTradeSession())
/** 自动刷新是否暂停(全天候模式或盘中 = 不暂停)。 */
const autoPaused = computed(() => sessionGated.value && !inSession.value)
const sessionLabel = computed(() => {
if (!sessionGated.value) return '全天候模式'
return inSession.value ? '交易中' : '休市 · 自动刷新已暂停'
})
function refreshAll() {
loadStat() loadStat()
loadBoards() loadBoards()
loadUnusual() loadUnusual()
@@ -420,20 +447,44 @@ onMounted(() => {
loadIdxSparks() loadIdxSparks()
loadIndexContext() loadIndexContext()
loadDist() loadDist()
statTimer = window.setInterval(loadStat, 30_000) }
slowTimer = window.setInterval(() => {
loadUnusual() function tickIfActive() {
loadRanks() // 门控放在 fetch 前:定时器照常触发,休市时只重估会话状态、不发请求
loadBoards() inSession.value = isTradeSession()
loadIdxSparks() if (autoPaused.value) return
loadIndexContext() loadStat()
}
function slowTickIfActive() {
if (autoPaused.value) return
loadUnusual()
loadRanks()
loadBoards()
loadIdxSparks()
loadIndexContext()
}
function distTickIfActive() {
if (autoPaused.value) return
loadDist()
}
onMounted(() => {
refreshAll()
statTimer = window.setInterval(tickIfActive, 30_000)
slowTimer = window.setInterval(slowTickIfActive, 60_000)
distTimer = window.setInterval(distTickIfActive, 120_000)
// 每分钟重估会话状态(跨过 11:30/15:05 边界后状态栏即时切换)
sessionTimer = window.setInterval(() => {
inSession.value = isTradeSession()
}, 60_000) }, 60_000)
distTimer = window.setInterval(loadDist, 120_000)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.clearInterval(statTimer) window.clearInterval(statTimer)
window.clearInterval(slowTimer) window.clearInterval(slowTimer)
window.clearInterval(distTimer) window.clearInterval(distTimer)
window.clearInterval(sessionTimer)
}) })
// ── 弹窗(个股 / 板块) ────────────────────────────────────────────────────── // ── 弹窗(个股 / 板块) ──────────────────────────────────────────────────────
@@ -456,6 +507,18 @@ function openBoard(code: string | undefined, name: string | undefined) {
<template> <template>
<div class="dash"> <div class="dash">
<!-- 刷新状态条会话状态 + 手动刷新 + 门控开关 -->
<div class="session-bar">
<span class="dot" :class="{ live: !autoPaused }"></span>
<span class="session-label" :class="{ paused: autoPaused }">{{ sessionLabel }}</span>
<span class="dim session-hint">09:15~11:30, 13:00~15:05</span>
<label class="session-toggle">
<input v-model="sessionGated" type="checkbox" @change="onSessionToggle" />
仅交易时段自动刷新
</label>
<button class="manual-refresh" @click="refreshAll"> 手动刷新</button>
</div>
<!-- 指数条内嵌迷你分时 --> <!-- 指数条内嵌迷你分时 -->
<div class="idx-row"> <div class="idx-row">
<div <div
@@ -715,6 +778,55 @@ function openBoard(code: string | undefined, name: string | undefined) {
overflow: auto; overflow: auto;
padding: 14px 16px; padding: 14px 16px;
} }
.session-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
}
.session-bar .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
}
.session-bar .dot.live {
background: var(--up);
box-shadow: 0 0 4px var(--up);
}
.session-label {
font-weight: 600;
}
.session-label.paused {
color: var(--text-dim);
}
.session-hint {
font-size: 11px;
}
.session-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 12px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.manual-refresh {
margin-left: auto;
padding: 3px 12px;
font-size: 12px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.manual-refresh:hover {
border-color: var(--accent);
color: var(--accent);
}
.idx-row { .idx-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
// AI 解读历史页:历次「直接解读」的提问 Prompt + 模型解读 + 策略上下文。
// 每条可展开查看全文,「去回测」带策略/参数/标的/周期/日期一键跳回回测页,
// 「重新解读」用原 Prompt 再发起一次(模型更新/换模型后对比)。
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchLlmHistory, deleteLlmHistory, clearLlmHistory, formatError } from '../api'
import type { LlmHistoryItem } from '../types'
import RiskDisclaimer from '../components/RiskDisclaimer.vue'
const router = useRouter()
const items = ref<LlmHistoryItem[]>([])
const loading = ref(false)
const error = ref('')
const expandedId = ref<number | null>(null)
const showPromptOf = ref<number | null>(null)
onMounted(load)
async function load() {
loading.value = true
error.value = ''
try {
const resp = await fetchLlmHistory(100)
items.value = resp.items
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
function toggle(id: number) {
expandedId.value = expandedId.value === id ? null : id
}
function togglePrompt(id: number) {
showPromptOf.value = showPromptOf.value === id ? null : id
}
/** 本地时间 + 只保留日期时分。 */
function fmtTime(iso: string): string {
if (!iso) return ''
// 后端存 UTC(Z 结尾),转本地展示
const d = new Date(iso.endsWith('Z') && iso.length === 17 ? iso.slice(0, 16) + 'Z' : iso)
if (Number.isNaN(d.getTime())) return iso
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
/** 标题:策略 @ 标的 · 周期(手工调用无上下文时只显示模型)。 */
function title(it: LlmHistoryItem): string {
if (!it.strategy_label && !it.symbol) return `AI 对话 · ${it.model}`
const sym = it.symbol || '—'
const cat = it.category ? ` · ${it.category}` : ''
return `${it.strategy_label || it.strategy || '策略'} @ ${sym}${cat}`
}
/** 去回测:带全部上下文跳转(回测页 onMounted 读 query 预填)。 */
function goBacktest(it: LlmHistoryItem) {
const query: Record<string, string> = {}
if (it.strategy) query.strategy = it.strategy
if (Object.keys(it.params || {}).length) query.params = JSON.stringify(it.params)
if (it.symbol) query.symbol = it.symbol
if (it.category) query.category = it.category
if (it.start_date) query.startDate = it.start_date
if (it.end_date) query.endDate = it.end_date
router.push({ path: '/backtest', query })
}
async function onDelete(id: number) {
try {
await deleteLlmHistory(id)
items.value = items.value.filter((i) => i.id !== id)
} catch (e) {
error.value = formatError(e)
}
}
async function onClearAll() {
if (!window.confirm('确定清空全部 AI 解读历史?此操作不可恢复。')) return
try {
await clearLlmHistory()
items.value = []
} catch (e) {
error.value = formatError(e)
}
}
</script>
<template>
<div class="ai-history">
<aside class="side-panel">
<h2>AI 解读历史</h2>
<p class="hint">
每次直接解读成功后自动归档提问 Prompt模型解读以及当时的策略配置
去回测可一键带参数跳回回测页复现当时的场景重新解读适合换模型后对比结论
</p>
<button class="btn-ghost" :disabled="!items.length || loading" @click="onClearAll">
清空历史
</button>
<div v-if="error" class="error-banner"> {{ error }}</div>
<RiskDisclaimer>
历史解读由 AI 生成基于当时的回测数据均不构成投资建议策略过往表现不代表未来
</RiskDisclaimer>
</aside>
<main class="list-panel">
<div v-if="loading" class="empty">加载中</div>
<div v-else-if="!items.length" class="empty">
暂无解读记录单标的回测页跑一次回测🤖 AI 解读 直接解读即可归档
</div>
<div v-for="it in items" :key="it.id" class="record" :class="{ open: expandedId === it.id }">
<div class="record-head" @click="toggle(it.id)">
<span class="r-time mono dim">{{ fmtTime(it.created_at) }}</span>
<span class="r-title">{{ title(it) }}</span>
<span class="r-model mono dim">{{ it.provider }} · {{ it.model }}</span>
<span class="r-elapsed mono dim">{{ it.elapsed }}s</span>
<span class="r-arrow">{{ expandedId === it.id ? '▾' : '▸' }}</span>
</div>
<div v-if="expandedId === it.id" class="record-body">
<div class="reply">{{ it.reply }}</div>
<div class="ai-note">以上解读由 AI 模型生成可能存在错误或过时信息仅供参考不构成投资建议</div>
<div v-if="it.start_date || it.end_date" class="ctx-line dim">
回测区间 {{ it.start_date || '' }} ~ {{ it.end_date || '' }}
<template v-if="Object.keys(it.params || {}).length">
· 参数 {{ JSON.stringify(it.params) }}
</template>
</div>
<div class="actions">
<button
v-if="it.strategy || it.symbol"
class="btn-primary"
@click="goBacktest(it)"
>
去回测带参数
</button>
<button class="btn-ghost" @click="togglePrompt(it.id)">
{{ showPromptOf === it.id ? '收起提问' : '查看提问 Prompt' }}
</button>
<button class="btn-danger" @click="onDelete(it.id)">删除</button>
</div>
<pre v-if="showPromptOf === it.id" class="prompt">{{ it.prompt }}</pre>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.ai-history {
display: flex;
height: 100%;
overflow: hidden;
}
.side-panel {
width: 280px;
flex-shrink: 0;
padding: 20px;
background: var(--bg-panel);
border-right: 1px solid var(--border);
overflow-y: auto;
}
.side-panel h2 {
font-size: 16px;
margin-bottom: 12px;
}
.hint {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 16px;
}
.error-banner {
margin-top: 12px;
padding: 8px 12px;
background: rgba(244, 67, 54, 0.1);
border-radius: var(--radius);
font-size: 12px;
color: var(--red, #f44336);
}
.list-panel {
flex: 1;
overflow-y: auto;
padding: 14px 16px;
}
.empty {
color: var(--text-dim);
padding: 40px;
text-align: center;
font-size: 13px;
}
.record {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
background: var(--bg-panel);
}
.record.open {
border-color: var(--accent);
}
.record-head {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
cursor: pointer;
font-size: 12.5px;
}
.record-head:hover .r-title {
color: var(--accent);
}
.r-time {
flex-shrink: 0;
font-size: 11px;
}
.r-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.r-model {
flex-shrink: 0;
font-size: 11px;
}
.r-elapsed {
flex-shrink: 0;
font-size: 11px;
}
.r-arrow {
color: var(--text-dim);
flex-shrink: 0;
}
.record-body {
border-top: 1px solid var(--border);
padding: 12px;
}
.reply {
/* 不设内层滚动:正文完整铺开,由外层 list-panel 页面级滚动(内层限高
曾导致"高度太低无滚轴、文字展示不全"的阅读问题) */
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.75;
background: var(--bg-elevated);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 10px 12px;
}
.ctx-line {
margin-top: 8px;
font-size: 11.5px;
}
.ai-note {
margin-top: 6px;
font-size: 11px;
color: var(--warn, #ffc107);
}
.actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.btn-primary {
padding: 5px 14px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
}
.btn-ghost {
padding: 5px 14px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
}
.btn-ghost:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
padding: 5px 14px;
background: transparent;
border: 1px solid rgba(244, 67, 54, 0.5);
color: var(--red, #f44336);
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
}
.btn-danger:hover {
background: rgba(244, 67, 54, 0.1);
}
.prompt {
margin-top: 10px;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.6;
color: var(--text-dim);
background: var(--bg);
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 8px 10px;
}
</style>
+406
View File
@@ -0,0 +1,406 @@
<script setup lang="ts">
// AI 设置页:LLM Provider 配置(WebUI 表单 ⇆ ~/.easy_tdx/llm.json 同一份文件)。
// 选 Provider 自动填充预设 base_url / 默认模型,均可手工覆盖;API Key 脱敏
// 回显,留空或原样回传不覆盖已存 key。支持「测试连接」即时验证。
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchLlmConfig, saveLlmConfig, testLlm, formatError } from '../api'
import type { LlmProviderInfo } from '../types'
const providers = ref<LlmProviderInfo[]>([])
const configPath = ref('')
const configured = ref(false)
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const message = ref('')
const error = ref('')
const testResult = ref<string>('')
const form = reactive({
provider: 'deepseek',
api_url: '',
api_key: '',
model: '',
temperature: 0.3,
max_tokens: 16000,
timeout: 180,
system_prompt: '',
})
/** 当前选中 Provider 的预设(填充提示用)。 */
const currentPreset = computed(
() => providers.value.find((p) => p.id === form.provider) ?? null,
)
onMounted(load)
async function load() {
loading.value = true
error.value = ''
try {
const resp = await fetchLlmConfig()
providers.value = resp.providers
configPath.value = resp.config_path
configured.value = resp.configured
form.provider = resp.config.provider
form.api_url = resp.config.api_url
form.api_key = resp.config.api_key
form.model = resp.config.model
form.temperature = resp.config.temperature
form.max_tokens = resp.config.max_tokens
form.timeout = resp.config.timeout
form.system_prompt = resp.config.system_prompt
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
/** 切换 Provider:清空 key 与空字段,让预设兜底(已有自定义值不覆盖)。 */
function onProviderChange() {
form.api_key = ''
form.api_url = ''
form.model = ''
message.value = ''
error.value = ''
testResult.value = ''
}
async function save() {
saving.value = true
error.value = ''
message.value = ''
testResult.value = ''
try {
const resp = await saveLlmConfig({
provider: form.provider,
api_url: form.api_url,
// 脱敏串原样回传 = 不覆盖已存 key;后端再判一次
api_key: form.api_key,
model: form.model,
temperature: form.temperature,
max_tokens: form.max_tokens,
timeout: form.timeout,
system_prompt: form.system_prompt,
})
configured.value = resp.configured
// 回填脱敏 key,避免明文留在表单里
form.api_key = resp.config.api_key
message.value = `已保存到 ${resp.config_path}`
} catch (e) {
error.value = formatError(e)
} finally {
saving.value = false
}
}
async function test() {
testing.value = true
error.value = ''
message.value = ''
testResult.value = ''
try {
// 先保存再测(保存时脱敏串回传不会覆盖真 key),保证测的就是落盘配置
await save()
const r = await testLlm()
if (r.ok) {
testResult.value = `✓ 连通成功 · ${r.model} · ${r.latency_ms} ms · 回复「${r.reply}`
} else {
testResult.value = `✗ 失败 · ${r.error}`
}
} catch (e) {
error.value = formatError(e)
} finally {
testing.value = false
}
}
</script>
<template>
<div class="llm-settings">
<aside class="config-panel">
<h2>AI 设置</h2>
<p class="hint">
配置 LLM 回测页的AI 解读可直接把报告发给模型解读无需复制粘贴
配置同时落盘到 <code>{{ configPath || '~/.easy_tdx/llm.json' }}</code>
手工编辑该文件与此处保存完全等效
</p>
<label class="field">
<span>Provider</span>
<select v-model="form.provider" @change="onProviderChange">
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.label }}{{ p.needs_key ? '' : '免Key' }}
</option>
</select>
</label>
<label class="field">
<span>API 地址</span>
<input
v-model="form.api_url"
type="text"
:placeholder="currentPreset?.base_url || 'https://...OpenAI 兼容地址)'"
spellcheck="false"
/>
</label>
<label class="field">
<span>API Key</span>
<input
v-model="form.api_key"
type="password"
:placeholder="form.api_key ? '已保存(留空不修改,填 CLEAR 清除)' : 'sk-…'"
autocomplete="off"
spellcheck="false"
/>
</label>
<label class="field">
<span>模型</span>
<input
v-model="form.model"
type="text"
:placeholder="currentPreset?.default_model || '模型名'"
spellcheck="false"
/>
</label>
<div class="row-2">
<label class="field">
<span>Temperature</span>
<input v-model.number="form.temperature" type="number" min="0" max="2" step="0.1" />
</label>
<label class="field">
<span>Max Tokens思考型模型的思考链计入此预算建议 16000</span>
<input v-model.number="form.max_tokens" type="number" min="64" max="128000" step="512" />
</label>
</div>
<label class="field">
<span>超时报告解读需等模型生成完整段回复建议 120</span>
<input v-model.number="form.timeout" type="number" min="5" max="600" step="10" />
</label>
<label class="field">
<span>系统提示词AI 解读的默认角色设定</span>
<textarea v-model="form.system_prompt" rows="4" spellcheck="false"></textarea>
</label>
<div class="actions">
<button class="btn-primary" :disabled="saving || loading" @click="save">
{{ saving ? '保存中' : '保存配置' }}
</button>
<button class="btn-ghost" :disabled="testing || loading" @click="test">
{{ testing ? '测试中' : '保存并测试' }}
</button>
</div>
<div v-if="message" class="message">{{ message }}</div>
<div v-if="testResult" class="test-result" :class="{ ok: testResult.startsWith('✓') }">
{{ testResult }}
</div>
<div v-if="error" class="error-banner"> {{ error }}</div>
<div v-if="!loading && !configured" class="warn-banner">
尚未配置可用的 API KeyAI 解读仍可导出 Prompt 手动使用
</div>
</aside>
<main class="info-panel">
<h3>支持的 Provider</h3>
<table class="provider-table">
<thead>
<tr>
<th>Provider</th>
<th>默认地址</th>
<th>默认模型</th>
</tr>
</thead>
<tbody>
<tr v-for="p in providers" :key="p.id" :class="{ current: p.id === form.provider }">
<td>{{ p.label }}</td>
<td class="mono">{{ p.base_url || '—' }}</td>
<td class="mono">{{ p.default_model || '—' }}</td>
</tr>
</tbody>
</table>
<p class="hint">
Claude Anthropic 原生协议外其余均走 OpenAI 兼容接口自定义可填任意
兼容网关 OpenRouterone-apivLLMOllama 本地服务无需 API Key
环境变量 LLM_PROVIDER / LLM_API_KEY / LLM_BASE_URL / LLM_MODEL 在配置文件
缺字段时兜底生效
</p>
</main>
</div>
</template>
<style scoped>
.llm-settings {
display: flex;
height: 100%;
overflow: hidden;
}
.config-panel {
width: 360px;
flex-shrink: 0;
padding: 20px;
background: var(--bg-panel);
border-right: 1px solid var(--border);
overflow-y: auto;
}
.config-panel h2 {
font-size: 16px;
margin-bottom: 12px;
}
.hint {
font-size: 12px;
color: var(--text-dim);
line-height: 1.6;
}
.hint code {
font-family: var(--font-mono);
font-size: 11px;
word-break: break-all;
}
.field {
display: block;
margin-top: 12px;
}
.field span {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.field input,
.field select,
.field textarea {
width: 100%;
padding: 6px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: inherit;
}
.field textarea {
font-family: var(--font-mono);
font-size: 12px;
resize: vertical;
}
.row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.btn-primary {
flex: 1;
padding: 9px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-ghost {
flex: 1;
padding: 9px;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
}
.btn-ghost:hover:not(:disabled) {
background: var(--accent);
color: #fff;
}
.btn-primary:disabled,
.btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.message {
margin-top: 12px;
padding: 8px 12px;
background: var(--accent-bg, rgba(0, 120, 212, 0.1));
border-radius: var(--radius);
font-size: 12px;
color: var(--accent);
word-break: break-all;
}
.test-result {
margin-top: 10px;
padding: 8px 12px;
background: rgba(244, 67, 54, 0.08);
border-radius: var(--radius);
font-size: 12px;
color: var(--red, #f44336);
word-break: break-all;
}
.test-result.ok {
background: rgba(76, 175, 80, 0.12);
color: var(--green, #4caf50);
}
.error-banner {
margin-top: 12px;
padding: 8px 12px;
background: rgba(244, 67, 54, 0.1);
border-radius: var(--radius);
font-size: 12px;
color: var(--red, #f44336);
}
.warn-banner {
margin-top: 12px;
padding: 8px 12px;
background: rgba(255, 193, 7, 0.1);
border-radius: var(--radius);
font-size: 12px;
color: var(--warn, #ffc107);
}
.info-panel {
flex: 1;
overflow: auto;
padding: 20px;
}
.info-panel h3 {
font-size: 14px;
margin-bottom: 12px;
}
.provider-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
margin-bottom: 14px;
}
.provider-table th {
text-align: left;
padding: 7px 10px;
border-bottom: 2px solid var(--border);
color: var(--text-dim);
font-weight: 500;
}
.provider-table td {
padding: 7px 10px;
border-bottom: 1px solid var(--border);
}
.provider-table tr.current {
background: var(--accent-bg, rgba(0, 120, 212, 0.06));
}
.mono {
font-family: var(--font-mono);
font-size: 11.5px;
}
</style>
+1 -1
View File
@@ -30,7 +30,7 @@ const EXECUTIONS: { value: ExecutionMode; label: string }[] = [
{ value: 'next_open', label: '开盘价' }, { value: 'next_open', label: '开盘价' },
{ value: 'next_close', label: '收盘价' }, { value: 'next_close', label: '收盘价' },
] ]
const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60'] const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60', 'MIN_120']
// 日期默认(复用单标的逻辑) // 日期默认(复用单标的逻辑)
function isoDaysFromNow(days: number): string { function isoDaysFromNow(days: number): string {