diff --git a/CHANGELOG.md b/CHANGELOG.md index 015e66b..68055e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ 本文件记录 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 / 通义千问 / 智谱 GLM(bigmodel.cn)/ Kimi / MiniMax / OpenAI / Claude(Anthropic 原生协议)/ Ollama(本地免 Key)/ 自定义(任意 OpenAI 兼容网关),base_url 与模型均可覆盖。配置落盘 `~/.easy_tdx/llm.json`(随 `EASY_TDX_CONFIG_DIR`),WebUI 表单与手工编辑同一份文件、双向兼容;字段级优先级 = 文件 > 环境变量(`LLM_PROVIDER`/`LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL`)> 预设默认。API:GET/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 **修复指数/个股 K 线 vol 字段的三类协议语义错误**([#64](https://github.com/handsomejustin/easy_tdx/issues/64))——通达信服务端 K 线记录的第一个 4 字节字段(一直被当作成交量透传)的语义随周期/品种变化,此前原样返回错误数据。本轮通过逐字节拆包原始报文 + 新浪实时行情/东方财富分钟 K 三方交叉验证锁定规律后,在协议解析层(`GetIndexBarsCmd` / `GetSecurityBarsCmd` 的 `parse_response`,同步/异步客户端共用)统一修正。 diff --git a/pyproject.toml b/pyproject.toml index a62355d..edcb836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.28.2" +version = "1.29.0" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/MyTT.py b/src/easy_tdx/MyTT.py index f33a9dc..9a77b9d 100644 --- a/src/easy_tdx/MyTT.py +++ b/src/easy_tdx/MyTT.py @@ -571,4 +571,117 @@ def FSL(CLOSE, VOL, CAPITAL): # 分水岭指标:多空趋势强弱分界(SW 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 diff --git a/src/easy_tdx/MyTT.pyi b/src/easy_tdx/MyTT.pyi index ddb6538..5d60624 100644 --- a/src/easy_tdx/MyTT.pyi +++ b/src/easy_tdx/MyTT.pyi @@ -110,6 +110,7 @@ def FSL( VOL: npt.ArrayLike, CAPITAL: float, ) -> tuple[NDArray, NDArray]: ... +def ZIG(S: npt.ArrayLike, X: float = ...) -> NDArray: ... # ── Utility Functions ──────────────────────────────────────────────────────── diff --git a/src/easy_tdx/ai/__init__.py b/src/easy_tdx/ai/__init__.py new file mode 100644 index 0000000..3b68d90 --- /dev/null +++ b/src/easy_tdx/ai/__init__.py @@ -0,0 +1,35 @@ +"""LLM 客户端与配置(多 Provider,WebUI 与配置文件双向兼容)。 + +借鉴社区 Fork(swimmingaaron/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", +] diff --git a/src/easy_tdx/ai/llm.py b/src/easy_tdx/ai/llm.py new file mode 100644 index 0000000..a1a826e --- /dev/null +++ b/src/easy_tdx/ai/llm.py @@ -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 或 model(custom 未填全)。 + """ + 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), + } diff --git a/src/easy_tdx/backtest/strategies/builtin.py b/src/easy_tdx/backtest/strategies/builtin.py index 3a7f750..989fada 100644 --- a/src/easy_tdx/backtest/strategies/builtin.py +++ b/src/easy_tdx/backtest/strategies/builtin.py @@ -30,6 +30,7 @@ from easy_tdx.MyTT import ( EMA, EMV, FSL, + HHV, KDJ, KTN, MA, @@ -38,6 +39,7 @@ from easy_tdx.MyTT import ( TAQ, TRIX, WR, + ZIG, ) __all__: list[str] = [] # 注册副作用即可,无需导出符号 @@ -691,3 +693,113 @@ class FslStrategy(ParametrizedStrategy): def entry_exit_masks(self) -> tuple[Any, Any]: """与 next() 同源(gold/dead 即 next() 判定用的同一组掩码数组)。""" 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) diff --git a/src/easy_tdx/backtest/strategies/presets.py b/src/easy_tdx/backtest/strategies/presets.py index 2f360f5..bb7706c 100644 --- a/src/easy_tdx/backtest/strategies/presets.py +++ b/src/easy_tdx/backtest/strategies/presets.py @@ -98,6 +98,12 @@ STRATEGY_PRESETS: dict[str, dict[str, list[Any]]] = { # capital 仅作粗档扫描(1千万/1亿/10亿股),覆盖小盘→大盘 "capital": [1e7, 1e8, 1e9, 1e10], }, # 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 } diff --git a/src/easy_tdx/realtime/__init__.py b/src/easy_tdx/realtime/__init__.py index e44e24e..02454da 100644 --- a/src/easy_tdx/realtime/__init__.py +++ b/src/easy_tdx/realtime/__init__.py @@ -23,6 +23,7 @@ from easy_tdx.realtime.engine import ( RealtimeStrategy, ) from easy_tdx.realtime.feed import RealtimeDataFeed +from easy_tdx.realtime.session import SESSION_WINDOWS, is_trading_time, session_info __all__ = [ "EventBus", @@ -31,4 +32,7 @@ __all__ = [ "MarketEvent", "RealtimeDataFeed", "RealtimeStrategy", + "SESSION_WINDOWS", + "is_trading_time", + "session_info", ] diff --git a/src/easy_tdx/realtime/session.py b/src/easy_tdx/realtime/session.py new file mode 100644 index 0000000..cb679ad --- /dev/null +++ b/src/easy_tdx/realtime/session.py @@ -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:30:09:15 起集合竞价可看,11:30:30 容纳尾单撮合散点; +#: - 午盘 13:00:00-15:05:00:15: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(), + } diff --git a/src/easy_tdx/screen/__init__.py b/src/easy_tdx/screen/__init__.py index 31bc2a4..c845536 100644 --- a/src/easy_tdx/screen/__init__.py +++ b/src/easy_tdx/screen/__init__.py @@ -24,6 +24,7 @@ from easy_tdx.screen.strength import ( # noqa: F401 StrengthRanker, StrengthResult, ) +from easy_tdx.screen.universe import CORE_LEADERS, CORE_LEADERS_DESC # noqa: F401 __all__ = [ "SignalScanner", @@ -31,4 +32,6 @@ __all__ = [ "StrengthRanker", "StrengthResult", "STRENGTH_PRESETS", + "CORE_LEADERS", + "CORE_LEADERS_DESC", ] diff --git a/src/easy_tdx/screen/cli.py b/src/easy_tdx/screen/cli.py index 9f412dd..2d6df8c 100644 --- a/src/easy_tdx/screen/cli.py +++ b/src/easy_tdx/screen/cli.py @@ -35,7 +35,7 @@ def screen() -> None: @click.option( "--universe", default="all", - help="股票范围: all/sh/sz/<文件路径>(默认 all)", + help="股票范围: all/sh/sz/core/<文件路径>(默认 all;core=159只核心龙头池)", ) @click.option("--vipdoc", default=None, help="离线数据目录(默认自动检测)") @click.option("--cash", default=100_000.0, type=float, help="初始资金") diff --git a/src/easy_tdx/screen/scanner.py b/src/easy_tdx/screen/scanner.py index bc6edf0..1c9cf2a 100644 --- a/src/easy_tdx/screen/scanner.py +++ b/src/easy_tdx/screen/scanner.py @@ -108,6 +108,7 @@ class SignalScanner: - "all": 沪深全部 A 股(默认) - "sh": 仅上海 - "sz": 仅深圳 + - "core": 核心龙头池 159 只(跨沪深按名单过滤) - 文件路径: 每行一个 "市场 代码"(如 "SZ 000001") progress_callback: 进度回调函数(current, total, filename) workers: 并发工作进程数 @@ -357,13 +358,20 @@ class SignalScanner: """ # 确定要扫描的交易所目录 exchanges: list[str] = [] - if universe in ("all", "sz"): + if universe in ("all", "sz", "core"): exchanges.append("sz") - if universe in ("all", "sh"): + if universe in ("all", "sh", "core"): 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) # 扫描目录 @@ -383,6 +391,10 @@ class SignalScanner: if sec_type not in _A_STOCK_TYPES: continue + # 核心龙头池模式:只保留名单内的代码 + if core_codes is not None and code not in core_codes: + continue + market = exchange.upper() files.append((filepath, market, code)) diff --git a/src/easy_tdx/screen/strength.py b/src/easy_tdx/screen/strength.py index 9582e2e..9d73bf5 100644 --- a/src/easy_tdx/screen/strength.py +++ b/src/easy_tdx/screen/strength.py @@ -199,7 +199,7 @@ class StrengthRanker: """扫描全市场并返回强势股排名。 Args: - universe: all/sh/sz/<文件路径> + universe: all/sh/sz/core/<文件路径>(core=159 只核心龙头池) top_n: 返回前 N 名,0=全部 workers: 并发进程数(0=串行,4-8 推荐) progress_callback: 回调(current, total, name) @@ -229,13 +229,20 @@ class StrengthRanker: def _collect_files(self, universe: str) -> list[tuple[Path, str, str]]: """收集 A 股 .day 文件列表(复用 scanner 的逻辑)。""" exchanges: list[str] = [] - if universe in ("all", "sz"): + if universe in ("all", "sz", "core"): exchanges.append("sz") - if universe in ("all", "sh"): + if universe in ("all", "sh", "core"): 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) files: list[tuple[Path, str, str]] = [] @@ -247,6 +254,8 @@ class StrengthRanker: if _detect_security_type(filepath.name) not in _A_STOCK_TYPES: continue 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)) return files diff --git a/src/easy_tdx/screen/universe.py b/src/easy_tdx/screen/universe.py new file mode 100644 index 0000000..ac071f8 --- /dev/null +++ b/src/easy_tdx/screen/universe.py @@ -0,0 +1,190 @@ +"""扫描股票池(universe)定义。 + +核心龙头池 ``CORE_LEADERS``:159 只,按东方财富全行业龙头名单整理, +涵盖全球第一、国内第一、科技细分龙头与约 40 个行业冠军,四组分层。 +来源:社区 Fork(swimmingaaron/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) diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index 0a89219..472e694 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -293,6 +293,7 @@ def _create_app( 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.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_quotes import router as mac_quotes_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") # 服务器设置路由(列出/测速/切换 TDX host) app.include_router(server_router, prefix="/api/v1") + # LLM 配置与对话路由(AI 设置页 / AI 解读直连,无行情依赖) + app.include_router(llm_router, prefix="/api/v1") # 自选股路由(SQLite 持久化,纯 CRUD,不依赖行情连接) app.include_router(watchlist_router, prefix="/api/v1") # 实时行情 SSE 路由(依赖 lifespan 里的 QuoteStreamer) @@ -366,19 +369,42 @@ def _create_app( from starlette.responses import FileResponse class SPAStaticFiles(StaticFiles): - """StaticFiles + SPA fallback:404 时返回 index.html。""" + """StaticFiles + SPA fallback:404 时返回 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] try: - return await super().get_response(path, scope) + resp = await super().get_response(path, scope) except Exception: # 任何 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" if index.is_file(): - return FileResponse(str(index)) + return FileResponse(str(index), headers=_INDEX_HEADERS) 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") logger.info("Web UI mounted from %s (SPA fallback enabled)", dist_dir) diff --git a/src/easy_tdx/web/llm_history_store.py b/src/easy_tdx/web/llm_history_store.py new file mode 100644 index 0000000..d928416 --- /dev/null +++ b/src/easy_tdx/web/llm_history_store.py @@ -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 diff --git a/src/easy_tdx/web/routers/bars.py b/src/easy_tdx/web/routers/bars.py index 65d33b2..0a3af49 100644 --- a/src/easy_tdx/web/routers/bars.py +++ b/src/easy_tdx/web/routers/bars.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging from typing import Any +import numpy as np import pandas as pd from fastapi import APIRouter, Depends, Query @@ -26,6 +27,11 @@ router = APIRouter(tags=["bars"]) # 规整后保持的列顺序(匹配旧 SecurityBar 输出契约) _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: return DataFrameResponse.from_dataframe(df) @@ -71,13 +77,151 @@ def _normalize_mac_df(df: pd.DataFrame, daily_plus: bool) -> pd.DataFrame: 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) async def security_bars( market: str = Query(..., description="市场: SZ, SH, BJ"), code: str = Query(..., min_length=6, max_length=6), category: str = Query( "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), count: int = Query(800, ge=1, le=800), @@ -96,9 +240,21 @@ async def security_bars( MAC 主机未连接时自动回退 AsyncTdxClient.get_security_bars(无复权,adjust 参数忽略)。 输出契约与旧版一致:日线返回 ``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 单位:分钟线/日线 = 成交量(股);周/月/季/年线服务端原样返回真实 成交量/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) if mac_client is not None: period, times = period_times_from_category(cat) @@ -123,7 +279,7 @@ async def security_bars( df = await client.get_security_bars( 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) @@ -143,11 +299,13 @@ async def index_bars( vol 单位:日线/周线/月线/季线/年线 = 成交量(手)(周及以上周期服务端 原样返回真实成交量/100,已 ×100 还原);**分钟线协议不提供成交量** (报文中该字段实为成交额/100),vol 为 ``null``,请勿当作成交量使用。 + + 每根 bar 同样附带 ``pre_close/change/change_pct/amplitude_pct`` 衍生字段。 """ df = await client.get_index_bars( 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) diff --git a/src/easy_tdx/web/routers/llm.py b/src/easy_tdx/web/routers/llm.py new file mode 100644 index 0000000..0960ef8 --- /dev/null +++ b/src/easy_tdx/web/routers/llm.py @@ -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} diff --git a/src/easy_tdx/web/routers/market.py b/src/easy_tdx/web/routers/market.py index 1ec8572..d537918 100644 --- a/src/easy_tdx/web/routers/market.py +++ b/src/easy_tdx/web/routers/market.py @@ -76,6 +76,34 @@ async def market_stat( 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) async def fund_flow( market: str = Query(..., description="市场: SZ, SH"), @@ -117,7 +145,7 @@ async def market_strength( w60: float | None = Query(None, description="自定义 60 日权重(覆盖预设)"), vol_adjusted: bool | None = Query(None, description="波动率惩罚开关(覆盖预设)"), 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/core(core=核心龙头池159只)"), min_listed_days: int = Query(65, ge=30, description="最小上市天数"), min_amount: float = Query(0.0, ge=0, description="最近 5 日日均成交额下限(元)"), vipdoc: str | None = Query(None, description="离线数据目录(默认自动检测)"), diff --git a/strategies/README.md b/strategies/README.md index 383914f..11c1383 100644 --- a/strategies/README.md +++ b/strategies/README.md @@ -24,6 +24,7 @@ easy-tdx backtest SH 600519 --strategy-file strategies/macd_cross.py --cash 5000 | `bias_reversal.py` | 乖离率反转 | 反转 | 震荡回归 | | `volume_price.py` | 量价配合 | 综合判断 | 放量突破 | | `obv_trend.py` | OBV 能量潮趋势 | 量价趋势 | 资金持续流入的上升趋势 | +| `zig_breakout.py` | ZIG 右侧突破回补 | 右侧突破/波段 | 波谷启动建仓,见顶卖出后右侧突破前高回补(带硬止损;ZIG 为未来函数,回测信号有前视性) | ## 编写自定义策略 diff --git a/strategies/zig_breakout.py b/strategies/zig_breakout.py new file mode 100644 index 0000000..29dac7f --- /dev/null +++ b/strategies/zig_breakout.py @@ -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) diff --git a/tests/golden/backtest_metrics.json b/tests/golden/backtest_metrics.json index 088cd88..b1b43bc 100644 --- a/tests/golden/backtest_metrics.json +++ b/tests/golden/backtest_metrics.json @@ -336,6 +336,19 @@ "ulcer_index": 0.0, "var_95": -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 } } } diff --git a/tests/unit/test_ai_llm.py b/tests/unit/test_ai_llm.py new file mode 100644 index 0000000..af6ede4 --- /dev/null +++ b/tests/unit/test_ai_llm.py @@ -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 diff --git a/tests/unit/test_backtest_engine_vector.py b/tests/unit/test_backtest_engine_vector.py index 92766fd..fee664a 100644 --- a/tests/unit/test_backtest_engine_vector.py +++ b/tests/unit/test_backtest_engine_vector.py @@ -300,10 +300,20 @@ def test_auto_falls_back_on_mask_shape_mismatch() -> None: def test_vector_path_actually_used_for_builtins() -> None: - """默认 signal_path='auto' 下内置策略确实走了向量化(防止回退被掩盖)。""" + """默认 signal_path='auto' 下内置策略确实走了向量化(防止回退被掩盖)。 + + 例外白名单:信号依赖路径状态(无法用静态掩码等价表达)的策略, + 引擎对它们走逐 bar 回放(与 next() 完全一致),属设计而非回退。 + """ from easy_tdx.backtest.strategy import Strategy as Base + # zig_breakout 的 _breakout_level(见顶清仓后记录的前高)随持仓路径 + # 变化,掩码不可表达;见 builtin.py 该策略的注释 + path_dependent = {"zig_breakout"} + for name in get_registry().names(): + if name in path_dependent: + continue strat_cls = get_registry().get(name).strategy_cls assert strat_cls.entry_exit_masks is not Base.entry_exit_masks, ( f"{name} 未实现 entry_exit_masks,auto 将永远走逐 bar" diff --git a/tests/unit/test_bars_min120_derived.py b/tests/unit/test_bars_min120_derived.py new file mode 100644 index 0000000..1f57a0c --- /dev/null +++ b/tests/unit/test_bars_min120_derived.py @@ -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"} diff --git a/tests/unit/test_llm_history_store.py b/tests/unit/test_llm_history_store.py new file mode 100644 index 0000000..707f6fb --- /dev/null +++ b/tests/unit/test_llm_history_store.py @@ -0,0 +1,69 @@ +"""AI 解读历史存储测试(llm_history_store.py,v1.29)。""" + +from __future__ import annotations + +import pytest + +from easy_tdx.web.llm_history_store import LlmHistoryRecord, LlmHistoryStore + + +@pytest.fixture() +def store(tmp_path, monkeypatch): + monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path)) + return LlmHistoryStore() + + +def _rec(**kw) -> LlmHistoryRecord: + base = dict(provider="zhipu", model="glm-5.3-flash", prompt="报告…", reply="解读…") + base.update(kw) + return LlmHistoryRecord(**base) + + +class TestLlmHistoryStore: + def test_add_and_list_newest_first(self, store): + store.add(_rec(reply="第一条")) + store.add(_rec(reply="第二条")) + items = store.list_all() + assert len(items) == 2 + assert items[0].reply == "第二条" # 倒序 + assert items[0].id is not None and items[0].created_at + + def test_context_roundtrip(self, store): + store.add( + _rec( + strategy="zig_breakout", + strategy_label="ZIG 右侧突破回补", + symbol="600519", + category="DAY", + params={"zig_delta": 5.0, "confirm_pct": 2.0}, + start_date="2024-01-01", + end_date="2025-01-01", + ) + ) + it = store.list_all()[0] + assert it.strategy == "zig_breakout" and it.symbol == "600519" + assert it.params == {"zig_delta": 5.0, "confirm_pct": 2.0} # JSON 往返保真 + assert it.start_date == "2024-01-01" + + def test_corrupt_params_json_tolerated(self, store, tmp_path): + store.add(_rec()) + # 手工写坏 params 列,读取不应抛异常 + import sqlite3 + + with sqlite3.connect(store.db_path) as conn: + conn.execute("UPDATE llm_history SET params = '{broken'") + assert store.list_all()[0].params == {} + + def test_delete_and_clear(self, store): + a = store.add(_rec()) + store.add(_rec()) + assert store.delete(a.id) is True + assert store.delete(a.id) is False # 重复删除 + assert len(store.list_all()) == 1 + assert store.clear() == 1 + assert store.list_all() == [] + + def test_limit(self, store): + for i in range(5): + store.add(_rec(reply=f"r{i}")) + assert len(store.list_all(limit=3)) == 3 diff --git a/tests/unit/test_mytt_zig.py b/tests/unit/test_mytt_zig.py new file mode 100644 index 0000000..64b3fda --- /dev/null +++ b/tests/unit/test_mytt_zig.py @@ -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 diff --git a/tests/unit/test_realtime_session.py b/tests/unit/test_realtime_session.py new file mode 100644 index 0000000..c4b08b8 --- /dev/null +++ b/tests/unit/test_realtime_session.py @@ -0,0 +1,58 @@ +"""交易时段判断单元测试(realtime/session.py,v1.29)。 + +覆盖:窗口边界(09:15/11:30:30/13:00/15:05)、午休、周末、 +盘前盘后、session_info 响应结构。 +""" + +from __future__ import annotations + +from datetime import datetime + +from easy_tdx.realtime.session import SESSION_WINDOWS, is_trading_time, session_info + + +def _dt(s: str) -> datetime: + # 2026-09-02 是周三(盘中日) + return datetime.strptime(f"2026-09-02 {s}", "%Y-%m-%d %H:%M:%S") + + +class TestIsTradingTime: + def test_weekday_morning_session(self): + assert is_trading_time(_dt("09:15:00")) # 集合竞价起 + assert is_trading_time(_dt("10:30:00")) + assert is_trading_time(_dt("11:30:30")) # 窗口含端 + + def test_lunch_break_excluded(self): + assert not is_trading_time(_dt("11:31:00")) + assert not is_trading_time(_dt("12:30:00")) + assert not is_trading_time(_dt("12:59:59")) + + def test_afternoon_session(self): + assert is_trading_time(_dt("13:00:00")) + assert is_trading_time(_dt("14:30:00")) + assert is_trading_time(_dt("15:05:00")) # 收盘竞价缓冲端点 + assert not is_trading_time(_dt("15:05:01")) + + def test_pre_and_post_market(self): + assert not is_trading_time(_dt("09:14:59")) + assert not is_trading_time(_dt("08:00:00")) + assert not is_trading_time(_dt("22:00:00")) + + def test_weekend_rejected(self): + # 2026-09-05 周六 / 2026-09-06 周日,取盘中时间也应为 False + assert not is_trading_time(datetime(2026, 9, 5, 10, 0)) + assert not is_trading_time(datetime(2026, 9, 6, 14, 0)) + + +class TestSessionInfo: + def test_shape(self): + info = session_info(_dt("10:00:00")) + assert info["is_trading_time"] is True + assert info["weekday"] == 2 # 周三 + assert len(info["sessions"]) == len(SESSION_WINDOWS) + assert info["session_desc"] == "09:15~11:30, 13:00~15:05" + assert "T" in info["server_time"] # isoformat + + def test_closed(self): + info = session_info(datetime(2026, 9, 5, 10, 0)) # 周六 + assert info["is_trading_time"] is False diff --git a/tests/unit/test_screen_universe_core.py b/tests/unit/test_screen_universe_core.py new file mode 100644 index 0000000..85a2c7f --- /dev/null +++ b/tests/unit/test_screen_universe_core.py @@ -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 下都被排除 diff --git a/tests/unit/test_web_spa_api_guard.py b/tests/unit/test_web_spa_api_guard.py new file mode 100644 index 0000000..763de77 --- /dev/null +++ b/tests/unit/test_web_spa_api_guard.py @@ -0,0 +1,84 @@ +"""SPA fallback 的 /api 守卫测试(v1.29)。 + +背景(实测踩坑):未注册的 ``/api/*`` 路径会掉进 StaticFiles 的 SPA +fallback 返回 200 + index.html——前端 ``resp.ok`` 为 true、``resp.json()`` +抛 ``Unexpected token '<'``,把"服务是旧版本/端点不存在"伪装成前端解析 +错误。守护:未知 /api 路径必须返回 JSON 404,前端路由路径仍回 index.html。 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +fastapi_testclient = pytest.importorskip("fastapi.testclient") + + +def _make_app_with_ui(tmp_path: Path): + """带假前端 dist 的 app(index.html + 一个资产文件)。""" + from easy_tdx.web.app import _create_app + + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("
+ 这份名单是什么:按东方财富公开的"全行业龙头股名单"整理的
+ 选股扫描范围(即离线扫描的 universe="core" 股票池,
+ easy-tdx screen scan --universe core 与
+ /market/strength?universe=core 均按此过滤),四组分层:全球第一 / 国内第一 /
+ 科技细分 / 行业冠军。名单仅描述"这些公司在其行业内规模/市占率领先"这一客观事实,
+ 不代表任何买入价值判断——龙头同样可能高估、滞涨或衰退。点击行查看个股详情。
+
+ 本页面展示的"核心龙头池"仅为策略扫描的股票范围筛选清单, + 不构成任何形式的个股推荐、买入建议或投资顾问服务。名单基于第三方公开 + 资料整理,可能存在滞后、遗漏或错误;"行业龙头"是对历史经营地位的描述, + 不预示未来股价表现。本工具及作者不对任何人依据本名单作出的投资行为及损失承担责任。 + 投资有风险,入市需谨慎,据此操作风险自负。 +
+{{ it.prompt }}
+ | Provider | +默认地址 | +默认模型 | +
|---|---|---|
| {{ p.label }} | +{{ p.base_url || '—' }} | +{{ p.default_model || '—' }} | +
+ 除 Claude 走 Anthropic 原生协议外,其余均走 OpenAI 兼容接口;「自定义」可填任意 + 兼容网关(如 OpenRouter、one-api、vLLM)。Ollama 本地服务无需 API Key。 + 环境变量 LLM_PROVIDER / LLM_API_KEY / LLM_BASE_URL / LLM_MODEL 在配置文件 + 缺字段时兜底生效。 +
+