From ecb49cbaca017a0fcbce8efa2d9dc3b03dce4664 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Tue, 13 Oct 2026 13:40:17 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v0.3.6=20=E2=80=94=20=E6=A0=B8?= =?UTF-8?q?=E5=BF=83+=E6=89=A9=E5=B1=95=E6=9E=B6=E6=9E=84=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 0.3.6 分支全部内容 (36 提交, 详细历史见 0.3.6 分支): ■ 数据与插件 - TdxQuant 数据源: 通达信量化接入 (实时/五档/日K/复权/分钟), tdxaidata 收进默认依赖 - 扩展表两级拉取配方: 概念/行业出厂即带完整可拉配置, 可作自定义接入参考示例 - 能力门禁日志带生效源; 标的池解析只走 instruments 维表 ■ 页面扩展化 - 11 个官方页面全量迁移为单文件扩展 (custom/*/extension.tsx), 删目录即卸载 - 运行时页面包系统: 打包产物上传即挂载, 免重建宿主 - 新装用户扩展页默认隐藏, 老用户升级菜单无感 ■ AI - Responses 协议接入; 会话历史与长任务跨设备同步 (服务端注册表) - 助手头部快捷入口 + 悬浮球拖拽 zoom 坐标系修复 + 系统设置开关 ■ 界面 - 设置「关于」子菜单: 版权/更新/打赏 + 开源许可弹窗与合规声明双保险 - 侧栏两态收起、数据/AI 助手快捷行 (同步呼吸光晕)、核心/扩展 ext 标记 - 界面缩放默认 90%; 菜单设置徽标 Swiss 化; 策略池「叠加」分组 - 情绪评分口径可配置; 财务画像 5 张子表手动拉取 ■ 文档与其他 - README 重构「核心+扩展」主线 + 架构图重画; 开放端点数核实为 65 - 版本号 bump 0.3.6; tdx 物化库测试夹具补 macOS dylib --- README.md | 210 +++-- VERSION | 2 +- assets/support/wechat-appreciation.jpg | Bin 72651 -> 100506 bytes backend/app/__init__.py | 2 +- backend/app/api/ext_data.py | 35 +- backend/app/api/ext_pages.py | 136 +++ backend/app/api/kline.py | 8 +- backend/app/api/settings.py | 137 ++- backend/app/api/task_registry.py | 63 ++ backend/app/config.py | 2 + backend/app/custom/assistant/routes.py | 35 + backend/app/custom/assistant/sessions.py | 104 +++ backend/app/custom/assistant/streaming.py | 19 +- backend/app/data_providers/capabilities.py | 89 ++ backend/app/data_providers/custom/loader.py | 8 + backend/app/jobs/daily_pipeline.py | 33 +- backend/app/main.py | 17 + backend/app/plugins/fuyao/plugin.yaml | 2 +- backend/app/plugins/tdxquant/__init__.py | 1 + backend/app/plugins/tdxquant/client.py | 260 ++++++ backend/app/plugins/tdxquant/plugin.yaml | 23 + backend/app/plugins/tdxquant/probe.py | 162 ++++ backend/app/plugins/tdxquant/provider.py | 782 ++++++++++++++++++ backend/app/secrets_store.py | 10 + backend/app/services/ai_provider.py | 119 ++- backend/app/services/ai_responses.py | 516 ++++++++++++ backend/app/services/depth_service.py | 22 +- backend/app/services/emotion_score.py | 243 ++++++ backend/app/services/ext_data.py | 5 + backend/app/services/ext_fanout.py | 155 ++++ backend/app/services/ext_pages.py | 133 +++ backend/app/services/ext_presets.py | 336 ++++---- backend/app/services/ext_pull.py | 66 +- backend/app/services/extend_history.py | 36 +- backend/app/services/financial_sync.py | 13 +- backend/app/services/index_sync.py | 2 + backend/app/services/kline_sync.py | 16 + .../app/services/market_overview_builder.py | 57 +- backend/app/services/preferences.py | 32 + backend/app/services/quote_service.py | 5 +- backend/app/services/task_registry.py | 178 ++++ .../app/strategy/prompts/strategy-guide.md | 3 +- backend/pyproject.toml | 4 +- backend/tests/test_ai_probe.py | 161 ++++ backend/tests/test_ai_responses.py | 613 ++++++++++++++ .../tests/test_custom_assistant_sessions.py | 97 +++ backend/tests/test_emotion_score.py | 200 +++++ backend/tests/test_ext_fanout.py | 293 +++++++ backend/tests/test_ext_pages.py | 138 ++++ .../tests/test_ext_preset_dimension_values.py | 26 +- backend/tests/test_ext_presets_startup.py | 126 ++- backend/tests/test_packaging_spec.py | 8 + backend/tests/test_resolve_universe_index.py | 52 +- backend/tests/test_secrets_external_edit.py | 72 ++ backend/tests/test_sponsor_models.py | 134 +++ backend/tests/test_task_registry.py | 106 +++ backend/tests/test_tdxquant_provider.py | 625 ++++++++++++++ backend/uv.lock | 11 + docs/configuration.md | 1 + docs/factor-system-design.md | 2 +- docs/features.md | 14 +- docs/page-sdk.md | 72 ++ docs/plugin-development.md | 10 + frontend/index.html | 12 +- frontend/package.json | 2 +- frontend/page-pack-template/README.md | 25 + frontend/page-pack-template/package.json | 20 + frontend/page-pack-template/src/extension.tsx | 86 ++ frontend/page-pack-template/src/tsp.ts | 18 + frontend/page-pack-template/vite.config.ts | 26 + frontend/public/donate-wechat.jpg | Bin 0 -> 100506 bytes frontend/src/components/Layout.tsx | 166 ++-- frontend/src/components/OssLicenseDialog.tsx | 138 ++++ frontend/src/components/analysis-shared.tsx | 42 +- .../dashboard/EmotionScoreConfigDialog.tsx | 266 ++++++ .../src/components/dashboard/registry.tsx | 34 +- frontend/src/components/dashboard/shared.tsx | 26 +- .../components/data/FinancialSyncPanel.tsx | 198 +++++ frontend/src/components/data/StatCard.tsx | 16 +- .../components/ext-data/ExtDataPullPanel.tsx | 45 +- .../screener/StrategyBuilderDialog.tsx | 10 +- .../screener/StrategyPoolDialog.test.tsx | 28 +- .../screener/StrategyPoolDialog.tsx | 14 +- frontend/src/custom/README.md | 36 +- .../abnormal/extension.tsx} | 16 + .../custom/assistant/AssistantLauncher.tsx | 2 - frontend/src/custom/assistant/extension.tsx | 14 + .../custom/assistant/sessionsMerge.test.ts | 40 + .../src/custom/assistant/sessionsMerge.ts | 36 + frontend/src/custom/assistant/store.ts | 76 +- .../src/custom/assistant/ui/AiConfigEntry.tsx | 124 --- .../custom/assistant/ui/AssistantDrawer.tsx | 4 +- .../assistant/ui/AssistantFloatingButton.tsx | 34 +- .../assistant/ui/AssistantHeaderEntry.tsx | 36 + .../assistant/ui/FabVisibilitySetting.tsx | 39 + .../concept-analysis/extension.tsx} | 29 +- .../financials/extension.tsx} | 16 + .../industry-analysis/extension.tsx} | 29 +- .../limit-ladder/extension.tsx} | 16 + .../Lots.tsx => custom/lots/extension.tsx} | 16 + .../monitor/extension.tsx} | 16 + .../Paper.tsx => custom/paper/extension.tsx} | 16 + .../review/extension.tsx} | 21 +- .../signals/extension.tsx} | 16 + .../stock-analysis/extension.tsx} | 16 + frontend/src/data/ossLicenses.ts | 101 +++ frontend/src/extensions/runtime.test.ts | 106 +++ frontend/src/extensions/runtime.tsx | 187 +++++ frontend/src/extensions/types.ts | 10 +- frontend/src/index.css | 36 + frontend/src/lib/api.ts | 167 +++- frontend/src/lib/backtestTask.ts | 41 +- frontend/src/lib/miningTask.ts | 52 +- frontend/src/lib/navOrder.test.ts | 68 ++ frontend/src/lib/navOrder.ts | 62 ++ frontend/src/lib/optimizerTask.ts | 46 +- frontend/src/lib/pageZoom.ts | 10 +- frontend/src/lib/queryKeys.ts | 1 + frontend/src/lib/taskRegistry.ts | 80 ++ frontend/src/lib/walkforwardTask.ts | 46 +- frontend/src/main.tsx | 9 + frontend/src/pages/Data.tsx | 22 +- frontend/src/pages/Onboarding.tsx | 367 ++++---- frontend/src/pages/Screener.tsx | 45 +- frontend/src/pages/Settings.tsx | 6 +- frontend/src/pages/settings/AI.tsx | 606 +++++++++----- frontend/src/pages/settings/About.tsx | 254 ++++++ frontend/src/pages/settings/DataSources.tsx | 65 +- frontend/src/pages/settings/MenuSettings.tsx | 101 +-- frontend/src/pages/settings/PagePacks.tsx | 191 +++++ frontend/src/pages/settings/System.tsx | 109 +-- frontend/src/router.tsx | 30 - packaging/tsp.spec | 4 + 133 files changed, 10103 insertions(+), 1381 deletions(-) create mode 100644 backend/app/api/ext_pages.py create mode 100644 backend/app/api/task_registry.py create mode 100644 backend/app/custom/assistant/sessions.py create mode 100644 backend/app/plugins/tdxquant/__init__.py create mode 100644 backend/app/plugins/tdxquant/client.py create mode 100644 backend/app/plugins/tdxquant/plugin.yaml create mode 100644 backend/app/plugins/tdxquant/probe.py create mode 100644 backend/app/plugins/tdxquant/provider.py create mode 100644 backend/app/services/ai_responses.py create mode 100644 backend/app/services/emotion_score.py create mode 100644 backend/app/services/ext_fanout.py create mode 100644 backend/app/services/ext_pages.py create mode 100644 backend/app/services/task_registry.py create mode 100644 backend/tests/test_ai_probe.py create mode 100644 backend/tests/test_ai_responses.py create mode 100644 backend/tests/test_custom_assistant_sessions.py create mode 100644 backend/tests/test_emotion_score.py create mode 100644 backend/tests/test_ext_fanout.py create mode 100644 backend/tests/test_ext_pages.py create mode 100644 backend/tests/test_secrets_external_edit.py create mode 100644 backend/tests/test_sponsor_models.py create mode 100644 backend/tests/test_task_registry.py create mode 100644 backend/tests/test_tdxquant_provider.py create mode 100644 docs/page-sdk.md create mode 100644 frontend/page-pack-template/README.md create mode 100644 frontend/page-pack-template/package.json create mode 100644 frontend/page-pack-template/src/extension.tsx create mode 100644 frontend/page-pack-template/src/tsp.ts create mode 100644 frontend/page-pack-template/vite.config.ts create mode 100644 frontend/public/donate-wechat.jpg create mode 100644 frontend/src/components/OssLicenseDialog.tsx create mode 100644 frontend/src/components/dashboard/EmotionScoreConfigDialog.tsx create mode 100644 frontend/src/components/data/FinancialSyncPanel.tsx rename frontend/src/{pages/AbnormalMoves.tsx => custom/abnormal/extension.tsx} (99%) create mode 100644 frontend/src/custom/assistant/sessionsMerge.test.ts create mode 100644 frontend/src/custom/assistant/sessionsMerge.ts delete mode 100644 frontend/src/custom/assistant/ui/AiConfigEntry.tsx create mode 100644 frontend/src/custom/assistant/ui/AssistantHeaderEntry.tsx create mode 100644 frontend/src/custom/assistant/ui/FabVisibilitySetting.tsx rename frontend/src/{pages/ConceptAnalysis.tsx => custom/concept-analysis/extension.tsx} (96%) rename frontend/src/{pages/Financials.tsx => custom/financials/extension.tsx} (96%) rename frontend/src/{pages/IndustryAnalysis.tsx => custom/industry-analysis/extension.tsx} (96%) rename frontend/src/{pages/LimitUpLadder.tsx => custom/limit-ladder/extension.tsx} (99%) rename frontend/src/{pages/Lots.tsx => custom/lots/extension.tsx} (97%) rename frontend/src/{pages/Monitor.tsx => custom/monitor/extension.tsx} (99%) rename frontend/src/{pages/Paper.tsx => custom/paper/extension.tsx} (99%) rename frontend/src/{pages/Review.tsx => custom/review/extension.tsx} (98%) rename frontend/src/{pages/Signals.tsx => custom/signals/extension.tsx} (94%) rename frontend/src/{pages/StockAnalysis.tsx => custom/stock-analysis/extension.tsx} (96%) create mode 100644 frontend/src/data/ossLicenses.ts create mode 100644 frontend/src/extensions/runtime.test.ts create mode 100644 frontend/src/extensions/runtime.tsx create mode 100644 frontend/src/lib/navOrder.test.ts create mode 100644 frontend/src/lib/navOrder.ts create mode 100644 frontend/src/lib/taskRegistry.ts create mode 100644 frontend/src/pages/settings/About.tsx create mode 100644 frontend/src/pages/settings/PagePacks.tsx diff --git a/README.md b/README.md index 8b081c04..12e070c0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![个人开源](https://img.shields.io/badge/%E4%B8%AA%E4%BA%BA%E5%BC%80%E6%BA%90-green?style=for-the-badge&labelColor=red)](https://github.com/shy3130/tick-stock-panel) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](./LICENSE) [![Engine: Polars](https://img.shields.io/badge/Engine-Polars-6f42c1?style=for-the-badge)](https://pola.rs/) -[![Open API](https://img.shields.io/badge/Open_API-61_%E7%AB%AF%E7%82%B9-00b386?style=for-the-badge)](./docs/open-platform-plan.md) +[![Open API](https://img.shields.io/badge/Open_API-65_%E7%AB%AF%E7%82%B9-00b386?style=for-the-badge)](./docs/open-platform-plan.md) [![MCP](https://img.shields.io/badge/MCP-12_%E5%B7%A5%E5%85%B7-7c3aed?style=for-the-badge)](./mcp-server/README.md) [![Build: Docker CI](https://github.com/shy3130/tick-stock-panel/actions/workflows/docker.yml/badge.svg)](https://github.com/shy3130/tick-stock-panel/actions/workflows/docker.yml) @@ -24,7 +24,7 @@ shy3130%2Ftick-stock-panel | Trendshift - + @@ -50,7 +50,7 @@ About RunningHub API — 产品介绍 -**本项目由 [RunningHub](https://www.runninghub.ai/call-api?source=github&inviteCode=edt5wh7c) 提供支持** · 单一接口直连 400+ 主流大模型 · 免费测试 +**本项目由 [RunningHub](https://www.runninghub.ai/call-api?source=github&inviteCode=edt5wh7c) 提供支持** · 单一接口直连 400+ 主流大模型 · [DeepSeek v4.1 Flash 6 折](https://www.runninghub.ai/call-api?source=github&inviteCode=edt5wh7c) · 免费测试 @@ -71,7 +71,7 @@ | 策略、回测、监控各用一套工具,口径对不上 | 全站统一 **enriched 数据口径**:策略 → 回测 → 监控 → 复盘一条链 | | 盘中异动靠人盯盘,错过就是错过 | **竞价/盘中/偏移**全时段异动 + 实时弹窗、语音播报、飞书推送 | | 想查个数据要在几个页面之间来回点 | **AI 对话助手**:一句话问出全站数据,取数过程逐条可见、可展开核对;还能经确认卡放行生成信号、跑回测、补数据 | -| 想基于面板数据做自己的工具/机器人,只能爬页面 | **开放接口 + MCP**:Token 六档权限、61 端点契约化、SSE 事件流,AI 客户端即插即用 | +| 想基于面板数据做自己的工具/机器人,只能爬页面 | **开放接口 + MCP**:Token 六档权限、65 端点契约化、SSE 事件流,AI 客户端即插即用 | | 付费终端贵、云端平台数据出不了本地 | **自托管**:Docker 单容器,数据全部落在本地 Parquet,零运维 | ## ✨ 核心功能 @@ -141,14 +141,14 @@ + + + + +
61
开放 API 端点
65
开放 API 端点
6
Token 权限档
12
MCP 工具
2400+
测试全绿 · CI 守护
-**🧰 数据扩展**
插件化数据源, 扩展字段成页, 按日历史回补 +**🧰 数据扩展**
插件化数据源, 扩展字段成页, 两级拉取配方, 按日历史回补
-**🌐 开放接口**
Token 六档权限, 61 端点契约化, 读+写闭环 +**🌐 开放接口**
Token 六档权限, 65 端点契约化, 读+写闭环
@@ -160,61 +160,28 @@ **🧠 AI 对话助手**
一句话问全站数据, 工具足迹逐条可核对 +
+ +**🧩 扩展页面**
11 个官方页面单文件化, 删目录即卸载、复制即导入 + +
+ +**📦 页面包**
页面打包产物上传即挂载, 免重建宿主, 刷新生效 + +
+ +**🎛️ 菜单设置**
显隐/排序 + 内置/自定义/扩展三类管理与架构徽标 +
-
-📦 主要页面与功能 - -**📊 行情总览** - -- **看板** Dashboard — 市场情绪评分 + 涨跌/成交额榜单 + 概念/行业领涨领跌(点击板块直达成分股,领涨股带涨跌幅) + 大盘异动事件流,一日全貌; **布局可自定义** — 12 列吸附网格, 组件拖拽换位/角柄自由调宽高, 可增删组件、嵌入外部链接(iframe 沙箱), 跨设备同步, 一键恢复默认 -- **自选** Watchlist — 自选池,多分组管理(M:N),表格/卡片双视图,换手/量比/RSI 等实时指标,按档位分流实时刷新 -- **指数** Indices — 沪深指数浏览与同步 - -**🔍 策略与回测** - -- **策略** Screener — Polars 毫秒级扫描全 A 股,日线/分钟策略统一单池,按策略声明周期自动路由执行 -- **回测** Backtest — 四种研究视图: - - **因子回测** — IC/IR、分层收益、多空组合,62+ 因子目录先筛掉无效指标 - - **策略回测** — 净值曲线、回撤、夏普、胜率、盈亏比、蒙卡回撤,T+1/手续费/滑点/止损,SSE 流式进度;评分因子策略附带「因子归因」(胜/败单入场信号日因子对比) - - **分钟策略回测** — 逐交易日回放信号、分钟收盘入场,分钟级成交明细 - - **验证** — 参数敏感性与滚动样本外 - - 研究闭环:结果导出 CSV(概要/净值/交易明细/分标的统计) → 保存候选 → **一键载入复测** -- **因子** Factors — 检验/因子库/编辑器/组合四 tab:IC·分层·Newey-West 检验、自定义 DSL 因子(25 算子点选、双语字段、我的因子模板)、版本与生命周期管理;因子库可**一键生成排名策略**,策略触发器可直接引用因子条件 -- **挖掘** Mining — 嵌套样本外因子与策略挖掘:训练区间因子方向重估 + 相关性去重 + 多因子排名组合搜索,自有策略作对照轨;候选入库,显式确认后才发布,永不自动上线 - -**📈 个股与板块分析** - -- **个股分析** Stock Analysis (Beta) — 日K + 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面) -- **财务分析** Financials — 利润表/资负表/现金流/关键指标(多源并集合并,fuyao 财务四表适配) + AI 解读 -- **概念分析 / 行业分析** — ths 维度涨幅轮动矩阵 + 领涨/领跌主线 + 个股穿透 -- **市场环境** Regime — 情绪周期 6 阶段(冰点/启动/主升/高潮/退潮/修复,连板梯队驱动,EMA 平滑 + 2 日确认)+ 概念/行业主线排名,与 5 档环境分并存 -- **连板梯队** Limit Up Ladder — 连板层级统计 + 概念/行业分布 + 封单监控(可切换连跌梯队) - -**🔔 监控与复盘** - -- **监控中心** Monitor — 策略/个股信号/价格/异动四类规则,支持自选分组作用域,盘中实时弹窗 + 语音播报(播报个股名称与信号) + 触发记录持久化 -- **持仓提醒** Lots — 记录个股/ETF 买入批次,自动生成止盈止损/到期监控规则 -- **信号库** Signals — 全部为用户定义的条件信号(字段+运算符+阈值,支持因子条件、涨跌停判定价与 AI 生成),供策略触发器/回测/监控统一取用;新装为空库,可自建或导入 -- **异动监控** Abnormal Moves — 按交易时间线三 tab: - - **竞价异动** — 同花顺盘前风向标(含当日/次日真实收益对照、追高风险标记)+ 全市场竞价扫描(待采集任务) - - **盘中异动** — 涨停/炸板/翘板/跌停/新高/新低/放量当日信号聚合,零新增采集 - - **偏移异动** — 交易所异动偏离值口径(主板 3 日 ±20%、创业板/科创板 ±30%、北交所 ±40%;10 日 +100%/−50%、30 日 +200%/−70%),实时接近度 -- **复盘** Review (Beta) — 盘后 AI 自动生成市场复盘,注入龙虎榜资金动向与盘前风向标对照;可定时执行、推送飞书、下载 Markdown - -**🗄️ 数据与扩展** - -- **数据** Data — 本地数据画像与同步状态(维表/日K/除权/Enriched/指数/ETF/分钟K/财务),盘后管道与历史扩展 -- **扩展分析** (动态菜单) — 把任意第三方/扩展数据字段配成一级菜单,与内置数据同台分析 -- **设置** Settings — 数据源与能力检测(能力路由矩阵、档位徽章)、AI 接口、实时监控、扩展页面、开放接口(Token 管理)、菜单与系统设置 - -**🤖 AI 助手** - -- **AI 对话助手** — 悬浮球 / 侧栏 AI 徽标旁入口 / ⌘K 呼出; 21 个查询工具覆盖个股·大盘·板块·自选·持仓·信号·策略·因子·数据完整性·扩展数据表, 外加 4 个**动作工具**(生成信号策略 / 回测 / 加自选 / 数据补全)——写操作先弹**确认卡**(完整待执行参数可见, 确认/取消/120s 超时自动取消)才执行; 逐字流式输出 + 工具调用足迹卡(参数与耗时可展开核对), 每条回答附风险与数据口径提示; 完全解耦的扩展模块, 删除目录即卸载 - -
+> 📄 **19 个页面的完整功能细节**见 [docs/features.md](./docs/features.md) 与 [screenshots/](./screenshots/README.md) —— 上表之外, 回测四种研究视图、异动三时间线、页面包运行时分发等都在其中。 --- @@ -257,7 +224,13 @@ ## 🏗️ 架构 -**TSP 是一个"数据工程优先"的本地量化工作台** —— 数据源、口径、执行、开放通道做成一套自洽的架构,功能只是长在上面的应用。八个特点,一句话一个: +**TSP 的一切设计围绕一条主线:核心底座 + 扩展层。** + +- **核心域(8 个)** 拥有数据与语义 —— 数据底座、能力路由、指标与执行、事件与开放通道。它们是自洽的最小系统,不依赖任何外围。 +- **扩展域(5 条通道)** 只消费核心契约 —— 数据源插件、扩展数据表、自定义策略/信号、页面扩展、后端 custom 模块。**可整体替换或移除而不伤核心**:删除目录即卸载、复制目录即导入。 +- 你看到的 19 个页面里,**11 个官方页面本身就是扩展**(单文件 `frontend/src/custom//extension.tsx`)—— 监控中心页面可以整个换掉,规则引擎与推送管道属核心照常运行。**连 AI 对话助手都是扩展域的参考实现。** + +> 一句话:核心是引擎,其余皆插件。下面这张图,先看懂虚线框与实线框的边界,就看懂了 TSP。 | 架构特点 | 一句话说明 | | :--- | :--- | @@ -265,10 +238,8 @@ | 🧬 **单一 enriched 口径** | 只存 15 列基础数据、现算 68 列指标信号;策略 → 回测 → 监控 → 复盘吃同一份数据 | | 📁 **文件型零运维存储** | Parquet + DuckDB,单容器零外部数据库,数据 100% 落在本地 | | 🔁 **回测 = 实盘同路径** | 策略执行只此一条 `StrategyEngine.run`;分钟策略逐日回放 +「当日已知」纪律,杜绝未来函数 | -| 🏛️ **网关居中的开放单体** | 面板密码会话与 Token 网关双通道并行;61 端点契约快照 + CI 守护,SSE 事件流与 MCP 平等开放 | -| 🧩 **核心域 / 扩展域边界** | 8 个核心域拥有数据与语义,5 条扩展通道只消费契约——删除目录即整体卸载,零核心改动 | +| 🏛️ **网关居中的开放单体** | 面板密码会话与 Token 网关双通道并行;65 端点契约快照 + CI 守护,SSE 事件流与 MCP 平等开放 | | 🚀 **重活进程隔离** | 回测跑在 spawn worker 子进程,刷新/切页重连不丢任务,主服务永不被计算卡住 | -| 📅 **交易日探针** | 节假日自动停掉实时轮询与分钟增量,零无效请求 | ### 总览 · 网关居中的开放单体 @@ -284,33 +255,36 @@ flowchart TB APPC["外部程序
脚本 · 看板"] end - GW{{"开放网关
Token · 六档 scope
限流 120/min · 61 端点契约"}} + GW{{"开放网关
Token · 六档 scope
限流 120/min · 65 端点契约"}} API["应用层 · ⚡ FastAPI
REST · SSE 事件流"] - subgraph CORE["核心域 · 数据与语义的所有者"] - S1[("能力路由 · 插件化数据源
enriched 分区存储
Parquet · DuckDB")] - S2["指标流水线 · 存15现算68列
策略 · 因子 · 回测 · 挖掘"] - S3["监控引擎 · 事件总线 · SSE 出站
扩展数据 · 认证配置"] - S1 -->|"enriched 口径"| S2 - S2 -->|"信号 · 事件"| S3 - end - - subgraph EXT["🧩 扩展域 · 5 条接入通道 —— 只消费开放契约, 零核心改动"] + subgraph BODY[""] direction LR - X1["数据源插件"] - X2["扩展数据表
读 + 写"] - X3["自定义策略
与信号"] - X4["声明式页面
前端插槽挂载"] - X5["custom 后端模块
★ AI 助手参考实现"] + subgraph CORE["⬛ 核心域 — 数据与语义的所有者 (8 个)"] + direction TB + S1[("① 数据底座
能力路由 · 插件化数据源
Parquet · DuckDB")] + S2["② 计算与执行
指标流水线(存15现算68列)
策略 · 因子 · 回测 · 挖掘"] + S3["③ 事件与开放
监控引擎 · 事件总线 · SSE"] + S1 -->|"enriched 口径"| S2 + S2 -->|"信号 · 事件"| S3 + end + subgraph EXT["🧩 扩展域 — 5 条通道 · 只消费契约, 可整体替换/移除"] + direction TB + X1["数据源插件
TickFlow / fuyao / TdxQuant"] + X2["扩展数据表
两级拉取 · 读+写"] + X3["自定义策略
与信号"] + X4["页面扩展
11 官方页单文件 + 运行时页面包"] + X5["custom 后端模块
★ AI 助手参考实现"] + end end UI -->|"密码会话"| API MCP -->|"Bearer + scope"| GW APPC -->|"Bearer + scope"| GW GW -->|"契约放行"| API - API ==> S1 - S3 -.-> X3 + API ==>|"数据与语义"| CORE + EXT ==>|"只消费开放契约 · 零核心改动"| CORE classDef client fill:#eef2ff,stroke:#6366f1,color:#312e81 classDef gate fill:#faf5ff,stroke:#8b5cf6,color:#6b21a8,stroke-width:2px @@ -331,8 +305,9 @@ flowchart TB class X5 star style CLIENTS fill:#f5f3ff,stroke:#c4b5fd,stroke-width:1.5px,color:#4c1d95 - style CORE fill:#f8fafc,stroke:#cbd5e1,stroke-width:1.5px,color:#0f172a - style EXT fill:#f8fafc,stroke:#94a3b8,stroke-width:1.5px,stroke-dasharray:6 4,color:#334155 + style BODY fill:transparent,stroke:transparent + style CORE fill:#f8fafc,stroke:#334155,stroke-width:2px,color:#0f172a + style EXT fill:#fefce8,stroke:#a16207,stroke-width:1.5px,stroke-dasharray:7 4,color:#713f12 ``` ### 核心域 / 扩展域边界 @@ -342,23 +317,22 @@ flowchart TB | | 内容 | 划线依据 | | :------------------ | :--------------------------------------------------------------------------------------------------------------------- | :---------------------------------------- | | **核心域 · 8** | 数据底座 / 能力路由 / 订阅锚点(自选) / 策略与回测 / 因子与挖掘 / 环境数据生产 / 监控推送管道 / 扩展数据存储 + 认证配置 | 被多个模块消费其数据与语义,自身不依赖外围 | -| **扩展域 · 5 通道** | 数据源插件 · 扩展数据表 · 自定义策略/信号 · 声明式页面/前端插槽 · 后端 custom 模块 | 只消费核心契约,可整体替换/移除而不伤核心 | +| **扩展域 · 5 通道** | 数据源插件 · 扩展数据表 · 自定义策略/信号 · 页面扩展(单文件源码 + 运行时页面包) · 后端 custom 模块 | 只消费核心契约,可整体替换/移除而不伤核心 | -设置 → 菜单设置里每个页面带 `核心 / 扩展` 徽标与归属原因;11 个内置页面(概念/行业/个股/财务分析、监控中心、异动、持仓提醒、模拟盘、信号库、复盘、连板梯队)都是**可被二开替换的消费页** —— 例如监控中心页面可换,规则引擎与推送管道属核心照常运行。详见 [docs/open-platform-plan.md](./docs/open-platform-plan.md)。 +设置 → 菜单设置里每个页面带 `核心 / 扩展` 架构徽标与 `内置 / 自定义 / 扩展` 类型徽标;11 个官方页面(概念/行业/个股/财务分析、监控中心、异动、持仓提醒、模拟盘、信号库、复盘、连板梯队)已全部落地为 `frontend/src/custom//extension.tsx` **单文件扩展** —— 删除目录即卸载、复制目录即导入;例如监控中心页面可换,规则引擎与推送管道属核心照常运行。新装默认隐藏这些扩展页面(菜单设置一键开启),老用户升级自动保持原菜单位置与排序。免宿主重建的运行时分发走**页面包**(设置 → 页面包,契约见 [docs/page-sdk.md](./docs/page-sdk.md))。详见 [docs/open-platform-plan.md](./docs/open-platform-plan.md)。 ### 关键机制 | 机制 | 说明 | | :------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **能力路由矩阵** | 各数据集按源声明能力独立路由,注册表集中定义、可扩展:TICKFLOW 档位探测(None/Free/Starter/Pro/Expert)+ 插件源能力声明,fail-closed(声明 `pct_unit` 未声明即拒)。同一数据集可随时换源,指标与回测口径不变 | -| **交易日探针** | fuyao 交易日历(确定性,含调休)→ tickflow 全市场行情时间戳探针(OR 语义)→ 工作日兜底;节假日自动停掉实时轮询与分钟增量,零无效请求 | | **财务多源合并** | 按 `(symbol, period_end)` 报告期累积,多源取并集、逐列按公告日取最新(PIT);公告前一律空值,绝不填 0 | | **非路由数据集直连** | 龙虎榜/盘前风向标/交易日历等 fuyao 专有能力不进路由矩阵,由独立服务直连消费——按日 JSON 缓存(历史不可变)、交易日回退、四态降级 | | **回测执行隔离** | 回测在 spawn worker 子进程运行,持久 run ID,刷新/切页重连不丢任务;子进程结果消息经锁保护回传 | -| **分层缓存** | enriched 读取时现算指标(存储仅 15 列基础数据,现算 68 列指标与信号)+ 进程内快照缓存;扩展字段按日分区快照,页面即配即用 | | **开放网关** | Token 通道与面板密码会话并行互不影响:网络门禁(默认仅本机/内网, `API_TOKEN_LOCAL_ONLY=0` 放开) → 认证 → 六档 scope 校验 → 每 Token 滑动窗口限流(默认 120 次/分,O(1) 内存)→ 放行;管理面(数据同步/表结构/设置)永不开放给 Token | | **事件总线** | 进程内发布/订阅(慢消费者丢旧保新,广播失败不反噬主流程),告警落盘唯一入口已挂接;SSE 流按票据 scope 过滤,票据一次性 60 秒过期 | -| **完全解耦扩展** | 后端 `app/custom/<包>/` 启动时自动发现、注册独立路由(版本不符或 setup 失败即隔离跳过), 前端 `src/custom/*/extension.tsx` 构建时自动挂载到插槽; 删除目录即整体卸载, 扩展无需改动核心 —— **AI 对话助手**即该机制的参考实现 | +| **交易日探针** | fuyao 交易日历(确定性,含调休)→ tickflow 全市场行情时间戳探针(OR 语义)→ 工作日兜底;节假日自动停掉实时轮询与分钟增量,零无效请求 | +| **扩展自动发现** | 后端 `app/custom/<包>/` 启动时自动发现、注册独立路由(版本不符或 setup 失败即隔离跳过);前端 `src/custom//extension.tsx` 单文件即完整扩展(组件+路由+导航),构建时自动挂载 —— 删除目录即整体卸载、复制目录即导入;运行时分发走**页面包**(上传即挂载,免重建宿主) | ### 技术栈 @@ -367,8 +341,8 @@ flowchart TB | **后端** | ![Python](https://img.shields.io/badge/Python_3.11+-3776AB?logo=python&logoColor=white) ![FastAPI](https://img.shields.io/badge/FastAPI-009688?logo=fastapi&logoColor=white) ![Pydantic](https://img.shields.io/badge/Pydantic_v2-E92063?logo=pydantic&logoColor=white) APScheduler · sse-starlette | | **数据** | ![Polars](https://img.shields.io/badge/Polars-CD882D?logo=polars&logoColor=white)(计算)· ![DuckDB](https://img.shields.io/badge/DuckDB-FFF100?logo=duckdb&logoColor=black)(查询)· Parquet(存储) | | **回测** | 自研仓位模拟引擎(T+1/费用/滑点/分钟回放)· vectorbt(部分路径) | -| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK · fuyao(同花顺 REST) · 插件化扩展(YAML 自定义源) | -| **AI**(可选) | ![OpenAI兼容](https://img.shields.io/badge/OpenAI兼容-412991?logo=openai&logoColor=white) DeepSeek / 通义 / Ollama 等 · 策略生成 / 报告 / **对话助手**(助手依赖工具调用能力, 需 OpenAI 兼容接口) | +| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK · fuyao(同花顺 REST) · [TdxQuant](https://help.tdx.com.cn/quant/)(通达信量化,实时/五档/日K/复权/分钟) · 插件化扩展(YAML 自定义源) | +| **AI**(可选) | ![OpenAI兼容](https://img.shields.io/badge/OpenAI兼容-412991?logo=openai&logoColor=white) Chat Completions 与 Responses 双协议 · DeepSeek / 通义 / Ollama 等 · 策略生成 / 报告 / **对话助手**(助手依赖工具调用能力, 需 OpenAI 兼容接口) | | **MCP** | [mcp-server](./mcp-server/README.md)(官方 SDK 2.x, stdio) — 12 工具按 scope 暴露,权限裁决复用开放网关 | | **前端** | ![React 18](https://img.shields.io/badge/React_18-61DAFB?logo=react&logoColor=black) ![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) ![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?logo=tailwindcss&logoColor=white) Tanstack Query · [Lightweight Charts](https://www.tradingview.com/lightweight-charts/)(TradingView 开源) · ![ECharts](https://img.shields.io/badge/ECharts-AA344D?logo=apacheecharts&logoColor=white) · dnd-kit | | **部署** | ![Docker](https://img.shields.io/badge/Docker_单容器-2496ED?logo=docker&logoColor=white) 两阶段构建,前端 dist 拷进后端镜像 | @@ -390,11 +364,10 @@ flowchart TB | 回测 | 子进程隔离不卡主服务,刷新重连不丢任务 | spawn worker + 持久 run ID + 重活并发闸 | | 部署与兼容 | 单容器**零外部数据库**(Parquet + DuckDB 文件型) · 新老 CPU 双内核 | polars rtcompat 兼容内核,运行时自动探测 | -### 开放层(0.3.2 新架构带来) +> 开放层的性能(网关裁决 O(1)、契约快照守护、亚秒级事件推送等)见下方[开放能力](#-开放能力open-api--mcp)章节。 -| 能力 | 表现 | 靠什么 | -| :----------- | :---------------------------------- | :----------------------------------------- | -| 开放 API | **61 端点** · 每 Token 120 次/分 | 进程内滑动窗口限流,O(1) 内存,零外部依赖 | +----------- | :---------------------------------- | :----------------------------------------- | +| 开放 API | **65 端点** · 每 Token 120 次/分 | 进程内滑动窗口限流,O(1) 内存,零外部依赖 | | 网关裁决 | 认证→scope→限流,纯内存单函数完成 | 单一裁决点(`evaluate`),无数据库往返 | | 契约稳定性 | 机器可读契约 + **快照测试**守护 | 开放面增删必须显式改快照,CI 拦截无意识变更 | | 数据写闭环 | 外部写入复用管理端同一校验/落盘路径 | `write:ext` 只写行数据,表结构锁死管理面 | @@ -427,7 +400,7 @@ flowchart TB | **API Token** | `设置 → 开放接口` 创建,明文只显示一次,SHA-256 哈希存储,吊销立即生效 | | **网络门禁** | Token 通道默认仅本机/内网调用,公网一律 403(`API_TOKEN_LOCAL_ONLY=0` 显式放开,远程推荐 SSH 隧道);门禁先于验签,不泄露 Token 有效性也不消耗限流额度 | | **六档 scope** | `read:market` / `read:ext` / `write:ext` / `read:analysis` / `run:backtest` / `paper:trade`,管理面永不开放给 Token | -| **61 端点契约** | `GET /api/openapi.json?tier=a` 机器可读,Postman/代码生成即用;**契约快照测试 + CI 守护**,开放面变更必须显式确认 | +| **65 端点契约** | `GET /api/openapi.json?tier=a` 机器可读,Postman/代码生成即用;**契约快照测试 + CI 守护**,开放面变更必须显式确认 | | **数据写闭环** | `write:ext` 程序化写入扩展表行数据(与内置数据同台分析),表结构锁死在管理面 | | **SSE 事件流** | 60 秒一次性票据订阅实时告警推送,票据只继承 scope 不放大权限 | | **MCP 服务器** | 12 个精选工具,按 Token scope 暴露;模拟盘交易刻意不交给 AI | @@ -452,7 +425,7 @@ curl -H "Authorization: Bearer tsp_xxxx" \ | 打开方式 | 说明 | | :---------------- | :----------------------------------- | | **悬浮球** | 可拖动, 位置记忆; 生成中带状态指示点 | -| **AI 徽标旁入口** | 侧栏顶部模型徽标右侧, 一键展开 | +| **侧栏「AI 助手」行** | 左上角状态区 (AI 配置行下方), 一键展开 | | **⌘K / Ctrl+K** | 全局快捷键随时呼出, Esc 关闭 | **能问什么** — 21 个查询工具覆盖全站页面能力, 4 个动作工具经确认卡放行: @@ -470,19 +443,13 @@ curl -H "Authorization: Bearer tsp_xxxx" \ **交互设计** -- **逐字流式输出** — 文本按 token 逐步渲染, 长回答不再"整段蹦出" -- **分组快捷建议** — 新会话页按 行情与大盘 / 我的与个股 / 策略与信号 / 数据与扩展 分组给出 18 条按文字自适应排布的建议(含策略对比、个股横向对比), 并提示扩展内容同样可以问答 - **工具足迹卡** — 每次调用的工具名、参数、耗时、结果摘要均可展开核对; 取数可核对是设计铁律 -- **走势双图** — 个股问答自动附工具真实序列的走势小卡(日K蜡烛 + 当日分时), 并排展示且日K固定左侧、分时右侧; 宽表格(>4 列)按内容取宽不折行, 超宽时横向滚动 -- **动作确认卡** — 生成信号/回测/加自选/数据补全等写操作先弹确认卡: 动作名 + 影响说明 + **完整待执行参数**, 你点「确认执行」才会真正运行; 拒绝或 120 秒超时自动取消, 模型被明确告知此时不得重试 -- **轮次检查点** — 工具调用不设硬上限, 连续 N 轮未完成时弹「继续 / 停止」选择卡(120 秒未选自动停止), 继续则再跑一个周期, 已生成内容始终保留; 检查点轮数可在 设置→AI 调整(默认 100, 最小 5, 0 = 不检查) -- **非模态面板** — 从页面右缘滑入, 默认 720px, 左缘拖拽调宽(宽度记忆), 边看行情边问 -- **会话历史** — 本地保存, 支持多会话切换与删除, 刷新不丢 -- **固定合规提示** — 每条回答完成后附风险提示与数据口径提示 +- **动作确认卡** — 生成信号/回测/加自选/数据补全等写操作先弹确认卡(完整待执行参数可见), 你点「确认执行」才真正运行; 拒绝或 120 秒超时自动取消 +- **逐字流式 + 非模态面板** — 从页面右缘滑入可拖宽(宽度记忆), 边看行情边问; 新会话带分组快捷建议, 个股问答附走势双图(日K + 分时) +- **会话历史跨设备** — 服务端注册表同步(回测/挖掘/优化/滚动任务同步接回), 本地存储降级为缓存 +- **固定合规提示** — 每条回答完成后附风险提示与数据口径提示; 轮次检查点与连续执行上限可在设置调整 -**完全解耦的扩展模块** - -助手是项目扩展系统的参考实现: 后端 `app/custom/assistant/`(启动时自动发现并注册独立路由)与前端 `src/custom/assistant/`(构建时自动挂载到插槽)各自独立, **删除目录即整体卸载**。数据补全工具与数据页**共用同一条触发路径**(为此在核心 API 层抽取了两个共用触发函数, 端点行为不变), 单飞去重与进度上报天然一致。未配置 AI Key 或使用不支持工具调用的供应商(如 Codex CLI)时 fail-closed, 直接提示前往设置页。 +**扩展域的参考实现** — 后端 `app/custom/assistant/` + 前端 `src/custom/assistant/` 各自独立, 删除目录即整体卸载(见上方[架构](#-架构)); 数据补全工具与数据页共用同一条触发路径, 单飞去重天然一致。未配置 AI Key 或供应商不支持工具调用时 fail-closed, 直接提示前往设置页。 > ⚠️ 助手是数据分析工具, 不提供买卖指令; 涉及交易决策的问题会转换为客观的技术/财务状态、关键价位、风险因素与条件情景。 @@ -549,6 +516,7 @@ CODEX_CLI_VERSION=0.144.3 docker compose up --build > Codex CLI 模式允许 TickFlow 容器读取本机 Codex 登录凭据,仅应在受信任的本机环境启用。凭据目录以只读方式挂载,不会写入镜像。 + > 📖 Docker 进阶、老 CPU 兼容、访问密码设置等见 [docs/deployment.md](./docs/deployment.md)。 @@ -602,6 +570,7 @@ xattr -dr com.apple.quarantine /Applications/TSP.app 5. **监控中心**配规则,盘中实时弹窗 + 持久化记录;**异动监控**覆盖竞价/盘中/偏移全时段 6. 配好 AI Key 后,**悬浮球 / ⌘K** 呼出 **AI 对话助手**,直接问「今天市场怎么样」「我的自选表现如何」「我的扩展数据表里有什么」,或让它「生成一个放量站上 20 日线的信号」「检查一下数据完整性」——写操作会先弹确认卡, 你点确认才执行 7. 想接自己的程序?**设置 → 开放接口**建个 Token,照着 [examples/open-api](./examples/open-api/README.md) 十分钟跑通第一个脚本;AI 玩家直接配 [MCP](./mcp-server/README.md) +8. 想定制页面?核心之外 11 个官方页面都是单文件扩展(新装默认隐藏,在**设置 → 菜单设置**开启);自己开发的页面用 [frontend/page-pack-template](./frontend/page-pack-template) 打包,经**设置 → 页面包**上传即用,宿主无需重建 --- @@ -631,6 +600,7 @@ PORT=3018 # 服务端口 | **v0.3** | 能力路由矩阵 · fuyao 数据源(财务/龙虎榜/风向标) · 分钟策略与回测 · 交易日探针 · 全时段异动中心 · 回测导出与候选复测 | ✅ | | **AI 助手** | 对话式数据问答与操作: 21 查询 + 4 动作工具(确认卡放行, 含生成信号/回测/加自选/数据补全) · 逐字流式 + 工具足迹卡 · 完全解耦扩展模块 | ✅ | | **v0.3.2 开放能力版** | API Token + 六档 scope 网关 · 61 端点契约(快照+CI 守护) · 扩展数据读写闭环 · SSE 事件流 · MCP 服务器 · 核心/扩展域边界标注 | ✅ | +| **v0.3.6** | [TdxQuant](https://help.tdx.com.cn/quant/) 数据源(realtime/五档/日K/复权/分钟) · 11 官方页面单文件扩展化 + 运行时页面包 · 扩展表两级拉取配方(概念/行业出厂即用) · AI Responses 协议与会话跨设备 · 情绪评分口径可配置 · 财务画像手动拉取 · 策略池叠加分组 · 65 端点契约 | 🚧 | --- @@ -651,31 +621,37 @@ PORT=3018 # 服务端口 | [docs/market-phase.md](./docs/market-phase.md) | 市场情绪周期 6 阶段与概念/行业主线识别的口径与设计 | | [docs/plugin-development.md](./docs/plugin-development.md) | 数据源插件开发规范(以 fuyao 为参考实现) | | [docs/secondary-development.md](./docs/secondary-development.md) | 代码二次开发、前端插槽、后端策略接口与 AI 开发模板 | +| [docs/page-sdk.md](./docs/page-sdk.md) | 页面包开发指南:产物契约、能力桥 ctx、打包与上传、安全边界 | | [backend/app/strategy/prompts/strategy-guide.md](./backend/app/strategy/prompts/strategy-guide.md) | 策略开发完整规范(AI 生成与手写) | --- -## ❤️ 支持项目 +## ❤️ 支持与交流 -
+ + + + + +
如果这个项目对你有帮助,欢迎请作者喝杯咖啡 ☕ -微信赞赏码 · 感谢道友支持 愿一路长红 +
+ +微信赞赏码 · 感谢道友支持 愿一路长红 作者精力有限,优先响应赞助回馈,希望理解 📈 - - -## 💬 交流群 - -
+
欢迎加入交流群,一起讨论交流 · 个人维护了一些个性化接口统一公布在群公告 -交流群二维码 · 个人维护的个性化接口见群公告 +
- +交流群二维码 · 个人维护的个性化接口见群公告 + +
--- @@ -687,10 +663,16 @@ PORT=3018 # 服务端口 [MIT](./LICENSE) © tick-stock-panel contributors +> **分发与二次开发须知**:依据 MIT 协议,分发本软件或其任何部分(包括 fork 后发布、修改再发布、封装为自有产品或服务)时,必须完整保留原始版权声明与许可文本。未经版权所有者书面许可,移除、遮蔽或篡改版权署名、许可声明,或在再分发时隐匿本项目来源,均构成协议违约与著作权侵权,版权所有者保留依法追究责任的权利。 +> +> 协议全文与第三方组件许可清单亦可见应用内「设置 → 关于 → 开源许可」。 + 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款 内置数据源插件 [fuyao](https://fuyao.aicubes.cn/docs/api-reference/) 提供同花顺 REST 数据接口(行情 / 财务 / 龙虎榜 / 盘前风向标 / 交易日历等),需自备 API Key,使用前请遵守其服务条款 +内置数据源插件 [TdxQuant](https://help.tdx.com.cn/quant/) 提供通达信量化数据服务(实时行情 / 五档盘口 / 日K / 复权因子 / 分钟K),需自备 API Key,使用前请遵守其服务条款 + ## 社区 本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。 diff --git a/VERSION b/VERSION index 03f2afa1..d379b578 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.3.5 +v0.3.6 diff --git a/assets/support/wechat-appreciation.jpg b/assets/support/wechat-appreciation.jpg index ff950eb226c12bc6526213dac3b804fb44804162..70cb13491e6e5298ee06436d5502c78d790aceae 100644 GIT binary patch literal 100506 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%0AZAAK&k0@_0stW|qS=GYVjQkhdtC18p?`BKEO102 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

cC0Hfk%_+|S_Ac>7X2 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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index cf72d223..e074083a 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -8,7 +8,7 @@ from pathlib import Path # ISCC)、macOS 包元数据 (tsp.spec 读它)、页面版本徽标与更新检查 (本模块) 全部同源, # 改版本只改那一份文件。dev 模式读仓库文件; PyInstaller 冻结后读 _MEIPASS/package.json # (tsp.spec 的 datas 会收集)。读取失败回退硬编码值 —— 版本号绝不阻塞应用启动。 -_FALLBACK_VERSION = "0.3.5" +_FALLBACK_VERSION = "0.3.6" def _load_version() -> str: diff --git a/backend/app/api/ext_data.py b/backend/app/api/ext_data.py index e276734c..90c1e960 100644 --- a/backend/app/api/ext_data.py +++ b/backend/app/api/ext_data.py @@ -108,6 +108,8 @@ class PullConfigReq(BaseModel): auth: PullAuthReq | None = None # 单次拉取请求超时 (秒), 默认 30 与历史行为一致; 大响应接口可调高 timeout_seconds: int = Field(30, ge=5, le=300) + # 两级拉取配方 (目录 → 逐条目明细); 缺省 (None) = 保留现有配置 + fanout: dict | None = None class ApiKeyReq(BaseModel): @@ -407,27 +409,6 @@ def list_configs(request: Request): return {"items": items} -@router.post("/presets/{config_id}/fetch") -async def fetch_preset_data(request: Request, config_id: str): - """手动触发内置预设 (概念/行业) 的数据拉取。 - - 注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。 - 与通用 pull/run 不同: 走 ext_presets 的结构转换 (接口的 concepts/industries - 数组 → 拼接成字符串), 保证 schema 与现有数据一致。 - """ - from app.services.ext_presets import fetch_preset - - try: - n = await fetch_preset(config_id, _data_dir(request)) - except ValueError as e: - raise HTTPException(404, str(e)) from e - except Exception as e: - raise HTTPException(400, f"拉取失败: {e}") from e - - _refresh_views(request) - return {"status": "ok", "rows": n} - - @router.post("") def create_config(request: Request, body: CreateExtReq): """创建扩展数据配置。""" @@ -1016,6 +997,15 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq): # 保留历史状态字段; auth 缺省时沿用现有配置 (关闭鉴权需显式传 {"type":"none"}) old_pull = config.pull + # auth.key_ref / fanout: UI 未暴露的键在保存时沿用旧值, 避免一次普通保存 + # 就把出厂配方里的凭据引用与两级请求配方抹掉。 + old_auth = (old_pull.auth or {}) if old_pull else {} + if body.auth is not None: + new_auth: dict | None = body.auth.model_dump() + if new_auth and old_auth.get("key_ref") and not new_auth.get("key_ref"): + new_auth["key_ref"] = old_auth["key_ref"] + else: + new_auth = old_pull.auth if old_pull else None config.pull = PullConfig( url=body.url, method=body.method, @@ -1030,8 +1020,9 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq): date_param=body.date_param, date_format=body.date_format, time_field=body.time_field, - auth=body.auth.model_dump() if body.auth else (old_pull.auth if old_pull else None), + auth=new_auth, timeout_seconds=body.timeout_seconds, + fanout=body.fanout if body.fanout is not None else (old_pull.fanout if old_pull else None), last_run=old_pull.last_run if old_pull else None, last_status=old_pull.last_status if old_pull else None, last_message=old_pull.last_message if old_pull else None, diff --git a/backend/app/api/ext_pages.py b/backend/app/api/ext_pages.py new file mode 100644 index 00000000..c01c2185 --- /dev/null +++ b/backend/app/api/ext_pages.py @@ -0,0 +1,136 @@ +"""运行时页面包 API — 上传/清单回填/启停/删除/bundle 分发。 + +分发端点位于 /api 前缀下, 自动受集中鉴权中间件保护: 浏览器同源动态 +import 会携带登录 cookie, 与页面会话同一访问边界。 +""" +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from app.services.ext_pages import PagePackMeta, PagePackStore + +router = APIRouter(prefix="/api/ext-pages", tags=["ext-pages"]) + +# 单文件 ES 模块产物通常 < 1MB, 5MB 上限留足余量; 分块写防 OOM(同 ext_data) +_MAX_BUNDLE_BYTES = 5 * 1024 * 1024 +_UPLOAD_CHUNK_BYTES = 1024 * 1024 + + +async def _write_bundle_capped(file: UploadFile, dest: Path, max_bytes: int) -> None: + """分块写入, 累计超限立即 413 并停止读取(防内存放大)。""" + total = 0 + with dest.open("wb") as f: + while True: + chunk = await file.read(_UPLOAD_CHUNK_BYTES) + if not chunk: + break + total += len(chunk) + if total > max_bytes: + raise HTTPException(413, f"页面包过大(上限 {max_bytes // (1024 * 1024)}MB)") + f.write(chunk) + + +def _store(request: Request) -> PagePackStore: + return PagePackStore(request.app.state.repo.store.data_dir) + + +class ManifestBody(BaseModel): + label: str = "" + path: str = "" + error: str = "" + + +class EnabledBody(BaseModel): + enabled: bool + + +@router.post("/upload") +async def upload_page_pack( + request: Request, + id: Annotated[str, Form()], + file: Annotated[UploadFile, File()], +) -> PagePackMeta: + """上传页面包产物(.js/.mjs), 覆盖式: 同 ID 重新上传即更新。""" + suffix = Path(file.filename or "").suffix.lower() + if suffix not in (".js", ".mjs"): + raise HTTPException(400, "仅支持 .js / .mjs 页面包产物") + store = _store(request) + tmp_dir = Path(tempfile.mkdtemp()) + try: + tmp_path = tmp_dir / "bundle.js" + await _write_bundle_capped(file, tmp_path, _MAX_BUNDLE_BYTES) + if tmp_path.stat().st_size == 0: + raise HTTPException(400, "页面包为空文件") + try: + return store.save_upload(id, tmp_path) + except ValueError as e: + raise HTTPException(400, str(e)) from e + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +@router.get("") +def list_page_packs(request: Request) -> list[PagePackMeta]: + return _store(request).list() + + +@router.get("/{pack_id}/bundle.js") +def get_page_pack_bundle(pack_id: str, request: Request) -> FileResponse: + """分发产物。no-store + ETag(sha256): 热更新产物后浏览器立即取新, 未变时可用缓存协商。""" + store = _store(request) + try: + meta = store.get(pack_id) + except ValueError as e: + raise HTTPException(400, str(e)) from e + bundle = store.bundle_path(pack_id) if meta else None + if meta is None or bundle is None: + raise HTTPException(404, f"页面包 '{pack_id}' 不存在") + return FileResponse( + bundle, + media_type="text/javascript", + headers={ + "Cache-Control": "no-store", + "ETag": f'"{meta.sha256}"', + }, + ) + + +@router.put("/{pack_id}/manifest") +def put_page_pack_manifest(pack_id: str, body: ManifestBody, request: Request) -> PagePackMeta: + """前端试载结果回填: error 非空 → 停用并记错误, 否则启用并登记摘要。""" + store = _store(request) + try: + return store.update_manifest(pack_id, label=body.label, path=body.path, error=body.error) + except KeyError: + raise HTTPException(404, f"页面包 '{pack_id}' 不存在") from None + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.put("/{pack_id}/enabled") +def put_page_pack_enabled(pack_id: str, body: EnabledBody, request: Request) -> PagePackMeta: + store = _store(request) + try: + return store.set_enabled(pack_id, body.enabled) + except KeyError: + raise HTTPException(404, f"页面包 '{pack_id}' 不存在") from None + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.delete("/{pack_id}") +def delete_page_pack(pack_id: str, request: Request) -> dict: + store = _store(request) + try: + if not store.delete(pack_id): + raise HTTPException(404, f"页面包 '{pack_id}' 不存在") + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"ok": True} diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 377a8343..6c1c62e9 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -1265,19 +1265,19 @@ async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=N try: progress("sync_minute", 5, "解析标的池…") - universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A"))) - # 补充 instruments 全量标的,覆盖北交所、新股等 + # 标的池 = instruments 维表 + 自选 (单一来源, 不拉 CN_Equity_A/SW1 池) + universe: set[str] = set(get_pool("watchlist")) inst_path = repo.store.data_dir / "instruments" / "instruments.parquet" if inst_path.exists(): try: import polars as pl inst = pl.read_parquet(inst_path, columns=["symbol"]) - universe = sorted(set(universe) | set(inst["symbol"].to_list())) + universe.update(inst["symbol"].to_list()) except Exception: # noqa: BLE001 pass # 剔除指数 symbol: 指数分钟K无本地存储, 落库会污染 kline_minute index_set = repo.get_index_symbol_set() - universe = [s for s in universe if s not in index_set] + universe = sorted({s for s in universe if s not in index_set}) progress("sync_minute", 10, f"标的池 {len(universe)} 只") days = override_days if override_days else get_minute_sync_days() diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 96659ca7..fded9d46 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -60,6 +60,7 @@ def get_settings() -> dict: current_ai_context_window, current_ai_max_output_tokens, current_ai_model, + current_ai_protocol, current_ai_round_checkpoint, current_codex_command, current_codex_model, @@ -81,8 +82,11 @@ def get_settings() -> dict: "extras_caps": extras_caps(), # 首次使用引导 "onboarding_completed": preferences.get_onboarding_completed(), + # 配置文件位置 (AI 与数据源 Key 统一存放, 支持外部编辑后热加载) + "secrets_path": str(secrets_store.file_path()), # AI 配置 "ai_provider": ai_provider, + "ai_protocol": current_ai_protocol(), "ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url), "ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()), "has_ai_key": bool(secrets_store.get_ai_key()), @@ -254,6 +258,7 @@ class AiSettingsIn(BaseModel): base_url: str = "" api_key: str | None = None model: str = "" + protocol: str = "" # chat_completions | responses; 空=保持原值 reasoning_effort: str = Field(default="high", max_length=64) codex_command: str = "" codex_reasoning_effort: str = "" @@ -263,16 +268,63 @@ class AiSettingsIn(BaseModel): round_checkpoint: int | None = None # 助手工具轮次检查点; 0=不检查 +class AiTestIn(BaseModel): + """草稿连通性测试入参 — 只读表单值, 与保存完全解耦。""" + + provider: str = "openai_compat" + base_url: str = "" + api_key: str | None = None # 留空回退已保存 Key (改模型/协议不必重输) + model: str = "" + protocol: str = "" + + +@router.post("/ai/test") +async def test_ai_settings(req: AiTestIn) -> dict: + """用当前表单值发一次最小请求验证连通性, 不保存/不修改任何配置。 + + 与 POST /ai (保存) 解耦: 测试失败不影响已保存配置, 状态栏也不会把 + 未验证的草稿展示为已生效。错误保留 200 包体 (ok=false), 由前端内联 + 展示在测试结果徽章, 不触发全局 toast。 + """ + from app.services.ai_provider import ( + RESPONSES_PROTOCOL, + _format_openai_error, + _is_openai_transport_error, + probe_ai_config, + ) + + provider = req.provider or "openai_compat" + api_key = (req.api_key or "").strip() or (secrets_store.get_ai_key() or "") + protocol = req.protocol if req.protocol == RESPONSES_PROTOCOL else "" + try: + text = await probe_ai_config( + provider=provider, + api_key=api_key, + base_url=req.base_url.strip(), + model=req.model.strip(), + protocol=protocol, + ) + except Exception as exc: # noqa: BLE001 — 连通性测试: 任何异常都转为结果回报 + detail = str(exc) or repr(exc) + if _is_openai_transport_error(exc): + detail = _format_openai_error(exc) + return {"ok": False, "error": detail, "error_type": type(exc).__name__} + return {"ok": True, "model": req.model.strip() or provider, "response": text[:80]} + + @router.post("/ai") def save_ai_settings(req: AiSettingsIn) -> dict: """保存 AI 配置(全部持久化到 secrets.json)""" from app.config import settings from app.services.ai_provider import ( + CHAT_COMPLETIONS_PROTOCOL, OPENAI_PROVIDER, + RESPONSES_PROTOCOL, ai_configured, current_ai_context_window, current_ai_max_output_tokens, current_ai_model, + current_ai_protocol, current_ai_provider, current_ai_round_checkpoint, current_codex_command, @@ -304,6 +356,12 @@ def save_ai_settings(req: AiSettingsIn) -> dict: if req.base_url: updates["ai_base_url"] = req.base_url settings.ai_base_url = req.base_url + if req.protocol: + # 协议仅对 openai/openai_compat 有意义; 非法值 fail-loud, 避免静默落错协议。 + if req.protocol not in (CHAT_COMPLETIONS_PROTOCOL, RESPONSES_PROTOCOL): + raise HTTPException(status_code=400, detail=f"不支持的协议: {req.protocol}") + updates["ai_protocol"] = req.protocol + settings.ai_protocol = req.protocol if req.api_key is not None: if req.api_key: updates["ai_api_key"] = req.api_key @@ -345,6 +403,7 @@ def save_ai_settings(req: AiSettingsIn) -> dict: return { "ok": True, "ai_provider": provider, + "ai_protocol": current_ai_protocol(), "ai_model": current_ai_model(), "ai_openai_model": current_openai_model(), "ai_reasoning_effort": current_openai_reasoning_effort(), @@ -376,9 +435,10 @@ def clear_ai_settings() -> dict: "ai_codex_command", "ai_codex_reasoning_effort", ) - secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort", "ai_max_output_tokens", "ai_context_window") + secrets_store.clear("ai_provider", "ai_protocol", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort", "ai_max_output_tokens", "ai_context_window") # 同步重置运行时内存(provider 回默认值,其余置空) settings.ai_provider = "openai_compat" + settings.ai_protocol = "chat_completions" settings.ai_base_url = "" settings.ai_api_key = "" settings.ai_model = "" @@ -396,6 +456,10 @@ async def list_sponsor_models() -> dict: 其网关按 Origin 头过滤: 浏览器跨域请求只会拿到国产模型子集, 服务端请求无 Origin 头可取全量, 故由后端代理转发。 + + /v1/models 除 id 外还携带 pricing (discountRate/分层单价) 与 + context_length; 精简为 items 下发供前端展示折扣徽章与价格, + models 保持纯 id 列表向后兼容。 """ import httpx @@ -406,11 +470,30 @@ async def list_sponsor_models() -> dict: data = res.json() except Exception as exc: raise HTTPException(status_code=502, detail=f"获取模型列表失败: {exc}") from exc - models = sorted({ - item.get("id") for item in data.get("data", []) - if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"] - }) - return {"models": models} + + def _amount(pricing: dict, dim: str) -> float | None: + value = (pricing.get(dim) or {}).get("amount") + return float(value) if isinstance(value, (int, float)) else None + + items: list[dict] = [] + for raw in data.get("data", []): + if not isinstance(raw, dict): + continue + model_id = raw.get("id") + if not isinstance(model_id, str) or not model_id: + continue + pricing = raw.get("pricing") if isinstance(raw.get("pricing"), dict) else {} + rate = pricing.get("discountRate") + context_length = raw.get("context_length") + items.append({ + "id": model_id, + "context_length": context_length if isinstance(context_length, int) else None, + "discount_rate": float(rate) if isinstance(rate, (int, float)) else 1.0, + "input_price": _amount(pricing, "input"), + "output_price": _amount(pricing, "output"), + }) + items.sort(key=lambda x: x["id"]) + return {"models": [item["id"] for item in items], "items": items} # ===== 偏好设置 ===== @@ -1212,6 +1295,48 @@ class MainlineFilterIn(BaseModel): exclude_st: bool | None = None +class EmotionScoreConfigIn(BaseModel): + """情绪评分口径 (维度权重 / 子项权重与归一化区间 / 档位阈值)。 + + config 为整棵树或 null — 传 null 表示恢复出厂口径。不做逐字段白名单: + 结构由 emotion_score.merge_config 归一化 (未知 key 丢弃、缺项补默认、 + 阈值乱序自动重排), 用户改坏权重也不会让看板算不出分。 + """ + + config: dict | None = None + + +@router.get("/emotion-score/config") +def get_emotion_score_config() -> dict: + """情绪评分口径: 当前配置 + 出厂默认 + 维度顺序 + 档位名。 + + 看板「情绪雷达」卡片的配置对话框一次拉全 —— 前端不硬编码任何权重/区间/ + 阈值字面量, 表单全部由本接口返回的结构渲染。 + """ + from app.services import preferences + from app.services.emotion_score import public_config + + payload = public_config(preferences.get_emotion_score_config()) + payload["customized"] = preferences.get_emotion_score_config() is not None + return payload + + +@router.put("/emotion-score/config") +def update_emotion_score_config(req: EmotionScoreConfigIn) -> dict: + """保存情绪评分口径; config 传 null/{} = 恢复出厂。 + + 形状校验在 preferences 层 (经 emotion_score.merge_config), 值的合法性 + (负权重/区间倒置/阈值乱序) 由计算时归一化兜底, 故此处不做逐字段白名单。 + """ + from app.services import preferences + from app.services.emotion_score import public_config + + saved = preferences.set_emotion_score_config(req.config) + payload = public_config(saved) + payload["customized"] = saved is not None + return payload + + @router.put("/preferences/mainline-filter") def update_mainline_filter(req: MainlineFilterIn) -> dict: """更新市场主线过滤配置。部分更新; 修改后需重算主线(POST /api/regime/mainline/recompute)生效。 diff --git a/backend/app/api/task_registry.py b/backend/app/api/task_registry.py new file mode 100644 index 00000000..45844b4c --- /dev/null +++ b/backend/app/api/task_registry.py @@ -0,0 +1,63 @@ +"""进行中任务注册表 API — 前端 fire-and-forget 上报 + 页面挂载时查询。 + +PUT/DELETE 由任务启动/终态触发 (不阻塞 UI, 失败静默); GET 在任务页挂载时 +最多调用一次。全部是小 JSON 文件读写, 不触碰任务执行路径。 +""" +from __future__ import annotations + +import re + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from app.services import task_registry + +router = APIRouter(prefix="/api/task-registry", tags=["task-registry"]) + +# id 由前端按 kind+参数指纹生成 (字母/数字/冒号/下划线/短横) +_VALID_ID = re.compile(r"^[A-Za-z0-9:_-]{1,128}$") + + +class TaskUpsertRequest(BaseModel): + kind: str = Field(max_length=32) + status: str = Field(max_length=16) + label: str = Field(default="", max_length=120) + payload: dict = Field(default_factory=dict) + + +def _data_dir(request: Request): + repo = getattr(request.app.state, "repo", None) + if repo is None: + raise HTTPException(status_code=503, detail="数据目录不可用") + return repo.store.data_dir + + +@router.get("") +def list_tasks(request: Request, kind: str | None = None) -> dict: + if kind is not None and kind not in task_registry.TASK_KINDS: + raise HTTPException(status_code=400, detail=f"未知的任务类型: {kind}") + return {"items": task_registry.list_items(_data_dir(request), kind)} + + +@router.put("/{task_id}") +def upsert_task(request: Request, task_id: str, req: TaskUpsertRequest) -> dict: + if not _VALID_ID.match(task_id): + raise HTTPException(status_code=400, detail="非法的任务 id") + try: + entry = task_registry.upsert( + _data_dir(request), + task_id, + kind=req.kind, + status=req.status, + label=req.label, + payload=req.payload, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, "item": entry} + + +@router.delete("/{task_id}") +def delete_task(request: Request, task_id: str) -> dict: + removed = task_registry.delete(_data_dir(request), task_id) + return {"ok": True, "removed": removed} diff --git a/backend/app/config.py b/backend/app/config.py index 0554ab5a..49a86a5f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -84,6 +84,8 @@ class Settings(BaseSettings): # AI ai_provider: str = "openai_compat" + # OpenAI 系请求协议: chat_completions (兼容面最广) | responses (OpenAI 官方新协议)。 + ai_protocol: str = "chat_completions" ai_base_url: str = "https://llm.runninghub.ai/v1" ai_api_key: str = "" ai_model: str = "openai/gpt-6-astra-saver" diff --git a/backend/app/custom/assistant/routes.py b/backend/app/custom/assistant/routes.py index c3800d85..b455ab7b 100644 --- a/backend/app/custom/assistant/routes.py +++ b/backend/app/custom/assistant/routes.py @@ -3,11 +3,14 @@ POST /api/custom/assistant/chat NDJSON 事件流(协议见模块 __init__) GET /api/custom/assistant/status 供应商配置状态(前端门控展示) GET /api/custom/assistant/suggests 空会话快捷指令 +GET /api/custom/assistant/sessions 会话历史(服务端, 跨设备) +PUT /api/custom/assistant/sessions 会话历史整体保存(前端防抖写入) POST /api/custom/assistant/actions/{call_id}/decision 动作工具确认/拒绝(确认卡按钮回调) """ from __future__ import annotations +from pathlib import Path from typing import Literal from fastapi import APIRouter, HTTPException, Request @@ -15,6 +18,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from app.custom.assistant import actions as assistant_actions +from app.custom.assistant import sessions as assistant_sessions from app.custom.assistant import tools as assistant_tools from app.custom.assistant.chat_service import chat_stream from app.services.ai_provider import ( @@ -45,6 +49,19 @@ class ActionDecisionRequest(BaseModel): approve: bool +class AssistantSessionsPayload(BaseModel): + """前端 store.ts 的持久化载荷: 会话数组 + 活跃会话 id。 + + sessions 用宽松的 list[dict] (消息形状由前端定义, 后端只裁剪条数), + 上限与前端一致, 防御异常客户端把文件写爆。字段名对齐前端 (activeId)。 + """ + + sessions: list[dict] = Field(max_length=assistant_sessions.MAX_SESSIONS) + active_id: str = Field(default="", max_length=64, alias="activeId") + + model_config = {"populate_by_name": True} + + def build_router() -> APIRouter: router = APIRouter(prefix="/api/custom/assistant", tags=["custom-assistant"]) @@ -89,6 +106,24 @@ def build_router() -> APIRouter: def suggests() -> dict: return {"suggests": assistant_tools.QUICK_SUGGESTS} + @router.get("/sessions") + def sessions(request: Request) -> dict: + repo = getattr(request.app.state, "repo", None) + if repo is None: + raise HTTPException(status_code=503, detail="数据目录不可用") + return assistant_sessions.load_sessions(Path(repo.store.data_dir)) + + @router.put("/sessions") + def put_sessions(request: Request, req: AssistantSessionsPayload) -> dict: + repo = getattr(request.app.state, "repo", None) + if repo is None: + raise HTTPException(status_code=503, detail="数据目录不可用") + n = assistant_sessions.save_sessions( + Path(repo.store.data_dir), + {"sessions": req.sessions, "activeId": req.active_id}, + ) + return {"ok": True, "sessions": n} + @router.post("/actions/{call_id}/decision") async def action_decision(call_id: str, req: ActionDecisionRequest) -> dict: """确认卡回调: 唤醒挂起中的动作工具(批准执行或拒绝)。""" diff --git a/backend/app/custom/assistant/sessions.py b/backend/app/custom/assistant/sessions.py new file mode 100644 index 00000000..cd11d959 --- /dev/null +++ b/backend/app/custom/assistant/sessions.py @@ -0,0 +1,104 @@ +"""AI 助手会话历史服务端存储 — 单用户, 前端为主写入方, 后端只存取与裁剪。 + +动机: 会话历史是用户产生的内容而非设备习惯, 原先只存浏览器 localStorage, +换设备即不可见; 2026-10 起同步到服务端, localStorage 降级为启动缓存/离线兜底。 + +文件: data/user_data/assistant_sessions.json + {"sessions": [...], "activeId": "..."} (字段名与前端 store.ts 载荷一致) + +容量纪律 (与前端 store.ts 相同): ≤20 会话 × 每会话 ≤200 条消息, 超限裁尾部。 +写走 fs_utils.atomic_write_text 原子替换; 读带 mtime 签名缓存 — GET 只在应用 +加载时调用一次、写经前端防抖, 磁盘 IO 可忽略, 不影响其他任何请求路径。 +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from app.services.fs_utils import atomic_write_text + +logger = logging.getLogger(__name__) + +MAX_SESSIONS = 20 +MAX_MESSAGES_PER_SESSION = 200 +_FILENAME = "assistant_sessions.json" + +_cache: dict[str, Any] | None = None +_cache_sig: tuple[int, int] | None = None + + +def _path(data_dir: Path) -> Path: + p = data_dir / "user_data" / _FILENAME + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def _signature(p: Path) -> tuple[int, int] | None: + try: + st = p.stat() + return (st.st_mtime_ns, st.st_size) + except OSError: + return None + + +def _sanitize_sessions(data: Any) -> list[dict]: + """结构校验 + 容量裁剪: 只信 id 为字符串的会话, 消息截尾至 200 条。""" + if not isinstance(data, dict): + return [] + sessions = data.get("sessions") + if not isinstance(sessions, list): + return [] + out: list[dict] = [] + for s in sessions: + if not isinstance(s, dict) or not isinstance(s.get("id"), str) or not s["id"]: + continue + msgs = s.get("messages") + if not isinstance(msgs, list): + msgs = [] + out.append({**s, "messages": msgs[-MAX_MESSAGES_PER_SESSION:]}) + return out[:MAX_SESSIONS] + + +def _sanitize_active(data: Any) -> str: + if isinstance(data, dict): + active = data.get("activeId") + if isinstance(active, str): + return active + return "" + + +def load_sessions(data_dir: Path) -> dict[str, Any]: + """读取 {sessions, activeId}; 文件不存在/损坏返回空结构 (不抛错)。""" + global _cache, _cache_sig + p = _path(data_dir) + sig = _signature(p) + if sig is None: + return {"sessions": [], "activeId": ""} + if _cache is not None and sig == _cache_sig: + return _cache + try: + data = json.loads(p.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 — 损坏文件视为无历史, 不阻断 + logger.warning("assistant sessions 文件损坏, 视为空: %s", e) + return {"sessions": [], "activeId": ""} + result = { + "sessions": _sanitize_sessions(data), + "activeId": _sanitize_active(data), + } + _cache, _cache_sig = result, sig + return result + + +def save_sessions(data_dir: Path, payload: dict[str, Any]) -> int: + """校验 + 裁剪 + 原子写盘; 返回保留的会话数。""" + global _cache, _cache_sig + doc = { + "sessions": _sanitize_sessions(payload), + "activeId": _sanitize_active(payload), + } + p = _path(data_dir) + atomic_write_text(p, json.dumps(doc, ensure_ascii=False)) + _cache, _cache_sig = doc, _signature(p) + return len(doc["sessions"]) diff --git a/backend/app/custom/assistant/streaming.py b/backend/app/custom/assistant/streaming.py index 4f419831..04cb8109 100644 --- a/backend/app/custom/assistant/streaming.py +++ b/backend/app/custom/assistant/streaming.py @@ -17,6 +17,7 @@ tool_calls 分片, 流结束时统一返回本轮结果, 由 chat_service 决定 from __future__ import annotations import asyncio +import inspect from collections.abc import AsyncIterator, Sequence from typing import Any @@ -24,11 +25,13 @@ from openai import AsyncOpenAI from app import secrets_store from app.config import settings +from app.services import ai_responses from app.services.ai_provider import ( OPENAI_PROVIDER, current_ai_model, current_ai_provider, current_openai_reasoning_effort, + is_responses_protocol, normalize_openai_base_url, ) @@ -88,6 +91,14 @@ async def stream_openai_round( timeout: float = 240.0, ) -> AsyncIterator[dict[str, Any]]: """执行一轮流式补全: yield 文本 delta, 最后 yield 一个 round_end 事件。""" + if is_responses_protocol(): + # Responses 协议走平行实现, 事件输出与本函数同构, chat_service 无感。 + async for event in ai_responses.stream_round( + messages, tools, temperature=temperature, timeout=timeout, + ): + yield event + return + client = _client(timeout) model = current_ai_model() kwargs = _build_kwargs(temperature) @@ -159,10 +170,14 @@ async def stream_openai_round( if getattr(fn, "arguments", None): entry["arguments"] += str(fn.arguments) - # 让 SDK 流对象尽快释放(部分兼容网关要求显式 close)。 + # 让 SDK 流对象尽快释放(部分兼容网关要求显式 close); SDK 2.x 的 + # AsyncStream.close 是协程, 必须区分同步/异步两种签名。 close = getattr(stream, "close", None) if close is not None: - await asyncio.to_thread(close) + if inspect.iscoroutinefunction(close): + await close() + else: + await asyncio.to_thread(close) if calls: yield { diff --git a/backend/app/data_providers/capabilities.py b/backend/app/data_providers/capabilities.py index ccbb97ce..0a067662 100644 --- a/backend/app/data_providers/capabilities.py +++ b/backend/app/data_providers/capabilities.py @@ -198,3 +198,92 @@ def build_capability_matrix(current: dict[str, str], tickflow_tier: str = "none" "pending": pending, }) return {"tickflow_tier": tier_base, "capabilities": capabilities} + + +# --------------------------------------------------------------------------- +# 门禁视角 (面向各服务的启动/调度判断与日志) +# --------------------------------------------------------------------------- + +def _cap_specs_by_id() -> dict[str, dict]: + return {c["id"]: c for c in CAPABILITY_REGISTRY} + + +def _current_routing() -> dict[str, str]: + """偏好字段 → 当前生效源 (经 preferences getters, 非法值已回退默认)。 + + 与 api/settings.py 的 capability-matrix 端点同一组 getter; 显式列全, + 不用 getattr 拼函数名 (拼错会静默回退, 难排查)。 + """ + from app.services import preferences + + return { + "realtime_data_provider": preferences.get_realtime_data_provider(), + "daily_data_provider": preferences.get_daily_data_provider(), + "minute_data_provider": preferences.get_minute_data_provider(), + "full_minute_data_provider": preferences.get_full_minute_data_provider(), + "depth5_data_provider": preferences.get_depth5_data_provider(), + "adj_factor_provider": preferences.get_adj_factor_provider(), + "financial_data_provider": preferences.get_financial_provider(), + } + + +def resolve_capability_sources(tickflow_tier: str = "none") -> list[dict]: + """每个能力一行: 生效源 + 展示名 + 该源当前能否真正提供 (usable)。 + + 与 build_capability_matrix 同口径 (复用同一 candidates 组装), 供服务层 + 的门禁判断与日志标签使用 —— 门禁必须看「生效源能否提供」, 而不是只看 + TickFlow 套餐 (路由到插件时 TickFlow 档位无关; 同理路由到 TickFlow 而 + 档位不足时, 插件声明了也不该被算作可用)。 + + 返回 [{id, label, field, source, source_display, usable, tf_available}]。 + """ + tier_rank = _TIER_RANK.get(_tier_base(tickflow_tier), -1) + sources = _declared_sources() + current = _current_routing() + + rows: list[dict] = [] + for cap in CAPABILITY_REGISTRY: + field = cap.get("field") + if not field: + continue + effective = current.get(field, cap["default"]) + tf_available = tier_rank >= _TIER_RANK[cap["tf_tier"]] + provider_names = {"tickflow"} if tf_available else set() + provider_names |= { + s["name"] for s in sources + if cap["id"] in s["datasets"] and s["available"] + } + rows.append({ + "id": cap["id"], + "label": cap["label"], + "field": field, + "source": effective, + "source_display": _display_of(sources, effective), + "usable": effective in provider_names, + "tf_available": tf_available, + }) + return rows + + +def capability_gate(cap_id: str, tickflow_tier: str | None = None) -> tuple[bool, str]: + """单个能力的门禁: (是否可用, 日志标签)。 + + 标签形如 "日K ← fuyao" / "财务数据 ← TickFlow(档位不足)", 让「为什么没跑」 + 在日志里一眼可读, 而不必回设置页对照路由。 + + 可用性以生效源为准 (与设置页 usable 同口径): 路由到已声明的插件即可用, + 路由到 TickFlow 但档位不够则不可用。 + """ + if tickflow_tier is None: + from app.tickflow import policy + tickflow_tier = policy.base_tier_name() + + for row in resolve_capability_sources(tickflow_tier): + if row["id"] == cap_id: + label = f"{row['label']} ← {row['source_display']}" + if not row["usable"] and row["source"] == "tickflow" and not row["tf_available"]: + label += "(档位不足)" + elif not row["usable"]: + label += "(当前源不可用)" + return row["usable"], label + return False, f"{cap_id}(未知能力)" diff --git a/backend/app/data_providers/custom/loader.py b/backend/app/data_providers/custom/loader.py index 18a69aa2..ae183dde 100644 --- a/backend/app/data_providers/custom/loader.py +++ b/backend/app/data_providers/custom/loader.py @@ -578,6 +578,14 @@ def _register_one_plugin(manifest: dict) -> None: "homepage": manifest.get("homepage", ""), "api_key_env": manifest.get("api_key_env", ""), "api_key_masked": _plugin_key_masked(name, manifest.get("api_key_env", "")), + # 计费标识 (可选): 如 "points" → 设置页能力芯片/适配行标注「积分」 + "billing": manifest.get("billing", ""), + # 计费提示 (可选): 积分消耗与轮询频率/拉取范围的关系, 设置页按警示样式展示 + "billing_note": manifest.get("billing_note", ""), + # 使用要点 (可选): 各能力的规模/节奏说明, 设置页详情卡以列表展示 + "usage_notes": list(manifest.get("usage_notes", []) or []), + # Key/账务管理页 (可选): 设置页 Key 配置区展示入口 + "key_manage_url": manifest.get("key_manage_url", ""), } if not available: return # 依赖没装: 不注册, 但状态已记录供 UI 显示 diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 100e8aef..7775ffb7 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -26,7 +26,7 @@ from app.market_time import cn_today from app.services import index_sync, instrument_sync, kline_sync from app.services import preferences as _prefs from app.tickflow.capabilities import Cap, CapabilitySet -from app.tickflow.pools import DEMO_SYMBOLS, get_pool +from app.tickflow.pools import get_pool from app.tickflow.repository import KlineRepository logger = logging.getLogger(__name__) @@ -139,35 +139,26 @@ def _invalidate(table: str | None = None) -> None: def _resolve_universe(capset: CapabilitySet, repo=None) -> list[str]: - """解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。 + """解析标的池 — 本地 instruments 维表 + 自选 (单一来源, 无池兜底)。 - 有 batch 能力 → 直接拉 CN_Equity_A universe - 其他用户 → 用 instruments parquet + watchlist 兜底 + 不再拉 CN_Equity_A 池: 其 universe 匹配曾错配到 Expired 变体、触发 + SW1 行业聚合兜底, 产出 4224 只的残缺池。维表由盘后管道 Step 0 先同步, + 这里只读 parquet; 维表缺失时仅剩自选, 宁缺毋假 (不塞演示标的), + 由上游按空池结果如实呈现。 - repo 传入时过滤自选兜底里的指数 symbol (指数日K走独立 kline_index_* 存储, + repo 传入时过滤自选里的指数 symbol (指数日K走独立 kline_index_* 存储, 进股票池会污染 kline_daily/kline_minute)。ETF 刻意保留 (既有行为)。 """ - if capset.has(Cap.KLINE_DAILY_BATCH): - try: - all_a = get_pool("CN_Equity_A", refresh=True) - if all_a: - return sorted(all_a) - except Exception as e: # noqa: BLE001 - logger.warning("CN_Equity_A pool unavailable, fallback: %s", e) - - # Free 用户兜底: instruments parquet + watchlist + demo - base: set[str] = set(DEMO_SYMBOLS) - base.update(get_pool("watchlist")) - d = Path(settings.data_dir) - inst_path = d / "instruments" / "instruments.parquet" + base: set[str] = set(get_pool("watchlist")) + inst_path = Path(settings.data_dir) / "instruments" / "instruments.parquet" if inst_path.exists(): try: inst = pl.read_parquet(inst_path, columns=["symbol"]) base.update(inst["symbol"].to_list()) except Exception as e: # noqa: BLE001 - logger.warning("instruments supplement failed: %s", e) - # 过滤自选兜底里的指数 symbol (指数日K走独立 kline_index_* 存储, - # 进股票池会污染 kline_daily/kline_minute)。ETF 刻意保留 (既有行为)。 + logger.warning("instruments 维表读取失败, 标的池仅剩自选: %s", e) + else: + logger.warning("instruments 维表缺失, 标的池仅剩自选 %d 只", len(base)) if repo is not None: base -= set(repo.get_index_symbol_set()) return sorted(base) diff --git a/backend/app/main.py b/backend/app/main.py index 278245f6..3abd0d28 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from app.api import ( data, events, ext_data, + ext_pages, factors, financials, indices, @@ -39,6 +40,7 @@ from app.api import ( signals, stock_analysis, strategy, + task_registry, watchlist, ) from app.api import auth as auth_api @@ -160,6 +162,19 @@ async def _application_lifespan(app: FastAPI): capset = detect_capabilities() app.state.capabilities = capset logger.info("ready; %d capabilities active", len(capset.all())) + # 各能力的生效源与可用性: 切换数据源后一眼看出路由落点, 无需回设置页对照 + try: + from app.data_providers.capabilities import resolve_capability_sources + + for row in resolve_capability_sources(): + logger.info( + " 能力路由 | %-8s ← %-12s %s", + row["label"], + row["source_display"], + "可用" if row["usable"] else "不可用", + ) + except Exception as e: # noqa: BLE001 — 仅日志, 失败不影响启动 + logger.warning("能力路由日志输出失败: %s", e) # 全局行情服务 qs = QuoteService() @@ -558,6 +573,8 @@ app.include_router(analysis.router) app.include_router(pipeline.router) app.include_router(data.router) app.include_router(ext_data.router) +app.include_router(ext_pages.router) +app.include_router(task_registry.router) app.include_router(financials.router) app.include_router(stock_analysis.router) app.include_router(market_recap.router) diff --git a/backend/app/plugins/fuyao/plugin.yaml b/backend/app/plugins/fuyao/plugin.yaml index cc063f71..5e8073f2 100644 --- a/backend/app/plugins/fuyao/plugin.yaml +++ b/backend/app/plugins/fuyao/plugin.yaml @@ -9,6 +9,6 @@ entry: app.plugins.fuyao.provider:FuyaoProvider check: app.plugins.fuyao.provider:availability datasets: [realtime, daily, adj_factor, financial] api_key_env: FUYAO_API_KEY # 声明后设置页提供 Key 输入框(先探后存, secrets.json 优先) -description: "同花顺官方 REST 数据 API。A 股实时快照、日K(原始价: 近端走 10d dump, 深窗口走 10 年全量 dump 一次下载秒级筛选, 兜底单标的接口)、除权因子(事件 dump + 本地日K dump 配价推导, 涨跌停自检, 秒级)、财务(利润表/资产负债表/现金流量表/指标, 字段映射至项目口径, bps 由估值反推; 股本无上游接口, 指标历史期建议切回 TickFlow 补齐);分钟未接入,自动回退 TickFlow。" +description: "同花顺官方金融数据 API。提供实时快照、日K(原始价)、除权因子、财务三表与指标;未接入分钟与股本,自动回退 TickFlow。" install_hint: "点击卡片中的输入框配置 API Key(https://fuyao.aicubes.cn 申请),或在 .env 中配置 FUYAO_API_KEY" homepage: "https://fuyao.aicubes.cn" diff --git a/backend/app/plugins/tdxquant/__init__.py b/backend/app/plugins/tdxquant/__init__.py new file mode 100644 index 00000000..710f8727 --- /dev/null +++ b/backend/app/plugins/tdxquant/__init__.py @@ -0,0 +1 @@ +"""TdxQuant(通达信量化数据服务)内置数据源插件。""" diff --git a/backend/app/plugins/tdxquant/client.py b/backend/app/plugins/tdxquant/client.py new file mode 100644 index 00000000..26417684 --- /dev/null +++ b/backend/app/plugins/tdxquant/client.py @@ -0,0 +1,260 @@ +"""TdxQuant — tdxaidata (通达信量化数据) 原生库封装。 + +tdxaidata 是 ctypes 包装的私有 TCP 协议客户端(aihs/aids.tdx.com.cn), +DLL 与运行时 ini 随 wheel 分发。两个实测约束决定本模块的形状: + - 原生层每进程加载一次、token 写在 DLL 旁的 ini 里 → Key 物化到私有 + lib 目录 + TDX_AI_DATA_LIB 指向, 不污染 site-packages、换 Key 只重写 ini; + - 空闲后首次调用会 "Invalid server response" → 统一重试包装。 +""" +from __future__ import annotations + +import hashlib +import logging +import os +import shutil +import sys +import time +from datetime import date +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 全 A 股票池: 五个市场名实测(2026-10)合计约 5579 只; ETF/指数不在枚举内 +_MARKETS = ("上证主板", "科创板", "深证主板", "创业板", "北交所") + +# 快照批量字段(实测有效名; 注意昨收叫 LastClose、高低叫 Max/Min, 快照无 Name) +SNAPSHOT_FIELDS = ( + "Now", "Open", "Max", "Min", "LastClose", "Volume", "Amount", "RefreshTime", + "Buyp", "Buyv", "Sellp", "Sellv", +) + +_RETRIES = 3 +_NONE_RETRIES = 2 # 空返回(可能是永久性坏代码)只补试一次 +_RETRY_SLEEP_S = 0.6 +_UNIVERSE_TTL_S = 12 * 3600.0 + +_DLL_BY_PLATFORM = {"win32": "TdxAiData.dll", "darwin": "libTdxAiData.dylib"} +LIB_DLL_NAME = _DLL_BY_PLATFORM.get(sys.platform, "libTdxAiData.so") +LIB_ENV = "TDX_AI_DATA_LIB" # tdxaidata 读取: 指向 DLL 文件路径 + + +class TdxQuantError(RuntimeError): + """tdxaidata 调用失败(网络/参数/协议)。""" + + +def package_lib_dir() -> Path: + """tdxaidata 包自带的 lib 目录(平台 DLL + 运行时 ini)。""" + import tdxaidata + + return Path(tdxaidata.__file__).resolve().parent / "lib" + + +def write_token_ini(ini: Path, api_key: str) -> None: + """把 [Token] token= 写入 ini, 保留其余配置行。""" + lines = ( + ini.read_text(encoding="utf-8", errors="replace").splitlines() + if ini.exists() + else ["[Token]"] + ) + out: list[str] = [] + in_token = False + replaced = False + for line in lines: + stripped = line.strip() + if stripped.lower() == "[token]": + in_token = True + out.append(line) + continue + if stripped.startswith("[") and stripped.endswith("]"): + in_token = False + if in_token and "=" in line and stripped.lower().split("=", 1)[0].strip() == "token": + if not replaced: + out.append(f"token={api_key}") + replaced = True + continue + out.append(line) + if "[Token]" not in {line.strip() for line in out}: + out.extend(["[Token]", f"token={api_key}"]) + elif not replaced: + idx = next(i for i, line in enumerate(out) if line.strip().lower() == "[token]") + out.insert(idx + 1, f"token={api_key}") + ini.write_text("\n".join(out) + "\n", encoding="utf-8") + + +def materialize_lib(api_key: str, lib_root: Path, source: Path | None = None) -> Path: + """复制包内 lib 到私有目录并写入 token, 返回 DLL 路径。 + + marker 记 token 指纹 + DLL mtime/size, 未变化时直接复用(每轮 realtime + 轮询都会走到这里, 不能反复复制几十 MB 的 DLL)。 + """ + src = source or package_lib_dir() + dll = src / LIB_DLL_NAME + if not dll.exists(): + raise TdxQuantError(f"tdxaidata lib 缺少 {LIB_DLL_NAME}: {src}") + lib_root.mkdir(parents=True, exist_ok=True) + marker = lib_root / ".tdxquant_lib_marker" + stat = dll.stat() + want = f"{hashlib.sha256(api_key.encode()).hexdigest()}:{stat.st_mtime_ns}:{stat.st_size}" + copied = lib_root / LIB_DLL_NAME + if marker.exists() and marker.read_text(encoding="utf-8") == want and copied.exists(): + return copied + for item in src.iterdir(): + if item.is_file(): + shutil.copy2(item, lib_root / item.name) + for ini_name in ("TdxAiData.ini", "user.ini"): + write_token_ini(lib_root / ini_name, api_key) + marker.write_text(want, encoding="utf-8") + return copied + + +class TdxQuantClient: + """原生 tqs 的薄封装: 重试、股票池枚举、快照/日K/除权三个取数面。""" + + def __init__(self, api_key: str, lib_root: Path | None = None) -> None: + from app.config import settings + + self.api_key = api_key + self._lib_root = lib_root or (settings.data_dir / "user_data" / "tdxquant_lib") + dll = materialize_lib(api_key, self._lib_root) + os.environ[LIB_ENV] = str(dll) + from tdxaidata import tqs + from tdxaidata.tdxaidata import _NativeTdxAiData + + self._tqs = tqs + self._native_cls = _NativeTdxAiData + self._universe: list[str] = [] + self._universe_at = 0.0 + self._warmup() + + def _warmup(self, attempts: int = 5) -> None: + """DLL 冷启动后有一段"未就绪"窗口, 业务调用持续失败(实测首批整批丢弃)。 + + 用最小快照轮询到就绪; 一直失败不抛 — 后续调用可能自愈, 只留告警。 + """ + for _ in range(attempts): + try: + rows = self._tqs.get_market_snapshot_batch( + stock_list=["600519.SH"], field_list=["Now"] + ) + if isinstance(rows, dict) and rows: + return + except Exception: # noqa: BLE001 — 就绪探测, 任何异常都重试 + pass + time.sleep(1.2) + logger.warning("TdxQuant 原生层预热未成功(%d 次), 首批业务调用可能失败重试", attempts) + + def close(self) -> None: # 原生 DLL 常驻进程, 无可回收句柄; 保留接口对齐 loader + self._universe.clear() + + # ---- 原生调用(重试包装) ---- + def _raw(self, method: str, *args): + """直接调原生层并 _clean 解包; 异常重试 3 次、空返回补试 1 次。 + + 空闲后首连 "Invalid server response" 实测重试即恢复; 坏代码等 + 永久错误重试也只会快失败(返回 None), 不至于长阻塞。 + """ + last: Exception = TdxQuantError(f"{method} 未知失败") + for attempt in range(_RETRIES): + try: + out = self._tqs._clean(getattr(self._native_cls.get_instance(), method)(*args)) + if out is not None: + return out + last = TdxQuantError(f"{method} 返回空数据(第 {attempt + 1} 次)") + if attempt + 1 >= _NONE_RETRIES: + break + except Exception as exc: # noqa: BLE001 — 原生 ctypes 层异常类型不可枚举 + last = TdxQuantError(f"{method}: {exc}") + time.sleep(_RETRY_SLEEP_S) + raise last + + # ---- 股票池 ---- + def stock_universe(self) -> list[str]: + """五市场全 A 代码(带交易所后缀)。12h 缓存, 避免每轮快照重复枚举。""" + now = time.time() + if self._universe and now - self._universe_at < _UNIVERSE_TTL_S: + return list(self._universe) + last: TdxQuantError = TdxQuantError("股票列表为空(五个市场均无返回)") + for _attempt in range(_RETRIES): + out: list[str] = [] + try: + for market in _MARKETS: + lst = self._tqs.get_stock_list(market=market, list_type=0) + out.extend(code for code in (lst or []) if isinstance(code, str) and code) + except Exception as exc: # noqa: BLE001 — SDK 内部异常类型不可枚举 + last = TdxQuantError(f"get_stock_list: {exc}") + out = [] + if out: + self._universe, self._universe_at = out, now + return list(out) + time.sleep(_RETRY_SLEEP_S) + raise last + + # ---- 快照 ---- + def snapshot_batch(self, codes: list[str]) -> dict[str, dict]: + """批量快照 → {code: row(字段见 SNAPSHOT_FIELDS)}。SDK 内部已做批处理。 + + 空闲后首连易 "Invalid server response"(实测重试即恢复), 批级失败必须 + 重试 — provider 只做批间隔离, 这里不重试会整批丢标的。 + """ + if not codes: + return {} + last: TdxQuantError = TdxQuantError(f"snapshot_batch({len(codes)} 只): 未知失败") + for _attempt in range(_RETRIES): + try: + rows = self._tqs.get_market_snapshot_batch( + stock_list=list(codes), field_list=list(SNAPSHOT_FIELDS) + ) + except Exception as exc: # noqa: BLE001 — SDK 内部异常类型不可枚举 + last = TdxQuantError(f"snapshot_batch({len(codes)} 只): {exc}") + time.sleep(_RETRY_SLEEP_S) + continue + if isinstance(rows, dict) and rows: + return rows + last = TdxQuantError(f"snapshot_batch({len(codes)} 只): 空返回") + time.sleep(_RETRY_SLEEP_S) + raise last + + # ---- K 线(count 模式, 原始列) ---- + def his_dats(self, code: str, end_d: date, count: int, period: str = "1d") -> dict: + """单标的 K 线原生 dict: Date 'YYYYMMDD' / Open / High / Low / Close / + Volume(股) / Amount(万元) 等。count 模式下 start 恒空(服务端只用 end)。 + """ + if count <= 0: + return {} + end = f"{end_d:%Y-%m-%d} 00:00:00" + out = self._raw("get_his_dats", code, "", end, period, "none", int(count)) + return out if isinstance(out, dict) else {} + + # ---- 除权事件 ---- + def divid_factors( + self, code: str, start_d: date | None = None, end_d: date | None = None + ) -> list[dict]: + """单标的除权事件, 每行 {Date 'YYYYMMDD', Type, Bonus, ShareBonus, + AllotPrice, Allotment} — 金额/比例均为**每 10 股**口径(Bonus=10派X元)。 + """ + start = f"{start_d:%Y-%m-%d}" if start_d else "" + end = f"{end_d:%Y-%m-%d}" if end_d else "" + try: + out = self._tqs.get_divid_factors(code, start, end) + except Exception as exc: # SDK 内部异常类型不可枚举 + raise TdxQuantError(f"get_divid_factors({code}): {exc}") from exc + if isinstance(out, list): + return out + # 环境装有 pandas 时 SDK 返回 DataFrame, 归一为 list[dict]。 + # 注意 Date 会成为 DataFrame 索引(同 get_market_data 的日期索引惯例), + # reset_index 后若列名漂移, 把首列恢复为 'Date'。 + to_dict = getattr(out, "to_dict", None) + reset_index = getattr(out, "reset_index", None) + if callable(to_dict) and callable(reset_index): + try: + records = reset_index().to_dict("records") + if isinstance(records, list) and records: + if "Date" not in records[0]: + first_key = next(iter(records[0])) + if first_key not in ("Type", "Bonus", "AllotPrice", "ShareBonus", "Allotment"): + for row in records: + row["Date"] = row.pop(first_key) + return records + except Exception: # noqa: BLE001 — 转换失败按结构异常处理 + pass + raise TdxQuantError(f"get_divid_factors({code}) 返回异常结构: {type(out)!r}") diff --git a/backend/app/plugins/tdxquant/plugin.yaml b/backend/app/plugins/tdxquant/plugin.yaml new file mode 100644 index 00000000..69318651 --- /dev/null +++ b/backend/app/plugins/tdxquant/plugin.yaml @@ -0,0 +1,23 @@ +# TdxQuant — 通达信量化数据服务插件 (https://help.tdx.com.cn/quant/) +# 原生 TCP 私有协议 (tdxaidata 包, ctypes 包装跨平台 DLL), runtime: python。 +# tdxaidata 已是后端默认依赖; 只需配置 TDXQUANT_API_KEY, 未配置时灰显不可切换。 + +name: tdxquant +display_name: "TdxQuant (TdxAiData)" +runtime: python +entry: app.plugins.tdxquant.provider:TdxQuantProvider +check: app.plugins.tdxquant.provider:availability +datasets: [realtime, depth5, daily, adj_factor, minute, full_minute] +api_key_env: TDXQUANT_API_KEY # 声明后设置页提供 Key 输入框(先探后存, secrets.json 优先) +billing: points # 积分计费标识: 设置页每个能力芯片/适配行标注「积分」, 消耗以商城积分明细为准 +key_manage_url: "https://vip.tdx.com.cn/site/app/pc-mall/main.html#/aiKey" # Key/积分管理页(通达信商城), 设置页 Key 区提供入口 +description: "通达信 TdxAiData 数据服务(原生 TCP 协议, 依赖已内置): 全市场实时快照、五档盘口、日K、除权因子、分钟K、全量分钟六项能力; 按积分计费, 未接入财务, 自动回退 TickFlow。" +billing_note: "积分消耗与轮询频率、拉取范围直接相关: 间隔越短、范围越大消耗越高。建议实时轮询间隔 ≥60 秒, 全量分钟按需启用; 具体扣减以商城「积分明细」为准。" +usage_notes: + - "实时快照: 五市场全 A 股票池, 600 只/批, 全市场一轮约 10 批(30-60 秒)" + - "五档盘口: 随快照返回, 不产生额外请求" + - "日K / 除权因子: count 模式单标的拉取(原始价); 因子按交易所公式推导, 带涨跌停自检" + - "分钟K: 1m 逐标的, 240 根/日, 深度约 40 交易日" + - "全量分钟: 逐标的拉取, 全市场一轮约 10 分钟量级(上游无批量端点)" +install_hint: "依赖已内置; 在下方输入框填写数据服务 Key(通达信个人版商城-会员中心-积分和Key管理创建), 或配置环境变量 TDXQUANT_API_KEY" +homepage: "https://help.tdx.com.cn/quant/" diff --git a/backend/app/plugins/tdxquant/probe.py b/backend/app/plugins/tdxquant/probe.py new file mode 100644 index 00000000..16d2ae68 --- /dev/null +++ b/backend/app/plugins/tdxquant/probe.py @@ -0,0 +1,162 @@ +"""候选 Key 探测 — 独立子进程「格式 + 连通性」探测。 + +原生 DLL 每进程加载一次且绑定一个 token: 主进程可能已用旧 Key 加载, +候选 Key 无法在进程内替换, 因此探测必须另起进程(临时 lib 目录 + +TDX_AI_DATA_LIB 指向), 结论以 JSON 回传。 + +可靠性设计(实测教训): + - DLL 原生握手固定 ~30s(同 IP 并发会话疑似服务端节流), 超时必须 >45s; + - 子进程可能在原生层直接死亡(段错误级, 无任何输出) — 每阶段把进度写入 + 父进程指定的进度文件, 父进程据此定位死点并附退出码, 不再返回"无输出"; + - TDX 会话按 IP 复用: 同 IP 有活跃好 Key 会话时(常驻后端必然如此), + 错误 Key 的日K也能返回 ⇒ "无效 Key 拒绝"不可靠。本探测只验证 + 格式 + 连通性, Key 真伪由首次实拉(test_dataset / 日常轮询)暴露。 +""" +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +logger = logging.getLogger(__name__) + +_PROBE_ENV_KEY = "TDX_PROBE_CANDIDATE_KEY" +_PROBE_ENV_TMP = "TDX_PROBE_TMP_DIR" + +# 子进程入口: 复用 client.materialize_lib 保持与主进程同一套物化/写 token 逻辑 +_SCRIPT = """ +import json +import os +import time +from pathlib import Path + +key = os.environ.pop("TDX_PROBE_CANDIDATE_KEY", "") +tmp = Path(os.environ.pop("TDX_PROBE_TMP_DIR", "")) +progress = tmp / "progress.json" if str(tmp) else None + + +def stage(name, **extra): + if progress is None: + return + try: + progress.write_text(json.dumps({"stage": name, **extra}), encoding="utf-8") + except Exception: + pass + + +try: + stage("imports") + from app.plugins.tdxquant import client as tc + + stage("materialize") + dll = tc.materialize_lib(key, tmp) + os.environ[tc.LIB_ENV] = str(dll) + + stage("load_dll") + from tdxaidata.tdxaidata import _NativeTdxAiData + + native = _NativeTdxAiData.get_instance() + + # 连通性判定: 快照端点不依赖 token 鉴权, 拿到数据即证明 + # 服务器可达 + DLL 正常。轮询容忍 DLL 冷启动的未就绪窗口。 + stage("start") + reachable = False + for _ in range(3): + try: + if native.get_market_snapshot_batch("600519.SH", "Now"): + reachable = True + break + except Exception: + pass + time.sleep(1.2) + if not reachable: + stage("done", ok=False) + print(json.dumps({"ok": False, "reason": "行情服务器连接失败(网络不可达)"})) + raise SystemExit(0) + + # 日K仅作辅助信号(会话按 IP 复用时错误 Key 也能通过, 不能作拒绝依据) + stage("auth") + aux = "" + try: + raw = native.get_his_dats( + "600519.SH", "", "2026-01-01 00:00:00", "1d", "none", 3 + ) + from tdxaidata import tqs + + v = tqs._clean(raw) + aux = "his_ok" if isinstance(v, dict) and v.get("Date") else "his_empty" + except Exception as exc: + aux = "his_exc" + + stage("done", ok=True) + print(json.dumps({"ok": True, "reason": "", "aux": aux})) +except Exception as exc: + stage("exception", reason=str(exc)[:300]) + print(json.dumps({"ok": False, "reason": str(exc)[:300]})) +""" + + +def _read_stage(tmp_dir: str) -> str: + try: + data = json.loads((Path(tmp_dir) / "progress.json").read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 — 进度文件缺失/损坏按未知处理 + return "未知(无进度文件)" + stage = str(data.get("stage", "?")) + if stage == "exception": + return f"异常: {str(data.get('reason', ''))[:150]}" + return stage + + +def _tail(text: str, limit: int = 150) -> str: + lines = (text or "").strip().splitlines() + return lines[-1][:limit] if lines else "(空)" + + +def run(api_key: str, timeout_s: float = 75.0) -> tuple[bool, str]: + """子进程探测候选 Key: 格式由调用方把关, 这里验证连通性(快照可达)。""" + if not api_key: + return False, "Key 为空" + backend_root = Path(__file__).resolve().parents[3] + tmp_dir = tempfile.mkdtemp(prefix="tdx_probe_") + env = { + **os.environ, + _PROBE_ENV_KEY: api_key, + _PROBE_ENV_TMP: tmp_dir, + "PYTHONPATH": str(backend_root), + } + try: + try: + proc = subprocess.run( + [sys.executable, "-c", _SCRIPT], + capture_output=True, + text=True, + timeout=timeout_s, + cwd=str(backend_root), + env=env, + ) + except subprocess.TimeoutExpired: + stage = _read_stage(tmp_dir) + return False, f"探测超时({timeout_s:.0f}s, 停在阶段[{stage}]) — 原生握手约需 30s, 请检查网络后重试" + for line in reversed((proc.stdout or "").splitlines()): + line = line.strip() + if line.startswith("{"): + try: + payload = json.loads(line) + except json.JSONDecodeError: + break + if payload.get("ok"): + return True, "ok(连通正常; Key 有效性以首次拉取为准)" + return False, f"探测失败: {payload.get('reason', '未知原因')}" + # 无 JSON 结论: 原生层直接死亡(无输出)或入口即崩 — 用进度文件定位死点 + stage = _read_stage(tmp_dir) + return False, ( + f"探测失败: 子进程死在阶段[{stage}], exit={proc.returncode}, " + f"stderr={_tail(proc.stderr)}" + ) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/backend/app/plugins/tdxquant/provider.py b/backend/app/plugins/tdxquant/provider.py new file mode 100644 index 00000000..2dd11fcf --- /dev/null +++ b/backend/app/plugins/tdxquant/provider.py @@ -0,0 +1,782 @@ +"""TdxQuant(通达信量化数据服务)内置数据源 provider。 + +方法签名对齐 custom.GenericHTTPProvider(service 分流点按这套签名调用), +插件被 loader 扫描注册后, 各 service 无需改动即可路由。 + +实现数据集: + - realtime A 股全市场快照(五市场股票池, 600 只/批) + - depth5 五档盘口(快照 Buyp/Buyv/Sellp/Sellv) + - daily A 股日K, 原始价(dividend=none), count 模式单标的拉取 + - adj_factor 除权因子: 事件接口 + 本地K线配价, 交易所公式推导, 涨跌停自检 + - minute 分钟K(period=1m count 模式, 单标的 240 根/日, 原始价) + - full_minute 全量分钟: 修复轮 get_intraday_batch(当日窗口) + + 稳态增量 get_intraday_latest(每股最新 count 根)。 +未声明 financial → provider_has_dataset 为 False, 回退 tickflow。 + +单位与口径 (CONTRIBUTING §3, 不可凭字段名推断; 2026-10 实测): + - 快照 Volume 已是手, 直接透传; Amount 为万元 → ×10000 转元。 + - 快照无涨跌幅字段 → change_pct = (Now-LastClose)/LastClose, 小数制。 + - 日K Volume 为股 → floor(/100) 转手; Amount 为万元 → ×10000 转元。 + - 分钟K与日K同源同口径 (实测茅台全天 240 根 sum(Volume)=3511100 ≈ 日K + 3511051 股): Volume 股 → floor(/100) 转手; Amount 万元 → ×10000 转元; + datetime = Date+Time 拼北京墙钟 naive (契约 §分钟K)。 + - 日K取数 dividend="none" 锁定原始价, 前复权由 indicators.pipeline 用本地因子计算。 + - 除权事件 Bonus/ShareBonus/Allotment 均为每 10 股口径 → ÷10 转每股再进公式。 + - ex_factor 为单事件比值(非累积), 累积链由 pipeline._apply_adj_factor 构建。 +""" + +from __future__ import annotations + +import contextlib +import logging +import math +import time +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +import polars as pl + +from app.plugins.tdxquant.client import SNAPSHOT_FIELDS, TdxQuantClient, TdxQuantError + +logger = logging.getLogger(__name__) + +# 只声明真实提供的数据集; 其余数据集 provider_has_dataset 返回 False → 回退 tickflow +_DATASETS = ("realtime", "depth5", "daily", "adj_factor", "minute", "full_minute") + +API_KEY_ENV = "TDXQUANT_API_KEY" +SECRETS_FIELD = "tdxquant_api_key" # UI 配置的 Key 存 secrets.json, 优先级高于 .env + +_SNAPSHOT_BATCH = 600 # 实测 600 只/批正常返回 +_HIST_INTERVAL_S = 0.08 # 单标的 K 线/事件请求节流 +_HIST_MAX_COUNT = 2600 # count 模式单次上限(≈10.6 年日K, 项目深窗口即 10 年) +_ADJ_BACKDAYS = 30 # 推导因子时向前找"除权日前收盘"的回看天数(容忍长期停牌) +_ADJ_FUTURE_DAYS = 15 # 未来已公告事件窗口: 无前收盘自然跳过, 滚动增量轮补上 +_BEIJING = ZoneInfo("Asia/Shanghai") + +# 内部指数代码 → TDX 快照代码(实测 2026-10: 上证指数走 TDX 999999 别名) +_INDEX_CODE_MAP = {"000001.SH": "999999.SH"} + +_DAILY_SCHEMA = { + "symbol": pl.String, "date": pl.Date, + "open": pl.Float64, "high": pl.Float64, "low": pl.Float64, "close": pl.Float64, + "volume": pl.Int64, "amount": pl.Float64, +} +_ADJ_SCHEMA = { + "symbol": pl.String, "trade_date": pl.Date, "ex_factor": pl.Float64, + "dividend": pl.Float64, "bonus": pl.Float64, "allot": pl.Float64, + "allot_price": pl.Float64, "prev_close": pl.Float64, +} +_MINUTE_SCHEMA = { + "symbol": pl.String, "datetime": pl.Datetime("us"), + "open": pl.Float64, "high": pl.Float64, "low": pl.Float64, "close": pl.Float64, + "volume": pl.Float64, "amount": pl.Float64, +} +_MINUTE_BARS_PER_DAY = 240 # A 股 1m 一交易日根数(含收盘集合竞价分钟) +_MINUTE_MAX_COUNT = 9600 # 实测单次 count=9600(40 交易日)正常返回 +_MINUTE_INTERVAL_S = 0.08 # 单标的分钟请求节流(与日K同档) + + +def get_api_key() -> str: + from app import secrets_store + + return secrets_store.get_env_backed_secret(SECRETS_FIELD, API_KEY_ENV) + + +def _module_available() -> bool: + try: + import importlib.util + + return importlib.util.find_spec("tdxaidata") is not None + except Exception: # noqa: BLE001 — find_spec 对损坏安装也会抛 + return False + + +def availability() -> tuple[bool, str]: + """loader 启动自检: tdxaidata 已安装 + Key 已配置才注册。不抛异常。""" + if not _module_available(): + return False, "tdxaidata 未安装(默认依赖, 请检查 uv sync)" + if not get_api_key(): + return False, f"缺少 API Key(可在下方输入框直接填写,或配置环境变量 {API_KEY_ENV})" + return True, "ok" + + +def probe_api_key(api_key: str) -> tuple[bool, str]: + """候选 Key 探测: 格式校验 + 子进程连通性实探。 + + TDX 会话按 IP 复用(同 IP 活跃好 Key 会话时错误 Key 也能拉到日K, 实测), + "无效 Key 拒绝"不可靠 — 格式明显不对直接拒绝, 连通性通过即保存, + Key 真伪由首次实拉(test_dataset / 日常轮询)暴露, 卡片可一键试拉复核。 + """ + key = (api_key or "").strip() + if not key.startswith("TDX-") or len(key) < 12: + return False, "Key 格式应为 TDX- 开头(通达信商城创建的数据服务 Key)" + from app.plugins.tdxquant import probe + + return probe.run(key) + + +@dataclass +class _TdxQuantConfig: + """轻量 config shim, 让 custom loader 的 provider_has_dataset 能识别本 provider。""" + + name: str = "tdx" + display_name: str = "TdxQuant" + datasets: dict = field(default_factory=lambda: dict.fromkeys(_DATASETS)) + path: None = None + builtin: bool = True + + +def _to_float(value) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _compact_date(value) -> date | None: + """原生 Date 'YYYYMMDD'(或 DataFrame 归一后的 ISO 'YYYY-MM-DD…')→ date。 + + 非法值返回 None, 不伪造。 + """ + try: + text = str(value).strip() + if len(text) >= 10 and text[4] == "-" and text[7] == "-": + text = text[:10].replace("-", "") + return date(int(text[:4]), int(text[4:6]), int(text[6:8])) + except (TypeError, ValueError): + return None + + +def _snapshot_ms(refresh_time, fallback_ms: int) -> int: + """RefreshTime 'HHMMSS'(服务端北京墙钟) + 今日北京日期 → epoch ms。 + + 解析失败退回本地时间; A 股时段无跨日歧义。 + """ + try: + text = str(refresh_time).strip().zfill(6) + hh, mm, ss = int(text[:2]), int(text[2:4]), int(text[4:6]) + if hh > 23 or mm > 59 or ss > 59: + raise ValueError(text) + now_bj = datetime.now(_BEIJING) + stamp = datetime(now_bj.year, now_bj.month, now_bj.day, hh, mm, ss, tzinfo=_BEIJING) + return int(stamp.timestamp() * 1000) + except (TypeError, ValueError): + return fallback_ms + + +def _map_snapshot_row(symbol: str, row: dict, fallback_ms: int) -> dict | None: + """TDX 快照行 → 内部 realtime record。字段缺失按口径推导, 不伪造数据。""" + last = _to_float(row.get("Now")) + prev = _to_float(row.get("LastClose")) + if last is None and prev is None: + return None + + change_amount = last - prev if (last is not None and prev is not None) else None + # 与 quote_service 推导同口径: 小数制, 不乘 100 + change_pct = change_amount / prev if (change_amount is not None and prev) else None + + volume = _to_float(row.get("Volume")) # 快照已为手, 零换算 + amount = _to_float(row.get("Amount")) # 万元 → 元 + + return { + "symbol": symbol, + "name": None, # 快照无名称, 由下游维表关联 + "last_price": last, + "prev_close": prev, + "open": _to_float(row.get("Open")), + "high": _to_float(row.get("Max")), + "low": _to_float(row.get("Min")), + "volume": math.floor(volume) if volume is not None else None, + "amount": amount * 10_000.0 if amount is not None else None, + "change_pct": change_pct, + "change_amount": change_amount, + "amplitude": None, # 快照未提供, 不启发式计算 + "turnover_rate": None, # 需股本口径 (§3.4), 交给 enriched 管道 + "timestamp": _snapshot_ms(row.get("RefreshTime"), fallback_ms), + "session": None, + } + + +def _ref_price( + prev_close: float, dividend: float, bonus: float, allot: float, allot_price: float +) -> float | None: + """交易所除权参考价: (P - D + AR·AP) / (1 + S + AR), 四舍五入(half-up)保留 2 位。""" + denom = 1.0 + bonus + allot + if denom <= 0: + return None + x = (prev_close - dividend + allot * allot_price) / denom + return math.floor(x * 100 + 0.5) / 100 + + +def _price_limit(symbol: str) -> float: + """按代码前缀给涨跌停幅度(自检容差用): 创业板/科创板 20%, 北交所 30%, 主板 10%。""" + code = symbol.split(".")[0] + if code.startswith(("300", "301", "688", "689")): + return 0.20 + if code.startswith(("8", "4", "92")): + return 0.30 + return 0.10 + + +def _kline_rows(symbol: str, raw: dict, start_d: date | None, end_d: date) -> list[dict]: + """原生 count 模式 K 线 dict → 内部行(窗口过滤, volume 股→手, amount 万元→元)。""" + + def _col(name: str, i: int) -> float | None: + values = raw.get(name) + return _to_float(values[i]) if values and i < len(values) else None + + dates = raw.get("Date") or [] + out: list[dict] = [] + for i, compact in enumerate(dates): + d = _compact_date(compact) + if d is None or d > end_d: + continue + if start_d is not None and d < start_d: + continue + volume = _col("Volume", i) + amount = _col("Amount", i) + out.append({ + "symbol": symbol, + "date": d, + "open": _col("Open", i), + "high": _col("High", i), + "low": _col("Low", i), + "close": _col("Close", i), + "volume": math.floor(volume / 100.0) if volume is not None else None, + "amount": amount * 10_000.0 if amount is not None else None, + }) + return out + + +def _bars_for_window(span_days: int) -> int: + """窗口自然日跨度 → count 模式请求根数(交易日≈自然日×5/7, 加停牌冗余)。""" + return max(30, min(_HIST_MAX_COUNT, int(span_days * 5 / 7) + 20)) + + +def _minute_dt(compact_date, hhmmss) -> datetime | None: + """Date 'YYYYMMDD' + Time 'HHMMSS' → 北京墙钟 naive datetime; 非法返回 None。""" + d = _compact_date(compact_date) + if d is None: + return None + t = str(hhmmss or "").strip() + if len(t) < 6 or not t[:6].isdigit(): + return None + try: + return datetime(d.year, d.month, d.day, int(t[:2]), int(t[2:4]), int(t[4:6])) + except ValueError: + return None + + +def _minute_rows( + symbol: str, raw: dict, start_dt: datetime | None, end_dt: datetime | None, + today_only: date | None = None, +) -> list[dict]: + """原生 1m K 线 dict → 内部分钟行(datetime 北京墙钟, volume 股→手, amount 万元→元)。 + + today_only 非空时仅保留该交易日的行(修复轮用: count 模式尾部会带前一日余量)。 + """ + dates = raw.get("Date") or [] + times = raw.get("Time") or [] + out: list[dict] = [] + for i, compact in enumerate(dates): + dt = _minute_dt(compact, times[i] if i < len(times) else None) + if dt is None: + continue + if today_only is not None and dt.date() != today_only: + continue + if start_dt is not None and dt < start_dt: + continue + if end_dt is not None and dt > end_dt: + continue + + def _col(name: str, idx: int = i) -> float | None: + values = raw.get(name) + return _to_float(values[idx]) if values and idx < len(values) else None + + close = _col("Close") + if close is None: + continue # 停牌/无成交分钟, 不伪造 + volume = _col("Volume") + amount = _col("Amount") + out.append({ + "symbol": symbol, "datetime": dt, + "open": _col("Open"), "high": _col("High"), + "low": _col("Low"), "close": close, + "volume": float(math.floor(volume / 100.0)) if volume is not None else None, + "amount": amount * 10_000.0 if amount is not None else None, + }) + return out + + +def _minute_count_for_window(start_dt: datetime | None, end_dt: datetime) -> int: + """窗口 → 单次 count 请求根数(交易日×240 + 两日余量, 封顶 _MINUTE_MAX_COUNT)。""" + span_days = (end_dt.date() - start_dt.date()).days if start_dt else 30 + return max(240, min(_MINUTE_MAX_COUNT, int(span_days * 5 / 7 * _MINUTE_BARS_PER_DAY) + 480)) + + +class TdxQuantProvider: + """tdx 数据源。realtime = 五市场全 A 快照批拉; depth5 复用同一快照的五档数组。""" + + name = "tdxquant" + builtin = True + + def __init__(self) -> None: + self.config = _TdxQuantConfig() + self._client: TdxQuantClient | None = None + + def close(self) -> None: # loader.load_all 重建注册表时会对每个 provider 调 close + if self._client is not None: + with contextlib.suppress(Exception): + self._client.close() + self._client = None + + def _get_client(self) -> TdxQuantClient: + if self._client is None: + self._client = TdxQuantClient(api_key=get_api_key()) + return self._client + + # ---- realtime ---- + def get_realtime(self) -> list[dict]: + """全市场实时快照 → 内部 realtime records。失败软返回空列表(不阻断轮询)。""" + client = self._get_client() + try: + universe = client.stock_universe() + except TdxQuantError as e: + logger.warning("TdxQuant 股票池枚举失败: %s", e) + return [] + + fallback_ms = int(time.time() * 1000) + records: list[dict] = [] + dropped = failed_batches = 0 + for i in range(0, len(universe), _SNAPSHOT_BATCH): + codes = universe[i : i + _SNAPSHOT_BATCH] + try: + rows = client.snapshot_batch(codes) + except TdxQuantError as e: + failed_batches += 1 + logger.warning("TdxQuant 快照批次失败(%d 只, 第 %d 批): %s", len(codes), i // _SNAPSHOT_BATCH + 1, e) + continue + for code, row in rows.items(): + rec = _map_snapshot_row(code, row, fallback_ms) + if rec is not None: + records.append(rec) + else: + dropped += 1 + if failed_batches: + logger.warning("TdxQuant 快照 %d/%d 批失败, 本轮仅返回 %d 条", + failed_batches, math.ceil(len(universe) / _SNAPSHOT_BATCH), len(records)) + if not records and universe: + logger.warning("TdxQuant 实时行情本轮 0 条(请求 %d 只), 疑似连接或协议异常", len(universe)) + elif dropped: + logger.info("TdxQuant 实时行情拉取完成: %d 条(丢弃 %d 行无价快照)", len(records), dropped) + return records + + def get_realtime_indices(self, symbols: list[str]) -> list[dict] | None: + """指数实时快照(可选插件协议, quote_service 鸭子类型调用)。 + + TDX 快照直接支持指数代码; 未映射的代码跳过(上层用本地日K兜底)。 + 失败返回 None 保留上轮有效缓存, 成功但无数据返回 []。 + """ + wanted: list[str] = [] + for s in symbols: + if not s: + continue + code = _INDEX_CODE_MAP.get(s, s) + if code not in wanted: + wanted.append(code) + if not wanted: + return [] + try: + rows = self._get_client().snapshot_batch(wanted) + except TdxQuantError as e: + logger.warning("TdxQuant 指数行情拉取失败: %s", e) + return None + fallback_ms = int(time.time() * 1000) + records = [] + for code, row in rows.items(): + rec = _map_snapshot_row(code, row, fallback_ms) + if rec is not None: + records.append(rec) + logger.info("TdxQuant 指数行情拉取完成: %d 条(请求 %d 只)", len(records), len(wanted)) + return records + + # ---- daily ---- + def get_daily( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: str = "stock", + on_chunk_done: Callable[[int, int], None] | None = None, + ) -> pl.DataFrame: + chunks = [ + df + for df in self.iter_daily( + symbols, start_time=start_time, end_time=end_time, + asset_type=asset_type, on_chunk_done=on_chunk_done, + ) + if not df.is_empty() + ] + return pl.concat(chunks, how="diagonal_relaxed") if chunks else pl.DataFrame(schema=_DAILY_SCHEMA) + + def iter_daily( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: str = "stock", + on_chunk_done: Callable[[int, int], None] | None = None, + ) -> Iterator[pl.DataFrame]: + """逐标的 count 模式拉日K → 内部契约(原始价, volume 手, amount 元)。 + + ETF/指数日K未实测覆盖, asset_type 仅接受 stock, 其余空手而归(上层回退)。 + """ + if not symbols or asset_type != "stock": + if on_chunk_done: + on_chunk_done(0, 0) + return + client = self._get_client() + start_d = start_time.date() if start_time else None + end_d = end_time.date() if end_time else date.today() + span = (end_d - start_d).days if start_d else _HIST_MAX_COUNT * 1.4 + count = _bars_for_window(int(span)) + for i, symbol in enumerate(symbols): + try: + raw = client.his_dats(symbol, end_d, count) + except TdxQuantError as e: + logger.warning("TdxQuant 日K %s 拉取失败: %s", symbol, e) + raw = {} + rows = _kline_rows(symbol, raw, start_d, end_d) + if rows and rows[0]["date"] is not None and start_d is not None and rows[0]["date"] > start_d: + logger.info("TdxQuant 日K %s: count=%d 仅覆盖到 %s(窗口起点 %s 之前的数据不在返回内)", + symbol, count, rows[0]["date"], start_d) + yield pl.DataFrame(rows, schema=_DAILY_SCHEMA) + if on_chunk_done: + on_chunk_done(i + 1, len(symbols)) + time.sleep(_HIST_INTERVAL_S) + + # ---- adj_factor ---- + def get_adj_factors( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: str = "stock", + on_chunk_done: Callable[[int, int], None] | None = None, + ) -> pl.DataFrame: + """除权因子 → 内部契约(symbol/trade_date/ex_factor, 单事件比值非累积) + + 事件明细列(dividend/bonus/allot/allot_price/prev_close, 每股口径)。 + + 数据链: divid_factors 事件接口(每 10 股口径 → ÷10 转每股) → 同一标的 + 一次 count 模式日K取齐窗口内收盘价 → 交易所公式推导 → 涨跌停自检剔除。 + 未来已公告事件无前收盘, 自然跳过, 滚动增量窗口补上。 + """ + if not symbols or asset_type != "stock": + return pl.DataFrame(schema=_ADJ_SCHEMA) + client = self._get_client() + start_d = start_time.date() if start_time else None + end_d = end_time.date() if end_time else date.today() + + out_rows: list[dict] = [] + for i, symbol in enumerate(symbols): + try: + events = client.divid_factors(symbol, start_d, end_d + timedelta(days=_ADJ_FUTURE_DAYS)) + except TdxQuantError as e: + logger.warning("TdxQuant 除权事件 %s 拉取失败: %s", symbol, e) + events = [] + events = [ev for ev in events if _compact_date(ev.get("Date")) is not None] + if events: + events.sort(key=lambda ev: str(ev.get("Date"))) + first_ex = _compact_date(events[0]["Date"]) + closes = self._close_map(client, symbol, first_ex - timedelta(days=_ADJ_BACKDAYS), end_d) + out_rows.extend(self._factor_rows(symbol, events, closes, start_d, end_d)) + if on_chunk_done: + on_chunk_done(i + 1, len(symbols)) + time.sleep(_HIST_INTERVAL_S) + if not out_rows: + return pl.DataFrame(schema=_ADJ_SCHEMA) + return ( + pl.DataFrame(out_rows, schema=_ADJ_SCHEMA) + .unique(subset=["symbol", "trade_date"], keep="last") + .sort(["symbol", "trade_date"]) + ) + + def _close_map(self, client: TdxQuantClient, symbol: str, start_d: date, end_d: date) -> dict[date, float]: + """一次 count 模式日K → {交易日: 原始收盘价}(推导前收盘与自检共用)。""" + try: + raw = client.his_dats(symbol, end_d, _bars_for_window((end_d - start_d).days)) + except TdxQuantError as e: + logger.warning("TdxQuant 除权配价 %s 日K失败: %s", symbol, e) + return {} + out: dict[date, float] = {} + dates = raw.get("Date") or [] + closes = raw.get("Close") or [] + for i, compact in enumerate(dates): + d = _compact_date(compact) + close = _to_float(closes[i]) if i < len(closes) else None + if d is not None and close is not None: + out[d] = close + return out + + def _factor_rows( + self, symbol: str, events: list[dict], closes: dict[date, float], + start_d: date | None, end_d: date, + ) -> list[dict]: + days = sorted(closes) + rows: list[dict] = [] + for ev in events: + exd = _compact_date(ev.get("Date")) + if exd is None or exd > end_d + timedelta(days=_ADJ_FUTURE_DAYS): + continue + if start_d is not None and exd < start_d: + continue + # 每 10 股口径 → 每股 (Bonus=10派X元, ShareBonus=10送Y股, Allotment=10配Z股) + dividend = (_to_float(ev.get("Bonus")) or 0.0) / 10.0 + bonus = (_to_float(ev.get("ShareBonus")) or 0.0) / 10.0 + allot = (_to_float(ev.get("Allotment")) or 0.0) / 10.0 + allot_price = _to_float(ev.get("AllotPrice")) + prev_days = [d for d in days if d < exd] + if not prev_days: + continue # 日K窗口未覆盖(远古事件或未来已公告事件) + p = closes[prev_days[-1]] + ref = _ref_price(p, dividend, bonus, allot, allot_price or 0.0) + if ref is None or ref <= 0: + logger.warning("TdxQuant 除权因子: %s %s 参考价无法计算(P=%s D=%s S=%s AR=%s AP=%s), 跳过", + symbol, exd, p, dividend, bonus, allot, allot_price) + continue + factor = p / ref + ex_days = [d for d in days if d >= exd] + if ex_days: # 涨跌停自检: 错误因子会令复权后除权日涨跌幅超出板限 + ret = closes[ex_days[0]] / (p / factor) - 1.0 + if abs(ret) > _price_limit(symbol) + 0.02: + logger.warning("TdxQuant 除权因子自检剔除 %s %s: 复权后除权日涨跌幅 %.1f%% 超出涨跌停", + symbol, exd, ret * 100) + continue + rows.append({ + "symbol": symbol, "trade_date": exd, "ex_factor": factor, + "dividend": dividend, "bonus": bonus, "allot": allot, + "allot_price": allot_price, "prev_close": p, + }) + return rows + + # ---- minute / full_minute ---- + def get_minute( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: str = "stock", + freq: str = "1m", + on_chunk_done: Callable[[int, int], None] | None = None, + ) -> pl.DataFrame: + """分钟K → 内部契约(symbol/datetime 北京墙钟 naive/ohlcv/volume 手/amount 元)。 + + period=1m count 模式单标的拉取(实测 240 根/日, count≤9600 正常); + freq 仅支持 1m(上游有 5m 等档位但项目契约按 1m 路由), 其余空手而归还上层回退; + ETF/指数分钟未实测覆盖, asset_type 仅接受 stock。 + """ + chunks = list(self.iter_minute( + symbols, start_time=start_time, end_time=end_time, + asset_type=asset_type, freq=freq, on_chunk_done=on_chunk_done, + )) + parts = [df for df in chunks if not df.is_empty()] + return pl.concat(parts, how="diagonal_relaxed") if parts else pl.DataFrame(schema=_MINUTE_SCHEMA) + + def iter_minute( + self, + symbols: list[str], + start_time: datetime | None, + end_time: datetime | None, + asset_type: str = "stock", + freq: str = "1m", + on_chunk_done: Callable[[int, int], None] | None = None, + ) -> Iterator[pl.DataFrame]: + if not symbols or asset_type != "stock" or freq != "1m": + if on_chunk_done: + on_chunk_done(0, 0) + return + client = self._get_client() + end_dt = end_time or datetime.now() + count = _minute_count_for_window(start_time, end_dt) + for i, symbol in enumerate(symbols): + try: + raw = client.his_dats(symbol, end_dt.date(), count, period="1m") + except TdxQuantError as e: + logger.warning("TdxQuant 分钟K %s 拉取失败: %s", symbol, e) + raw = {} + rows = _minute_rows(symbol, raw, start_time, end_dt) + yield pl.DataFrame(rows, schema=_MINUTE_SCHEMA) + if on_chunk_done: + on_chunk_done(i + 1, len(symbols)) + time.sleep(_MINUTE_INTERVAL_S) + + def get_intraday_batch( + self, symbols: list[str], count: int = 300, asset_type: str = "stock", + ) -> pl.DataFrame: + """全量分钟修复轮: 当日窗口逐标的拉取, 只保留今日 1m 行。 + + count 模式尾部天然带前一日余量, 按 today_only 过滤; 单标的请求 + 240+count 余量根。全市场一轮 5579 次调用(含节流约 10 分钟量级), + 由 minute_refresh 以修复轮节奏调度。 + """ + if not symbols or asset_type != "stock": + return pl.DataFrame(schema=_MINUTE_SCHEMA) + client = self._get_client() + today = datetime.now().date() + fetch = max(_MINUTE_BARS_PER_DAY, min(count, 480)) + _MINUTE_BARS_PER_DAY + out: list[pl.DataFrame] = [] + for symbol in symbols: + try: + raw = client.his_dats(symbol, today, fetch, period="1m") + except TdxQuantError as e: + logger.warning("TdxQuant 修复轮 %s 拉取失败: %s", symbol, e) + continue + rows = _minute_rows(symbol, raw, None, None, today_only=today) + if rows: + out.append(pl.DataFrame(rows, schema=_MINUTE_SCHEMA)) + time.sleep(_MINUTE_INTERVAL_S) + return pl.concat(out, how="diagonal_relaxed") if out else pl.DataFrame(schema=_MINUTE_SCHEMA) + + def get_intraday_latest(self, symbols: list[str] | None = None, count: int = 3) -> pl.DataFrame: + """全量分钟稳态增量轮: 每只标的最新 count 根 1m(symbols 缺省 = 全市场股票池)。 + + 与修复轮同为逐标的 count 调用(无批量端点), 差别在根数少、负载轻; + 失败标的跳过, 空轮由调用方按空处理。 + """ + client = self._get_client() + targets = list(symbols) if symbols else [] + if not targets: + try: + targets = client.stock_universe() + except TdxQuantError as e: + logger.warning("TdxQuant 增量轮股票池枚举失败: %s", e) + return pl.DataFrame(schema=_MINUTE_SCHEMA) + count = max(1, min(int(count), 30)) + today = datetime.now().date() + out: list[pl.DataFrame] = [] + for j, symbol in enumerate(targets): + try: + raw = client.his_dats(symbol, today, count, period="1m") + except TdxQuantError as e: + logger.warning("TdxQuant 增量轮 %s 拉取失败: %s", symbol, e) + continue + rows = _minute_rows(symbol, raw, None, None) + if rows: + out.append(pl.DataFrame(rows, schema=_MINUTE_SCHEMA)) + if j % 1000 == 999: + logger.info("TdxQuant 增量轮进度: %d/%d", j + 1, len(targets)) + time.sleep(_MINUTE_INTERVAL_S) + return pl.concat(out, how="diagonal_relaxed") if out else pl.DataFrame(schema=_MINUTE_SCHEMA) + + # ---- depth5 ---- + def get_depth_batch(self, symbols: list[str]) -> dict[str, dict]: + """五档盘口 → {symbol: 标准盘口字典}。数量单位手(透传), 价格转 float。""" + client = self._get_client() + out: dict[str, dict] = {} + fallback_ms = int(time.time() * 1000) + for i in range(0, len(symbols), _SNAPSHOT_BATCH): + batch = symbols[i : i + _SNAPSHOT_BATCH] + try: + rows = client.snapshot_batch(batch) + except TdxQuantError as e: + logger.warning("TdxQuant 五档批次失败(第 %d 批): %s", i // _SNAPSHOT_BATCH + 1, e) + continue + for code, row in rows.items(): + entry = self._depth_entry(row, fallback_ms) + if entry is not None: + out[code] = entry + return out + + def _depth_entry(self, row: dict, fallback_ms: int) -> dict | None: + def _prices(name: str) -> list[float] | None: + values = row.get(name) + if not values: + return None + return [_to_float(v) for v in values] + + def _volumes(name: str) -> list[int] | None: + values = row.get(name) + if not values: + return None + out: list[int] = [] + for v in values: + f = _to_float(v) + out.append(math.floor(f) if f is not None else 0) + return out + + bid_prices = _prices("Buyp") + ask_prices = _prices("Sellp") + if not bid_prices or not ask_prices: + return None + return { + "bid_prices": bid_prices, + "bid_volumes": _volumes("Buyv") or [], + "ask_prices": ask_prices, + "ask_volumes": _volumes("Sellv") or [], + "timestamp": _snapshot_ms(row.get("RefreshTime"), fallback_ms), + } + + # ---- 设置页试拉 ---- + def test_dataset(self, dataset: str, symbols: list[str] | None = None) -> dict: + if dataset in ("daily", "adj_factor"): + syms = [s for s in (symbols or [])][:3] or ["000001.SZ"] + try: + if dataset == "daily": + df = self.get_daily(syms, datetime.now() - timedelta(days=30), datetime.now()) + else: + df = self.get_adj_factors(syms, datetime.now() - timedelta(days=365), datetime.now()) + except TdxQuantError as e: + return {"provider": self.name, "dataset": dataset, "rows": 0, "error": str(e)} + head = df.head(5).to_dicts() + for row in head: # date/datetime → ISO 字符串, 保证 JSON 可序列化 + for k, v in list(row.items()): + if isinstance(v, (date, datetime)): + row[k] = v.isoformat() + return {"provider": self.name, "dataset": dataset, "rows": df.height, + "columns": df.columns, "preview": head} + if dataset in ("minute", "full_minute"): + syms = [s for s in (symbols or [])][:3] or ["600519.SH"] + try: + end = datetime.now() + df = (self.get_intraday_batch(syms) if dataset == "full_minute" + else self.get_minute(syms, end - timedelta(days=3), end)) + except TdxQuantError as e: + return {"provider": self.name, "dataset": dataset, "rows": 0, "error": str(e)} + head = df.head(5).to_dicts() + for row in head: + for k, v in list(row.items()): + if isinstance(v, (date, datetime)): + row[k] = v.isoformat() + return {"provider": self.name, "dataset": dataset, "rows": df.height, + "columns": df.columns, "preview": head} + if dataset in ("realtime", "depth5"): + syms = [s for s in (symbols or [])][:5] or ["600519.SH", "000001.SZ"] + try: + rows = self._get_client().snapshot_batch(syms) + except TdxQuantError as e: + return {"provider": self.name, "dataset": dataset, "rows": 0, "error": str(e)} + fallback_ms = int(time.time() * 1000) + if dataset == "realtime": + head = [r for r in (_map_snapshot_row(c, row, fallback_ms) for c, row in rows.items()) if r] + return {"provider": self.name, "dataset": dataset, "rows": len(rows), + "columns": list(head[0].keys()) if head else list(SNAPSHOT_FIELDS), + "preview": head[:5]} + depths = {c: d for c, d in ((c, self._depth_entry(row, fallback_ms)) for c, row in rows.items()) if d} + return {"provider": self.name, "dataset": dataset, "rows": len(depths), + "columns": ["symbol", "bid_prices", "bid_volumes", "ask_prices", "ask_volumes", "timestamp"], + "preview": [{"symbol": c, **d} for c, d in list(depths.items())[:5]]} + return {"provider": self.name, "dataset": dataset, "rows": 0, + "error": f"tdx 插件未接入 {dataset} 数据集(自动回退 TickFlow)"} + + +__all__ = [ + "API_KEY_ENV", + "TdxQuantProvider", + "availability", + "get_api_key", + "probe_api_key", +] diff --git a/backend/app/secrets_store.py b/backend/app/secrets_store.py index 5eee014c..bb81da97 100644 --- a/backend/app/secrets_store.py +++ b/backend/app/secrets_store.py @@ -30,6 +30,16 @@ def _path() -> Path: return p +def file_path() -> Path: + """secrets.json 绝对路径 (设置页展示用)。 + + 该文件是 AI 与数据源 Key 的统一配置源, 支持用户用编辑器直接修改; + load() 按 mtime 签名缓存失效, 外部保存后下一次请求即读取最新内容, + 无需重启。 + """ + return _path() + + def load() -> dict: """读取 secrets.json (带 mtime 签名缓存)。返回深拷贝, 调用方可自由修改。""" global _cache, _cache_sig diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 6a5fdcb6..a8841b71 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -26,6 +26,10 @@ CODEX_CLI_PROVIDER = "codex_cli" CODEX_DEFAULT_COMMAND = "codex" CODEX_SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} OPENAI_DEFAULT_REASONING_EFFORT = "high" +# OpenAI 系两种请求协议: chat.completions (兼容面最广) 与 Responses (官方新协议)。 +CHAT_COMPLETIONS_PROTOCOL = "chat_completions" +RESPONSES_PROTOCOL = "responses" +_SUPPORTED_PROTOCOLS = {CHAT_COMPLETIONS_PROTOCOL, RESPONSES_PROTOCOL} _CODEX_ENV_ALLOWLIST = ( "PATH", @@ -124,6 +128,24 @@ def current_ai_provider() -> str: return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER +def current_ai_protocol() -> str: + """当前 OpenAI 系请求协议: chat_completions (默认) | responses。 + + 仅对 openai/openai_compat 两种 provider 有意义; Codex CLI 走本地子进程, + 恒为 chat_completions。非法存储值一律回退默认, 不抛错。 + """ + if is_codex_cli_provider(): + return CHAT_COMPLETIONS_PROTOCOL + value = str( + secrets_store.get_ai_config("ai_protocol", settings.ai_protocol) or "" + ).strip().lower() + return value if value in _SUPPORTED_PROTOCOLS else CHAT_COMPLETIONS_PROTOCOL + + +def is_responses_protocol() -> bool: + return current_ai_protocol() == RESPONSES_PROTOCOL + + def current_openai_model() -> str: return secrets_store.get_ai_config("ai_model", settings.ai_model) @@ -318,6 +340,15 @@ async def generate_ai_text( _check_input_budget(messages, max_tokens=max_tokens) if is_codex_cli_provider(): return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0)) + if is_responses_protocol(): + from app.services import ai_responses + + return await ai_responses.run_text( + messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ) return await _run_openai_once( messages, temperature=temperature, @@ -356,13 +387,24 @@ async def generate_ai_text_with_tools( for _ in range(max_rounds): _check_input_budget(req_messages, max_tokens=max_tokens) - message = await _run_openai_message_once( - req_messages, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout, - tools=tool_schemas, - ) + if is_responses_protocol(): + from app.services import ai_responses + + message = await ai_responses.run_message_once( + req_messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + tools=tool_schemas, + ) + else: + message = await _run_openai_message_once( + req_messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + tools=tool_schemas, + ) if message is None: return req_messages @@ -446,6 +488,18 @@ async def stream_ai_text( yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0)) return + if is_responses_protocol(): + from app.services import ai_responses + + async for chunk in ai_responses.stream_text( + messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ): + yield chunk + return + async for chunk in _stream_openai( messages, temperature=temperature, @@ -631,6 +685,57 @@ def _openai_client(api_key: str, timeout: float): ) +async def probe_ai_config( + *, + provider: str, + api_key: str, + base_url: str, + model: str, + protocol: str = "", + timeout: float = 20.0, +) -> str: + """草稿配置一次性连通性探测 — 不读写任何配置状态。 + + 「测试」必须与「保存」解耦: 旧实现先保存草稿再测试, 测试失败时坏配置 + 已经落盘并被状态栏展示为已连接。本函数用表单值直接建临时客户端发一条 + 最小消息, 成功与否都不触碰 secrets。 + + 与正式调用共用 base URL 规范化与协议分支 (chat.completions / responses); + 不传输出上限/温度等可选参数, 规避 max_tokens 与 max_completion_tokens 等 + 模型差异。返回模型可见文本 (推理模型可能为空串 — 200 即证明鉴权/端点/ + 模型可用); 传输层异常原样抛出, 由调用方格式化后回报。 + """ + if provider == CODEX_CLI_PROVIDER: + if not codex_cli_available(): + raise RuntimeError("Codex CLI 不可用: 未找到可运行的 codex 命令") + return "" + if not api_key: + raise RuntimeError("未配置 API Key") + if not base_url: + raise ValueError("请先填写 API 地址") + if not model: + raise ValueError("请先填写模型名称") + + from openai import AsyncOpenAI + + user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent + client = AsyncOpenAI( + api_key=api_key, + base_url=normalize_openai_base_url(base_url), + timeout=timeout, + max_retries=0, + default_headers={"User-Agent": user_agent}, + ) + prompt = [{"role": "user", "content": "Reply exactly: OK"}] + if protocol == RESPONSES_PROTOCOL: + resp = await client.responses.create(model=model, input=prompt) + return str(getattr(resp, "output_text", "") or "") + resp = await client.chat.completions.create(model=model, messages=prompt) + if not getattr(resp, "choices", None): + return "" + return str(resp.choices[0].message.content or "") + + # 不同模型可能拒绝 temperature 或 reasoning_effort。这里不靠模型名猜测, # 只在 400 明确指出对应参数时移除该参数并重试; 每个参数最多移除一次。 _TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed") diff --git a/backend/app/services/ai_responses.py b/backend/app/services/ai_responses.py new file mode 100644 index 00000000..0b28629f --- /dev/null +++ b/backend/app/services/ai_responses.py @@ -0,0 +1,516 @@ +"""OpenAI Responses API 协议适配 — 与 chat.completions 路径平行的第二协议。 + +设计约束(与 ai_provider._stream_openai / _iter_openai_text 对齐): +- kwargs 构建与 400 自适应重试同语义, 仅参数名不同: + max_tokens→max_output_tokens, reasoning_effort→reasoning={"effort"}。 +- 事件流归一化沿用 chat 版的防御: 长度截断 / 仅推理无正文 / 空响应都显式报错。 +- 工具调用走 function_call output item + function_call_output 回填, + 对外仍暴露 chat 形态 (role:tool / tool_calls), 上层 (generate_ai_text_with_tools, + assistant chat_service) 零改动; 转换在本模块进出两端完成。 +- 本模块不做 provider 判定, 由 ai_provider.is_responses_protocol() 分流进入。 + +配置读取全部经 ai_provider 模块限定调用(非 from-import 名字绑定), +保证测试可 monkeypatch ai_provider.current_ai_provider 等。 +""" +from __future__ import annotations + +import asyncio +import inspect +from collections.abc import AsyncIterator, Sequence +from types import SimpleNamespace +from typing import Any + +from app.services import ai_provider +from app.services.ai_provider import Message + +# Responses 的 incomplete_details.reason 与 status 语义对齐 chat 的 finish_reason。 +_LENGTH_INCOMPLETE_REASONS = {"length", "max_tokens", "max_output_tokens"} +# 推理事件前缀: reasoning_summary_text.delta / reasoning_text.delta 等。 +_REASONING_EVENT_PREFIXES = ( + "response.reasoning_summary", + "response.reasoning_text", + "response.reasoning.", +) + + +def responses_client(timeout: float): + """复用核心的 AsyncOpenAI 客户端 (Key/Base URL/UA/重试策略一处维护)。""" + ai_key = ai_provider.secrets_store.get_ai_key() + if not ai_key: + raise RuntimeError("AI API Key 未配置, 请在设置页配置") + return ai_provider._openai_client(ai_key, timeout) + + +# --------------------------------------------------------------------------- +# 消息与工具的双向转换 (chat 形态 ⇄ Responses input/tools) +# --------------------------------------------------------------------------- + + +def chat_input(messages: Sequence[Message]) -> list[dict[str, Any]]: + """chat messages → Responses input items。 + + role:tool → function_call_output; assistant.tool_calls → function_call items; + 其余 (system/user/assistant 纯文本) 走 easy-input message 形态。 + Gemini 兼容层的 extra_content 在 Responses 协议下无意义, 静默丢弃。 + """ + items: list[dict[str, Any]] = [] + for m in messages: + role = str(m.get("role") or "user") + if role == "tool": + items.append({ + "type": "function_call_output", + "call_id": str(m.get("tool_call_id") or ""), + "output": str(m.get("content") or ""), + }) + continue + tool_calls = m.get("tool_calls") + if role == "assistant" and tool_calls: + if m.get("content"): + items.append({"role": "assistant", "content": str(m["content"])}) + for tc in tool_calls: + fn = tc.get("function") or {} + items.append({ + "type": "function_call", + "call_id": str(tc.get("id") or ""), + "name": str(fn.get("name") or ""), + "arguments": str(fn.get("arguments") or ""), + }) + continue + items.append({"role": role, "content": str(m.get("content") or "")}) + return items + + +def responses_tools(tools: Sequence[dict]) -> list[dict[str, Any]]: + """chat 工具 schema → Responses 扁平工具 schema。 + + chat: {"type":"function","function":{name,description,parameters}} + responses: {"type":"function","name","description","parameters"} + 兼容已是扁平形态的输入(部分网关直接吐扁平 schema)。 + """ + out: list[dict[str, Any]] = [] + for t in tools: + if not isinstance(t, dict): + continue + fn = t.get("function") + if not isinstance(fn, dict): + fn = t if t.get("type") == "function" else None + if not fn: + continue + item: dict[str, Any] = { + "type": "function", + "name": str(fn.get("name") or ""), + } + if fn.get("description"): + item["description"] = str(fn["description"]) + if fn.get("parameters") is not None: + item["parameters"] = fn["parameters"] + if item["name"]: + out.append(item) + return out + + +def responses_kwargs( + *, + temperature: float | None, + max_tokens: int | None, + tools: Sequence[dict] | None = None, +) -> dict[str, Any]: + """构建 responses.create kwargs (与 _openai_kwargs 同语义, 参数名映射)。""" + kwargs: dict[str, Any] = {} + if max_tokens is not None: + kwargs["max_output_tokens"] = max_tokens + if temperature is not None: + kwargs["temperature"] = temperature + if ai_provider.current_ai_provider() == ai_provider.OPENAI_PROVIDER: + effort = ai_provider.current_openai_reasoning_effort() + if effort: + kwargs["reasoning"] = {"effort": effort} + if tools: + kwargs["tools"] = responses_tools(tools) + return kwargs + + +def _is_reasoning_rejected(exc: Exception) -> bool: + """True if the upstream 400 specifically rejects the reasoning param.""" + if getattr(exc, "status_code", None) != 400: + return False + text = (ai_provider._openai_error_detail(exc) or str(exc)).lower() + param = ai_provider._openai_error_param(exc) + return param in ("reasoning", "reasoning.effort", "effort") or "reasoning" in text + + +def responses_retry_kwargs(exc: Exception, kwargs: dict[str, Any]) -> dict[str, Any] | None: + """Remove one explicitly rejected optional argument for a bounded retry.""" + retry = dict(kwargs) + if "temperature" in retry and ai_provider._is_temperature_rejected(exc): + retry.pop("temperature") + return retry + if "reasoning" in retry and _is_reasoning_rejected(exc): + retry.pop("reasoning") + return retry + return None + + +# --------------------------------------------------------------------------- +# 终态检查 (completed/failed/incomplete → chat 版等价报错) +# --------------------------------------------------------------------------- + + +def _incomplete_reason(resp) -> str: + details = getattr(resp, "incomplete_details", None) + reason = getattr(details, "reason", None) if details is not None else None + if reason: + return str(reason) + # 网关兼容: incomplete_details 为 dict 的情况 + if isinstance(details, dict): + return str(details.get("reason") or "") + return "" + + +def _response_error_detail(resp) -> str: + err = getattr(resp, "error", None) + if err is None: + return "上游未返回错误详情" + message = getattr(err, "message", None) + if message is None and isinstance(err, dict): + message = err.get("message") + return ai_provider._compact_error_text(str(message or err)) or "上游未返回错误详情" + + +def _check_terminal_response(resp, *, content_seen: bool, reasoning_seen: bool) -> None: + """completed/failed/incomplete 终态的防御性检查, 报错文案与 chat 版一致。""" + status = str(getattr(resp, "status", "") or "") + if status == "failed": + raise RuntimeError(f"AI 服务请求失败: {_response_error_detail(resp)}") + if status != "incomplete": + return + reason = _incomplete_reason(resp) + if reason not in _LENGTH_INCOMPLETE_REASONS: + return + if reasoning_seen and not content_seen: + raise RuntimeError( + "AI 推理达到输出长度上限, 未生成正文; 请提高输出 Token 上限或改用非推理模型" + ) + raise RuntimeError("AI 输出达到长度上限, 内容不完整; 请提高输出 Token 上限后重试") + + +def _raise_no_content(reasoning_seen: bool) -> None: + if reasoning_seen: + raise RuntimeError("AI 仅返回推理内容, 未生成正文; 请检查模型配置或改用非推理模型") + raise RuntimeError("AI 服务未返回正文内容; 请检查模型配置或稍后重试") + + +# --------------------------------------------------------------------------- +# 非流式: 纯文本 / 工具循环单轮 +# --------------------------------------------------------------------------- + + +def _response_text(resp) -> str: + """聚合 Response 的全部 output_text (便利属性缺失时逐 item 提取)。""" + text = getattr(resp, "output_text", None) + if isinstance(text, str) and text: + return text + parts: list[str] = [] + for item in getattr(resp, "output", None) or []: + if str(getattr(item, "type", "") or "") != "message": + continue + for part in getattr(item, "content", None) or []: + if str(getattr(part, "type", "") or "") in ("output_text", "text"): + value = getattr(part, "text", None) + if value: + parts.append(str(value)) + return "".join(parts) + + +def _response_reasoning_seen(resp) -> bool: + for item in getattr(resp, "output", None) or []: + if str(getattr(item, "type", "") or "").startswith("reasoning"): + return True + return False + + +def _response_calls(resp) -> list[Any]: + """output 里的 function_call item → chat 形态 tool_calls (SimpleNamespace)。""" + calls = [] + for item in getattr(resp, "output", None) or []: + if str(getattr(item, "type", "") or "") != "function_call": + continue + calls.append(SimpleNamespace( + id=str(getattr(item, "call_id", None) or getattr(item, "id", "") or ""), + type="function", + function=SimpleNamespace( + name=str(getattr(item, "name", "") or ""), + arguments=str(getattr(item, "arguments", "") or ""), + ), + )) + return calls + + +async def _create_with_retry(client, *, payload: dict[str, Any], stream: bool): + kwargs = dict(payload) + while True: + try: + return await client.responses.create(**kwargs, stream=stream) + except Exception as exc: + retry = responses_retry_kwargs(exc, kwargs) + if retry is not None: + kwargs = retry + continue + if ai_provider._is_openai_transport_error(exc): + raise RuntimeError(ai_provider._format_openai_error(exc)) from exc + raise + + +async def run_text( + messages: Sequence[Message], + *, + temperature: float | None, + max_tokens: int | None, + timeout: float, +) -> str: + """非流式纯文本 (generate_ai_text 的 Responses 分支)。""" + client = responses_client(timeout) + payload = responses_kwargs(temperature=temperature, max_tokens=max_tokens) + payload["model"] = ai_provider.current_ai_model() + payload["input"] = chat_input(messages) + resp = await _create_with_retry(client, payload=payload, stream=False) + text = _response_text(resp).strip() + _check_terminal_response( + resp, content_seen=bool(text), reasoning_seen=_response_reasoning_seen(resp), + ) + return text + + +async def run_message_once( + messages: Sequence[Message], + *, + temperature: float | None, + max_tokens: int | None, + timeout: float, + tools: Sequence[dict] | None = None, +): + """非流式工具单轮 (generate_ai_text_with_tools 的 Responses 分支)。 + + 返回 chat 形态 message 对象 (content + tool_calls), 核心循环零改动。 + """ + client = responses_client(timeout) + payload = responses_kwargs(temperature=temperature, max_tokens=max_tokens, tools=tools) + payload["model"] = ai_provider.current_ai_model() + payload["input"] = chat_input(messages) + resp = await _create_with_retry(client, payload=payload, stream=False) + text = _response_text(resp) + calls = _response_calls(resp) + if not calls: + # 无工具调用时才做终态/空正文防御; 有调用时由上层执行工具进入下一轮。 + _check_terminal_response( + resp, content_seen=bool(text), reasoning_seen=_response_reasoning_seen(resp), + ) + return SimpleNamespace(content=text, tool_calls=calls) + + +# --------------------------------------------------------------------------- +# 流式: 纯文本 (stream_ai_text 分支) +# --------------------------------------------------------------------------- + + +async def stream_text( + messages: Sequence[Message], + *, + temperature: float | None, + max_tokens: int | None, + timeout: float, +) -> AsyncIterator[str]: + client = responses_client(timeout) + payload = responses_kwargs(temperature=temperature, max_tokens=max_tokens) + payload["model"] = ai_provider.current_ai_model() + payload["input"] = chat_input(messages) + stream = await _create_with_retry(client, payload=payload, stream=True) + try: + async for piece in iter_text(stream): + yield piece + except Exception as exc: + if ai_provider._is_openai_transport_error(exc): + raise RuntimeError(ai_provider._format_openai_error(exc)) from exc + raise + + +def _is_reasoning_event(event_type: str) -> bool: + return event_type.startswith(_REASONING_EVENT_PREFIXES) + + +async def iter_text(stream) -> AsyncIterator[str]: + """Responses 事件流 → 文本增量, 防御与 _iter_openai_text 等价。""" + content_seen = False + reasoning_seen = False + completed = None + + async for event in stream: + event_type = str(getattr(event, "type", "") or "") + if event_type == "response.output_text.delta": + delta = getattr(event, "delta", None) + if delta: + content_seen = True + yield str(delta) + elif event_type == "response.output_text.done" and not content_seen: + # 个别网关只发 done 不发 delta: done 携带全量文本, 兜底产出一次。 + text = getattr(event, "text", None) + if text: + content_seen = True + yield str(text) + elif _is_reasoning_event(event_type): + reasoning_seen = True + elif event_type in ("response.completed", "response.incomplete"): + completed = getattr(event, "response", None) + elif event_type in ("response.failed", "error"): + resp = getattr(event, "response", None) + if resp is not None and getattr(resp, "error", None) is not None: + raise RuntimeError(f"AI 服务请求失败: {_response_error_detail(resp)}") + err = getattr(event, "error", None) + if err is not None: + raise RuntimeError(f"AI 服务请求失败: {_response_error_detail(SimpleNamespace(error=err))}") + + if completed is not None: + _check_terminal_response(completed, content_seen=content_seen, reasoning_seen=reasoning_seen) + if not content_seen: + _raise_no_content(reasoning_seen) + + +# --------------------------------------------------------------------------- +# 流式: 工具轮 (assistant stream_openai_round 分支) +# --------------------------------------------------------------------------- + + +def _event_item_id(event) -> str: + """function_call 累积的 key: item_id 优先, 缺失时回退 output_index。""" + item_id = getattr(event, "item_id", None) + if item_id: + return str(item_id) + item = getattr(event, "item", None) + if item is not None: + item_id = getattr(item, "id", None) + if item_id: + return str(item_id) + return f"#{getattr(event, 'output_index', 0)}" + + +def _completed_calls(resp) -> list[dict[str, Any]]: + """completed.response.output 是权威工具调用列表 (顺序与完整性优于分片累积)。""" + calls = [] + for item in getattr(resp, "output", None) or []: + if str(getattr(item, "type", "") or "") != "function_call": + continue + calls.append({ + "id": str(getattr(item, "call_id", None) or getattr(item, "id", "") or ""), + "name": str(getattr(item, "name", "") or ""), + "arguments": str(getattr(item, "arguments", "") or ""), + }) + return calls + + +async def stream_round( + messages: Sequence[dict[str, Any]], + tools: Sequence[dict[str, Any]], + *, + temperature: float | None = 0.3, + timeout: float = 240.0, +) -> AsyncIterator[dict[str, Any]]: + """一轮 Responses 流式补全, 输出与 assistant streaming.stream_openai_round 同构。""" + client = responses_client(timeout) + payload = responses_kwargs(temperature=temperature, max_tokens=None, tools=tools) + payload["model"] = ai_provider.current_ai_model() + payload["input"] = chat_input(messages) + stream = await _create_with_retry(client, payload=payload, stream=True) + + content_seen = False + reasoning_seen = False + calls: dict[str, dict[str, Any]] = {} + ordered: list[dict[str, Any]] = [] + final_calls: list[dict[str, Any]] | None = None + completed = None + + async for event in stream: + event_type = str(getattr(event, "type", "") or "") + if event_type == "response.output_text.delta": + delta = getattr(event, "delta", None) + if delta: + content_seen = True + yield {"type": "text", "delta": str(delta)} + elif event_type == "response.output_text.done" and not content_seen: + text = getattr(event, "text", None) + if text: + content_seen = True + yield {"type": "text", "delta": str(text)} + elif _is_reasoning_event(event_type): + reasoning_seen = True + elif event_type == "response.output_item.added": + item = getattr(event, "item", None) + if item is not None and str(getattr(item, "type", "") or "") == "function_call": + key = _event_item_id(event) + entry = {"id": "", "name": "", "arguments": ""} + calls[key] = entry + ordered.append(entry) + call_id = getattr(item, "call_id", None) + if call_id: + entry["id"] = str(call_id) + if getattr(item, "name", None): + entry["name"] = str(item.name) + elif event_type == "response.function_call_arguments.delta": + key = _event_item_id(event) + entry = calls.get(key) + if entry is None: + entry = {"id": "", "name": "", "arguments": ""} + calls[key] = entry + ordered.append(entry) + delta = getattr(event, "delta", None) + if delta: + entry["arguments"] += str(delta) + elif event_type == "response.output_item.done": + item = getattr(event, "item", None) + if item is not None and str(getattr(item, "type", "") or "") == "function_call": + # done item 是权威值, 整条覆盖累积分片 (网关分片乱序/缺失时兜底)。 + key = _event_item_id(event) + entry = calls.get(key) + if entry is None: + entry = {"id": "", "name": "", "arguments": ""} + calls[key] = entry + ordered.append(entry) + call_id = getattr(item, "call_id", None) + if call_id: + entry["id"] = str(call_id) + if getattr(item, "name", None): + entry["name"] = str(item.name) + if getattr(item, "arguments", None): + entry["arguments"] = str(item.arguments) + elif event_type in ("response.completed", "response.incomplete"): + completed = getattr(event, "response", None) + if completed is not None: + final_calls = _completed_calls(completed) + elif event_type in ("response.failed", "error"): + resp = getattr(event, "response", None) + err = getattr(event, "error", None) + if err is None and resp is not None: + err = getattr(resp, "error", None) + raise RuntimeError( + f"AI 服务请求失败: {_response_error_detail(SimpleNamespace(error=err))}" + ) + + # 让 SDK 流对象尽快释放(部分兼容网关要求显式 close); SDK 2.x 的 + # AsyncStream.close 是协程, 必须区分同步/异步两种签名。 + close = getattr(stream, "close", None) + if close is not None: + if inspect.iscoroutinefunction(close): + await close() + else: + await asyncio.to_thread(close) + + if final_calls is None: + final_calls = ordered + if final_calls: + yield {"type": "round_end", "tool_calls": final_calls, "finish_reason": "tool_calls"} + return + + if completed is not None: + _check_terminal_response(completed, content_seen=content_seen, reasoning_seen=reasoning_seen) + if not content_seen: + _raise_no_content(reasoning_seen) + yield {"type": "round_end", "tool_calls": [], "finish_reason": "stop"} diff --git a/backend/app/services/depth_service.py b/backend/app/services/depth_service.py index a65e9d7e..a6ac606c 100644 --- a/backend/app/services/depth_service.py +++ b/backend/app/services/depth_service.py @@ -101,7 +101,7 @@ class DepthService: def boot_check(self) -> None: """启动补跑: 当天 depth5 文件不存在则 finalize 一次; 已存在则恢复内存缓存。""" if not self._has_capability(): - logger.info("depth sealed: 无 DEPTH5_BATCH 能力, 跳过启动补跑") + logger.info("depth sealed: %s 不可用, 跳过启动补跑", self._capability_label()) return today = cn_today() if self._persisted_for_date(today): @@ -190,7 +190,7 @@ class DepthService: 返回 {"ok": bool, "count": int, "msg": str} """ if not self._has_capability(): - return {"ok": False, "count": 0, "msg": "无五档盘口能力(需 Pro+)"} + return {"ok": False, "count": 0, "msg": f"{self._capability_label()} 不可用"} try: self._fetch_and_seal(persist=True) # 落盘, 刷新页面不丢 with self._lock: @@ -631,9 +631,21 @@ class DepthService: # ================================================================ def _has_capability(self) -> bool: - capset = self._get_capset() - from app.tickflow.capabilities import Cap - return capset.has(Cap.DEPTH5_BATCH) + """五档能力是否可用 — 以【生效源】为准, 与取数路径 (_call_depth_batch) 同口径。 + + 不能只看 TickFlow 档位: 五档路由到声明了 depth5 的插件 (如 tdxquant) 时, + TickFlow 档位无关; 反之路由到 TickFlow 但档位不足时, 插件声明不该算数。 + 本轮修复前此处只查 capset, 导致「五档已切到插件但启动补跑被跳过」。 + """ + from app.data_providers.capabilities import capability_gate + ok, _ = capability_gate("depth5") + return ok + + def _capability_label(self) -> str: + """门禁日志标签: "五档盘口 ← fuyao" / "…(档位不足)", 便于判断该切哪个源。""" + from app.data_providers.capabilities import capability_gate + _, label = capability_gate("depth5") + return label def _get_capset(self): """获取当前 capset(优先 app.state, 回退 detect)。""" diff --git a/backend/app/services/emotion_score.py b/backend/app/services/emotion_score.py new file mode 100644 index 00000000..0cd38d53 --- /dev/null +++ b/backend/app/services/emotion_score.py @@ -0,0 +1,243 @@ +"""情绪评分 — 维度 / 权重 / 区间的单一权威定义 + 可配置计算。 + +口径即配置: 6 个维度各自由若干「指标 → 归一化区间 → 加权」子项合成, 维度再按 +权重加权平均得到总分; 档位阈值把分数映射为 强势/偏暖/震荡/偏冷/冰点。 + +设计约束: + - DEFAULT_CONFIG 复现历史硬编码口径, 逐位一致 (所有子项权重和 = 1, 维度等权); + - 子项权重在维度内**按权重和归一化**, 因此用户改动权重不会因和不为 1 而畸变; + - 用户配置与默认配置做逐字段合并, 新增维度/子项时老配置自动补默认值, 不会 + 因缺字段而 KeyError; + - 指标值缺失 (None) 的维度取中性 50, 与历史实现「无主线数据取 50」一致。 + +消费方: app.services.market_overview_builder (看板总览 + 复盘情绪分)。 +""" +from __future__ import annotations + +import math + +# 档位: (阈值键, 展示名, 颜色语义) — 从高到低匹配 +LABELS: tuple[tuple[str, str], ...] = ( + ("strong", "强势"), + ("warm", "偏暖"), + ("neutral", "震荡"), + ("cold", "偏冷"), +) +FALLBACK_LABEL = "冰点" +NEUTRAL = 50 + + +def _default_config() -> dict: + """出厂口径 — 与改造前的硬编码计算逐位一致。""" + + def dim(label: str, terms: list[dict], **extra) -> dict: + return {"label": label, "weight": 1.0, "terms": terms, **extra} + + def term(key: str, label: str, weight: float, low: float, high: float, unit: str = "") -> dict: + return {"key": key, "label": label, "weight": weight, "low": low, "high": high, "unit": unit} + + return { + "dimensions": { + "index": dim("指数", [ + term("avg_index_pct", "指数平均涨跌", 1.0, -2.5, 2.5, "%"), + ]), + "profit": dim("赚钱", [ + term("up_pct", "上涨占比", 0.45, 20, 80, "%"), + term("avg_pct", "平均涨跌", 0.25, -0.02, 0.02), + term("median_pct", "中位涨跌", 0.20, -0.02, 0.02), + term("strong_diff_pct", "强涨-强跌占比差", 0.10, -8, 8, "%"), + ]), + "money": dim("量能", [ + term("avg_vol_ratio", "平均量比", 0.70, 0.6, 1.8), + term("high_vol_pct", "放量股占比", 0.30, 2, 12, "%"), + ]), + "speculation": dim("投机", [ + term("limit_up", "涨停家数", 0.25, 5, 90, "家"), + term("seal_rate", "封板率", 0.35, 30, 85, "%"), + term("max_boards", "最高连板", 0.25, 1, 8, "板"), + term("tier2_count", "2板及以上", 0.15, 0, 30, "家"), + ]), + # 抗跌为逆向维度: 下跌越多分越低, 故 invert + "resilience": dim("抗跌", [ + term("down_pct", "下跌占比", 0.55, 20, 80, "%"), + term("strong_down_pct", "强跌占比", 0.45, 1, 12, "%"), + ], invert=True), + "mainline": dim("主线", [ + term("mainline_avg", "主线平均涨跌", 0.65, -0.005, 0.03), + term("mainline_cover_pct", "主线覆盖率", 0.35, 1, 12, "%"), + ]), + }, + "thresholds": {"strong": 70, "warm": 55, "neutral": 45, "cold": 30}, + } + + +DEFAULT_CONFIG: dict = _default_config() + +# 维度展示顺序 = 雷达图顶点顺序 = 配置面板行序 (三者共用, 避免各处再排一遍) +DIMENSION_ORDER: tuple[str, ...] = tuple(DEFAULT_CONFIG["dimensions"]) + + +def _finite(value) -> float | None: + if value is None: + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + return v if math.isfinite(v) else None + + +def score_of(value, low: float, high: float) -> int: + """指标 → 0..100 归一化分。与历史 `_score` 完全同语义 (含非有限输入取中性)。""" + if high <= low: + return NEUTRAL + v = _finite(value) + if v is None: + return NEUTRAL + return max(0, min(100, round((v - low) / (high - low) * 100))) + + +def merge_config(raw: dict | None) -> dict: + """用户配置 → 完整配置 (逐字段合并, 缺项补默认)。 + + 合并粒度到 term: 用户改过的权重/区间生效, 未提及的字段沿用出厂值。 + 维度/子项数量与 key 以出厂定义为准 (不支持增删指标 —— 指标值由计算方提供, + 凭空新增的 key 取不到数)。 + """ + merged = _default_config() + if not isinstance(raw, dict): + return merged + + raw_dims = raw.get("dimensions") + if isinstance(raw_dims, dict): + for key, dim in merged["dimensions"].items(): + user_dim = raw_dims.get(key) + if not isinstance(user_dim, dict): + continue + weight = _finite(user_dim.get("weight")) + if weight is not None and weight >= 0: + dim["weight"] = weight + user_terms = user_dim.get("terms") + if not isinstance(user_terms, list): + continue + by_key = {t["key"]: t for t in user_terms if isinstance(t, dict)} + for term in dim["terms"]: + user_term = by_key.get(term["key"]) + if not isinstance(user_term, dict): + continue + for field in ("weight", "low", "high"): + val = _finite(user_term.get(field)) + if val is not None: + term[field] = val + + raw_th = raw.get("thresholds") + if isinstance(raw_th, dict): + for key in merged["thresholds"]: + val = _finite(raw_th.get(key)) + if val is not None: + merged["thresholds"][key] = val + _normalize_thresholds(merged["thresholds"]) + return merged + + +def _normalize_thresholds(th: dict) -> None: + """阈值夹到 0..100 并保证降序 (强≥暖≥震荡≥冷)。 + + 用户可能把 强势 填得比 偏暖 还低, 那样档位区间为空、标签永远错。按数值重排 + 回填, 既保留用户意图的相对关系, 也保证映射单调。 + """ + for key in th: + val = _finite(th.get(key)) + th[key] = NEUTRAL if val is None else max(0.0, min(100.0, val)) + values = sorted((th[k] for k in th), reverse=True) + for (key, _), val in zip(LABELS, values): + th[key] = val + + +def label_of(score: float, thresholds: dict) -> str: + for key, name in LABELS: + if score >= thresholds.get(key, 0): + return name + return FALLBACK_LABEL + + +def dimension_value(dim: dict, metrics: dict) -> int: + """单个维度 → 0..100。子项权重和归一化; 全部子项缺数时取中性。""" + total_weight = 0.0 + acc = 0.0 + for term in dim["terms"]: + if term["key"] not in metrics: + continue # 该指标本次不可用 → 跳过, 不当作 0 + value = score_of(metrics[term["key"]], term["low"], term["high"]) + weight = max(0.0, float(term.get("weight") or 0)) + if weight <= 0: + continue + acc += value * weight + total_weight += weight + if total_weight <= 0: + return NEUTRAL + # 权重和已是 1 时不做除法: 出厂口径的 0.45+0.25+0.20+0.10 在浮点下是 + # 0.9999999999999999, 除一趟会把恰好落在 .5 的值推过舍入边界, 使默认结果 + # 与历史实现差 1 分。改过权重时才归一化 (此时结果本就该变)。 + weighted = acc if abs(total_weight - 1.0) < 1e-9 else acc / total_weight + if dim.get("invert"): + # 先取整再取反 (而非 round(100 - x)): 两者在 x 的小数部分恰为 .5 时 + # 结果可差 1, 出厂口径必须与历史实现逐位一致。 + return 100 - round(weighted) + return round(weighted) + + +def compute(metrics: dict, config: dict | None = None) -> dict: + """指标 + 配置 → {score, label, radar, dimensions}。 + + radar 保持历史形状 [{key,label,value}] (前端雷达图与复盘快照依赖), + dimensions 额外带权重与命中分, 供配置面板展示「各维度当前实际得分」。 + """ + cfg = merge_config(config) + radar: list[dict] = [] + dims: list[dict] = [] + for key in DIMENSION_ORDER: + dim = cfg["dimensions"][key] + value = dimension_value(dim, metrics) + radar.append({"key": key, "label": dim["label"], "value": value}) + dims.append({ + "key": key, + "label": dim["label"], + "value": value, + "weight": dim["weight"], + "invert": bool(dim.get("invert")), + }) + + # 等权时直接取整数均值: 权重在均值里本就约掉, 而 Python 3.12+ 的 sum() 对 + # 浮点走补偿求和、对整数是精确累加 —— 走 `int * 1.0` 会引入浮点误差, 让恰好 + # 落在 .5 的总分被舍到另一侧, 与历史实现差 1 分。改过权重时才走加权分支。 + weights = [d["weight"] for d in dims] + if weights and all(abs(w - weights[0]) < 1e-12 for w in weights): + score = round(sum(d["value"] for d in dims) / len(dims)) if dims else NEUTRAL + else: + total_weight = sum(weights) + if total_weight > 0: + score = round(sum(d["value"] * d["weight"] for d in dims) / total_weight) + elif dims: + score = round(sum(d["value"] for d in dims) / len(dims)) + else: + score = NEUTRAL + + return { + "score": score, + "label": label_of(score, cfg["thresholds"]), + "radar": radar, + "dimensions": dims, + "thresholds": cfg["thresholds"], + } + + +def public_config(config: dict | None = None) -> dict: + """完整配置 + 指标元数据 (供前端渲染配置面板)。""" + cfg = merge_config(config) + return { + "config": cfg, + "defaults": DEFAULT_CONFIG, + "dimension_order": list(DIMENSION_ORDER), + "labels": [{"key": k, "name": n} for k, n in LABELS] + [{"key": "fallback", "name": FALLBACK_LABEL}], + } diff --git a/backend/app/services/ext_data.py b/backend/app/services/ext_data.py index 86a41a1d..9c62e42b 100644 --- a/backend/app/services/ext_data.py +++ b/backend/app/services/ext_data.py @@ -53,6 +53,7 @@ class PullConfig: "next_run", "time_window_start", "time_window_end", "date_param", "date_format", "time_field", "auth", "timeout_seconds", "page_param", "page_size_param", "page_size", "page_start", "max_pages", + "fanout", ) def __init__( @@ -82,7 +83,9 @@ class PullConfig: page_size: int = 0, page_start: int = 1, max_pages: int = 20, + fanout: dict | None = None, ) -> None: + self.fanout = fanout or None # 两级拉取配方 (见 fanout.py), None=单请求 self.url = url self.method = method # GET | POST self.headers = headers or {} @@ -163,6 +166,7 @@ class PullConfig: "page_size": self.page_size, "page_start": self.page_start, "max_pages": self.max_pages, + "fanout": self.fanout, } @classmethod @@ -195,6 +199,7 @@ class PullConfig: page_size=d.get("page_size", 0), page_start=d.get("page_start", 1), max_pages=d.get("max_pages", 20), + fanout=d.get("fanout"), ) diff --git a/backend/app/services/ext_fanout.py b/backend/app/services/ext_fanout.py new file mode 100644 index 00000000..d7fda3bb --- /dev/null +++ b/backend/app/services/ext_fanout.py @@ -0,0 +1,155 @@ +"""两级拉取 (fanout) — 「目录 → 逐条目明细」接口的声明式配方。 + +适用面: 数据源只提供「类别清单」+「单类别成员」两个端点, 没有一次返回 +「成员 → 所属类别」全量映射的接口 (同花顺概念/行业、多数板块类 API)。 + +通用拉取是单请求 + 字段名映射, 表达不了这种形态; 与其按 config_id 硬编码 +特例, 不如把「第二级怎么发、结果怎么拼」写进 PullConfig.fanout —— 配置本身 +即配方, 在「数据」页展开扩展表 → 拉取 就能看到全貌, 也便于照抄接入自有源 +(见 docs/custom-data-source.md)。 + +fanout 配方字段: + url 第二级请求地址 (与 pull.url 同走标识头 + auth 鉴权) + param 第二级请求的查询参数名, 取值为第一级条目的 id_field + id_field 第一级条目中作为第二级参数的字段 + label_field 第一级条目中作展示名的字段 (可选, 缺省用 id_field; 仅用于告警) + items_path 第二级响应里成员数组的 dot-path (留空 = 响应本身即数组) + member_id_field 成员对象里取标的代码的字段 + member_label_field 成员对象里取标的名称的字段 (可选) + symbol_col 输出列名: 标的代码 + name_col 输出列名: 标的名称 + value_col 输出列名: 拼接后的类别 + separator 多类别拼接符 (概念用 ";", 行业层级用 "-") + concurrency 并发上限 (可选, 默认 8) + retries 单条目失败重试次数 (可选, 默认 1; 用尽则跳过该条目) + +产出: 行方向由「类别 → 成员」反转为「成员 → 类别清单」, 与单请求路径的 +行结构一致, 下游 rows_to_parquet / 分析页零改动。 +""" +from __future__ import annotations + +import asyncio +import logging +import math +from typing import Any + +from app.services.ext_data import ExtConfig + +logger = logging.getLogger(__name__) + +_DEFAULT_CONCURRENCY = 8 +_DEFAULT_RETRIES = 1 +_REQUIRED_KEYS = ( + "url", "param", "id_field", "items_path", "member_id_field", + "symbol_col", "name_col", "value_col", +) + + +def clean_label(value: Any) -> str: + """类别名归一: 剔除 None / NaN / 字符串 "nan" 等占位值。""" + if value is None or (isinstance(value, float) and not math.isfinite(value)): + return "" + text = str(value).strip() + return "" if text.casefold() in {"nan", "none", "null"} else text + + +def _raise_on_envelope_error(data: Any) -> None: + """常见信封 {code, message, data}: code 非 0 时抛出带服务端文案的错误。""" + if isinstance(data, dict): + code = data.get("code") + if code not in (0, None, "0"): + raise ValueError(f"接口返回错误 code={code}: {data.get('message', '')}") + + +async def fetch_fanout_rows(config: ExtConfig, target_date=None) -> list[dict]: + """执行两级拉取并反转为「成员 → 类别清单」行。""" + from app.services.ext_pull import _append_query_params, _extract_rows, _request_json + + pull = config.pull + if pull is None: + raise ValueError("拉取未配置") + fanout = pull.fanout or {} + missing = [k for k in _REQUIRED_KEYS if not fanout.get(k)] + if missing: + raise ValueError(f"fanout 配方缺少必填项: {', '.join(missing)}") + + # ---- 第一级: 类别清单 ---- + catalog = await _request_json(pull, config.id, target_date) + _raise_on_envelope_error(catalog) + items = _extract_rows(catalog, pull.response_path) + + id_field = fanout["id_field"] + label_field = fanout.get("label_field") or id_field + entries = [ + it for it in items + if isinstance(it, dict) and str(it.get(id_field) or "").strip() + ] + if not entries: + raise ValueError(f"第一级返回 0 个可用条目 (id_field={id_field})") + + # ---- 第二级: 逐条目成员 (并发 + 单条目失败容忍) ---- + concurrency = int(fanout.get("concurrency") or _DEFAULT_CONCURRENCY) + raw_retries = fanout.get("retries") + retries = int(_DEFAULT_RETRIES if raw_retries is None else raw_retries) + sem = asyncio.Semaphore(max(1, concurrency)) + + async def _members(entry: dict) -> list[dict] | None: + member_url = _append_query_params( + fanout["url"], {fanout["param"]: str(entry[id_field]).strip()} + ) + async with sem: + for attempt in range(retries + 1): + try: + data = await _request_json( + pull, config.id, None, url_override=member_url + ) + _raise_on_envelope_error(data) + return _extract_rows(data, fanout.get("items_path") or "") + except Exception: # noqa: BLE001 — 单条目失败: 重试后跳过 + if attempt == retries: + return None + await asyncio.sleep(0.5) + return None + + members_list = await asyncio.gather(*(_members(e) for e in entries)) + + # ---- 反转聚合: 成员 → 类别清单 (顺序跟随目录顺序, 保证拼接结果稳定) ---- + m_id = fanout["member_id_field"] + m_name = fanout.get("member_label_field") + agg: dict[str, dict] = {} + failed: list[str] = [] + for entry, members in zip(entries, members_list, strict=True): + if members is None: + failed.append(str(entry.get(label_field) or entry.get(id_field) or "")) + continue + label = clean_label(entry.get(label_field)) + for m in members: + if not isinstance(m, dict): + continue + sym = str(m.get(m_id) or "").strip() + if not sym: + continue + row = agg.get(sym) + if row is None: + row = { + fanout["symbol_col"]: sym, + fanout["name_col"]: (m.get(m_name) or "") if m_name else "", + fanout["value_col"]: [], + } + agg[sym] = row + if label and label not in row[fanout["value_col"]]: + row[fanout["value_col"]].append(label) + + if failed: + logger.warning( + "fanout 第二级失败 %d/%d (已跳过): %s", + len(failed), len(entries), "、".join(failed[:5]), + ) + if not agg: + raise ValueError("fanout 全部条目第二级拉取失败") + + separator = fanout.get("separator") or ";" + return [ + {**row, fanout["value_col"]: separator.join(row[fanout["value_col"]])} + for row in agg.values() + ] diff --git a/backend/app/services/ext_pages.py b/backend/app/services/ext_pages.py new file mode 100644 index 00000000..8c1ecb94 --- /dev/null +++ b/backend/app/services/ext_pages.py @@ -0,0 +1,133 @@ +"""运行时页面包存储 — data/ext_pages/{id}/ 目录式持久化。 + +页面包是前端扩展的打包产物(单文件 ES 模块): 上传后由前端在启动引导窗口 +动态 import 挂载(见 frontend/src/extensions/runtime.ts)。后端只负责存储、 +元数据与分发, 不解析 JS 内容 —— 清单契约(能否 import、apiVersion、路由 +冲突)由前端注册表校验, 校验结果经 manifest 接口回填到 page.json。 +""" +from __future__ import annotations + +import hashlib +import json +import re +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +# 与 ExtConfigStore 同口径的 ID 白名单: 防 ../ 路径穿越写删 +_VALID_ID = re.compile(r"^[a-zA-Z0-9_]{1,64}$") + + +@dataclass +class PagePackMeta: + id: str + enabled: bool = False + # 前端试载成功后回填的清单摘要(展示与排障用, 真实注册以 bundle 为准) + label: str = "" + path: str = "" + size: int = 0 + sha256: str = "" + uploaded_at: float = 0.0 + # 前端试载/注册失败的错误信息; 空串表示未出错 + error: str = "" + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +class PagePackStore: + """每个页面包一个目录: page.json(元数据) + bundle.js(产物)。 + + 元数据写入走 临时文件+rename, 与 ext_data 的原子写同语义, 避免半写 + 状态被启动扫描读到。 + """ + + def __init__(self, data_dir: Path): + self.data_dir = Path(data_dir) / "ext_pages" + self.data_dir.mkdir(parents=True, exist_ok=True) + + def _dir(self, pack_id: str) -> Path: + if not _VALID_ID.match(pack_id or ""): + raise ValueError("页面包 ID 仅允许字母/数字/下划线, 长度 1-64") + return self.data_dir / pack_id + + def _write_meta(self, meta: PagePackMeta) -> None: + d = self._dir(meta.id) + d.mkdir(parents=True, exist_ok=True) + tmp = d / "page.json.tmp" + tmp.write_text(json.dumps(asdict(meta), ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(d / "page.json") + + def save_upload(self, pack_id: str, bundle_src: Path) -> PagePackMeta: + """把已写好的临时 bundle 文件收编入库(同 ID 重新上传 = 覆盖更新)。""" + d = self._dir(pack_id) + d.mkdir(parents=True, exist_ok=True) + dest = d / "bundle.js" + # 临时目录与数据目录可能跨盘(Windows), 用 copy 而非 rename + dest.write_bytes(bundle_src.read_bytes()) + meta = PagePackMeta( + id=pack_id, + size=dest.stat().st_size, + sha256=_sha256_file(dest), + uploaded_at=time.time(), + ) + self._write_meta(meta) + return meta + + def get(self, pack_id: str) -> PagePackMeta | None: + p = self._dir(pack_id) / "page.json" + if not p.exists(): + return None + try: + return PagePackMeta(**json.loads(p.read_text(encoding="utf-8"))) + except (json.JSONDecodeError, TypeError): + # 半写/损坏的元数据按损坏页处理, 不阻断其他页面包 + return PagePackMeta(id=pack_id, error="page.json 损坏, 请重新上传") + + def bundle_path(self, pack_id: str) -> Path | None: + p = self._dir(pack_id) / "bundle.js" + return p if p.exists() else None + + def list(self) -> list[PagePackMeta]: + metas: list[PagePackMeta] = [] + for d in sorted(self.data_dir.iterdir()): + if not d.is_dir(): + continue + meta = self.get(d.name) + if meta is not None: + metas.append(meta) + return metas + + def update_manifest(self, pack_id: str, *, label: str, path: str, error: str) -> PagePackMeta: + """前端试载结果回填: error 非空 → 停用并记错误; 否则启用。""" + meta = self.get(pack_id) + if meta is None: + raise KeyError(pack_id) + meta.label = label + meta.path = path + meta.error = error + meta.enabled = not error + self._write_meta(meta) + return meta + + def set_enabled(self, pack_id: str, enabled: bool) -> PagePackMeta: + meta = self.get(pack_id) + if meta is None: + raise KeyError(pack_id) + meta.enabled = enabled + self._write_meta(meta) + return meta + + def delete(self, pack_id: str) -> bool: + d = self._dir(pack_id) + if not d.exists(): + return False + for f in d.iterdir(): + f.unlink(missing_ok=True) + d.rmdir() + return True diff --git a/backend/app/services/ext_presets.py b/backend/app/services/ext_presets.py index 527753bf..c649b825 100644 --- a/backend/app/services/ext_presets.py +++ b/backend/app/services/ext_presets.py @@ -1,22 +1,29 @@ -"""内置扩展数据预设 — 概念/行业启动时只创建配置, 等待用户手动获取 (#199)。 +"""内置扩展数据预设 — 概念/行业。 设计原则: - - 扩展数据通用逻辑零改动 (ExtConfig / fetch_and_ingest / API / 前端均不动) - - 仅在本模块做「接口结构 → 本地 schema」的转换 - - 「已存在则跳过」: 绝不覆盖用户已有数据, 老用户零影响 - - 拉取失败只记 warning, 不阻断启动 (保持「没数据也能跑」) + - 出厂即写【完整可用的拉取配置】(URL / 鉴权 / 两级请求配方), 不是空壳。 + 这样「数据」页展开表 → 拉取 就能看到一份真实可跑的配方, 照抄即可接入 + 自有源; 也免去"点获取按钮才拉"的隐藏路径。 + - 扩展数据通用逻辑零改动 (ExtConfig / fetch_and_ingest / API / 前端均不动): + 概念/行业所需的「目录 → 逐条目」形态由 PullConfig.fanout 声明式表达, + 通用引擎 (ext_fanout) 执行, 无按 id 的特例分支。 + - 「已存在则跳过」: 绝不覆盖用户已有配置与数据, 老用户零影响。 + - 出厂值变更时定向迁移: 仅当现有配置的 pull 精确命中历代出厂值时整体替换, + 用户自定义过的一律不碰 (见 _RETIRED_FACTORY_PULLS)。 + - 拉取失败只记 warning, 不阻断启动 (保持「没数据也能跑」)。 -种子数据来源 (概念/行业各自独立配置): - - 概念: https://shy313.com/api/plugins/market_flow/exports/ths-concepts - - 行业: https://shy313.com/api/plugins/market_flow/exports/ths-industries -作者更新数据只需改接口上的 JSON, 用户下次拉取自动同步, 无需发版。 +数据来源 (概念/行业共用 fuyao 同花顺指数接口): + - 板块清单: /api/a-share-index/catalog/ths-index-list (tag=cn_concept / industry) + - 板块成分: /api/a-share-index/constituents/ths-stock-list (单板块一次) + 接口方向为「板块→股票」, fanout 反转为「股票→板块名清单」。 + 鉴权复用 fuyao 行情插件同一凭据 (auth.key_ref = fuyao_api_key), 用户只需 + 在一处填 Key。全量约 390+320 次成分股调用, 8 并发可承受。 接入点: app.main.lifespan → ensure_builtin_presets(store.data_dir) """ from __future__ import annotations import logging -import math from pathlib import Path from app.services.ext_data import ( @@ -24,25 +31,39 @@ from app.services.ext_data import ( ExtConfigStore, ExtField, PullConfig, - rows_to_parquet, ) logger = logging.getLogger(__name__) -# 种子数据源 (概念/行业各自独立配置, 作者维护) -_CONCEPT_DATA_URL = "https://shy313.com/api/plugins/market_flow/exports/ths-concepts" -_INDUSTRY_DATA_URL = "https://shy313.com/api/plugins/market_flow/exports/ths-industries" +# fuyao 同花顺指数接口 (鉴权 X-api-key, 信封 {code, message, data:{item:[...]}}) +_FUYAO_BASE = "https://fuyao.aicubes.cn" +_FUYAO_CATALOG_PATH = "/api/a-share-index/catalog/ths-index-list" +_FUYAO_CONSTITUENTS_PATH = "/api/a-share-index/constituents/ths-stock-list" +# 成分股逐板块拉取: 全量 710 次调用, 8 并发 + 单板块失败重试 1 次 (实测无 429) +_FUYAO_CONCURRENCY = 8 +_FUYAO_TIMEOUT_S = 20.0 +_FUYAO_BOARD_RETRIES = 1 +# 复用 fuyao 行情插件的凭据 (secrets.json: fuyao_api_key / 环境变量 FUYAO_API_KEY) +_FUYAO_KEY_REF = "fuyao_api_key" + +_PRESET_IDS = ("ext_gn_ths", "ext_hy_ths") # --------------------------------------------------------------------------- -# 预设定义: 字段结构 + 拉取配方 +# 预设定义: 字段结构 + 完整拉取配方 # --------------------------------------------------------------------------- +def _fuyao_auth() -> dict: + """fuyao 鉴权: X-api-key 头, 凭据复用行情插件的 fuyao_api_key。""" + return {"type": "header", "header": "X-api-key", "key_ref": _FUYAO_KEY_REF} + + def _concept_preset() -> ExtConfig: """扩展概念 (ext_gn_ths)。 - 接口结构: [{symbol, name, concepts: [概念1, 概念2, ...]}] - 本地 schema: 股票代码 / 股票简称 / 所属概念(分号拼接) / symbol / code + 两级拉取: 板块目录 (tag=cn_concept) → 逐板块成分股, 反转为 + 「股票 → 所属概念」, 多个概念以分号拼接。 + 输出 schema: 股票代码 / 股票简称 / 所属概念 / symbol / code """ return ExtConfig( id="ext_gn_ths", @@ -55,16 +76,32 @@ def _concept_preset() -> ExtConfig: ExtField("股票简称", "string", "股票简称"), ExtField("所属概念", "string", "所属概念"), ], - description="同花顺概念分类 (启动仅创建配置, 在概念/行业页手动获取)", + description="同花顺概念分类 (fuyao 同花顺指数接口; 拉取配方见「拉取」页, 可照抄接自有源)", symbol_map={"type": "mapped", "col": "股票代码"}, code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"}, pull=PullConfig( - url=_CONCEPT_DATA_URL, + url=f"{_FUYAO_BASE}{_FUYAO_CATALOG_PATH}?tag=cn_concept", method="GET", + response_path="data.item", + auth=_fuyao_auth(), + timeout_seconds=int(_FUYAO_TIMEOUT_S), schedule_minutes=1440, - # enabled=False: ensure_builtin_presets 承诺启动不拉取, PullScheduler - # 只调度 enabled 配置; 手动获取走 fetch_preset 独立路径不受影响 (#199) - enabled=False, + enabled=True, + fanout={ + "url": f"{_FUYAO_BASE}{_FUYAO_CONSTITUENTS_PATH}", + "param": "thscode", + "id_field": "thscode", + "label_field": "name", + "items_path": "data.item", + "member_id_field": "thscode", + "member_label_field": "name", + "symbol_col": "股票代码", + "name_col": "股票简称", + "value_col": "所属概念", + "separator": ";", + "concurrency": _FUYAO_CONCURRENCY, + "retries": _FUYAO_BOARD_RETRIES, + }, ), ) @@ -72,8 +109,8 @@ def _concept_preset() -> ExtConfig: def _industry_preset() -> ExtConfig: """扩展行业 (ext_hy_ths)。 - 接口结构: [{symbol, name, industries: [一级行业, 二级行业, 三级行业]}] - 本地 schema: 股票代码 / 股票简称 / 所属同花顺行业(横杠拼接) / symbol / code + fuyao 为扁平板块清单, 无一/二/三级层级路径, 单级粒度。 + 输出 schema: 股票代码 / 股票简称 / 所属同花顺行业 / symbol / code """ return ExtConfig( id="ext_hy_ths", @@ -86,15 +123,32 @@ def _industry_preset() -> ExtConfig: ExtField("股票简称", "string", "股票简称"), ExtField("所属同花顺行业", "string", "所属同花顺行业"), ], - description="同花顺行业分类 (启动仅创建配置, 在概念/行业页手动获取)", + description="同花顺行业分类 (fuyao 同花顺指数接口, 扁平板块无层级; 拉取配方见「拉取」页)", symbol_map={"type": "mapped", "col": "股票代码"}, code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"}, pull=PullConfig( - url=_INDUSTRY_DATA_URL, + url=f"{_FUYAO_BASE}{_FUYAO_CATALOG_PATH}?tag=industry", method="GET", + response_path="data.item", + auth=_fuyao_auth(), + timeout_seconds=int(_FUYAO_TIMEOUT_S), schedule_minutes=1440, - # 同概念 preset: 出厂禁用, 避免启动即网络拉取 (#199) - enabled=False, + enabled=True, + fanout={ + "url": f"{_FUYAO_BASE}{_FUYAO_CONSTITUENTS_PATH}", + "param": "thscode", + "id_field": "thscode", + "label_field": "name", + "items_path": "data.item", + "member_id_field": "thscode", + "member_label_field": "name", + "symbol_col": "股票代码", + "name_col": "股票简称", + "value_col": "所属同花顺行业", + "separator": "-", + "concurrency": _FUYAO_CONCURRENCY, + "retries": _FUYAO_BOARD_RETRIES, + }, ), ) @@ -103,129 +157,6 @@ def _presets() -> list[ExtConfig]: return [_concept_preset(), _industry_preset()] -# --------------------------------------------------------------------------- -# 接口结构 → 本地 schema 转换 (仅预设使用) -# --------------------------------------------------------------------------- - -def _symbol_to_code(symbol: str) -> str: - """symbol (000001.SZ) → code (000001)。""" - return symbol.split(".", 1)[0] if "." in symbol else symbol - - -def _dimension_label(value: object) -> str: - if value is None or (isinstance(value, float) and not math.isfinite(value)): - return "" - text = str(value).strip() - return "" if text.casefold() in {"nan", "none", "null"} else text - - -def _flatten_concept_rows(raw_rows: list[dict]) -> list[dict]: - """概念: concepts 数组 → 分号拼接成「所属概念」字符串。 - - [{symbol, name, concepts:[...]}] → [{股票代码, 股票简称, 所属概念, symbol, code}] - 注: code 由 symbol 派生 (000001.SZ → 000001), 因 rows_to_parquet 不执行 code_map。 - """ - out: list[dict] = [] - for r in raw_rows: - sym = (r.get("symbol") or "").strip() - if not sym: - continue - concepts = r.get("concepts") or [] - labels = [label for c in concepts if (label := _dimension_label(c))] - out.append({ - "股票代码": sym, - "股票简称": r.get("name") or "", - "所属概念": ";".join(labels), - "symbol": sym, - "code": _symbol_to_code(sym), - }) - return out - - -def _flatten_industry_rows(raw_rows: list[dict]) -> list[dict]: - """行业: industries 数组 → 横杠拼接成「所属同花顺行业」字符串。 - - [{symbol, name, industries:[...]}] → [{股票代码, 股票简称, 所属同花顺行业, symbol, code}] - """ - out: list[dict] = [] - for r in raw_rows: - sym = (r.get("symbol") or "").strip() - if not sym: - continue - inds = r.get("industries") or [] - labels = [label for i in inds if (label := _dimension_label(i))] - out.append({ - "股票代码": sym, - "股票简称": r.get("name") or "", - "所属同花顺行业": "-".join(labels), - "symbol": sym, - "code": _symbol_to_code(sym), - }) - return out - - -# --------------------------------------------------------------------------- -# 拉取执行 (复用 httpx, 不依赖 fetch_and_ingest 的 PullConfig 路径) -# --------------------------------------------------------------------------- - -# 部分网络环境 (CDN/WAF/网关) 会把数组包成 {data: [...]}/{list: [...]}/{rows: [...]} 信封。 -# 这里做一次兼容解包, 避免误判为「接口返回不是数组」。 -_ENVELOPE_KEYS = ("data", "list", "rows", "result", "results") - - -async def _fetch_json(url: str) -> list[dict]: - """请求 JSON 接口, 返回行数组。超时 30s, 失败抛异常由调用方兜底。 - - 兼容两种上游返回形态: - - 直接是数组: [{...}, ...] → 原样返回 - - 信封包裹: {data: [{...}]} 等 → 自动解包 - """ - import httpx - - # 延迟导入避免与 ext_pull 循环依赖; 出站请求带 tsp 标识头 - from app.services.ext_pull import outbound_headers - - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.get(url, headers=outbound_headers()) - resp.raise_for_status() - data = resp.json() - - if isinstance(data, list): - return data - - # 信封解包: 在常见键里找第一个值为数组的 - if isinstance(data, dict): - for key in _ENVELOPE_KEYS: - inner = data.get(key) - if isinstance(inner, list): - return inner - # 兜底: 遍历所有值, 取第一个数组 - for v in data.values(): - if isinstance(v, list): - return v - - raise ValueError( - f"接口返回不是数组 (type={type(data).__name__}), " - f"响应预览: {str(data)[:200]}" - ) - - -async def _seed_one(config: ExtConfig, flatten, data_dir: Path) -> int: - """拉取 + 转换 + 写入单个预设。返回写入行数。""" - from datetime import date - - raw = await _fetch_json(config.pull.url) - rows = flatten(raw) - if not rows: - raise ValueError(f"接口返回 0 行: {config.pull.url}") - n = rows_to_parquet(rows, config, data_dir, snapshot_date=date.today()) - return n - - -# --------------------------------------------------------------------------- -# 对外入口 -# --------------------------------------------------------------------------- - def get_preset(config_id: str) -> ExtConfig | None: """按 id 取预设定义 (供 API 层校验 id 合法性)。""" for c in _presets(): @@ -234,48 +165,77 @@ def get_preset(config_id: str) -> ExtConfig | None: return None -async def ensure_builtin_presets(data_dir: Path) -> None: - """启动时: 为缺失的预设创建 config.json (含 pull 配置), 但【不拉取数据】。 +# 历代出厂 pull 配方指纹 — 仅比对 url + fanout 是否存在, 命中即视为未改过的出厂值。 +# 换源/改配方后, 老用户的出厂配置需要整体替换为新配方; 用户自己填过 URL 的不动。 +_RETIRED_FACTORY_PULLS: dict[str, tuple[dict, ...]] = { + "ext_gn_ths": ( + # 换源前的自维护种子源 + {"url": "https://shy313.com/api/plugins/market_flow/exports/ths-concepts"}, + # 过渡期的 fuyao 目录标记 (无 fanout, 拉不到成分股) + {"url": f"{_FUYAO_BASE}{_FUYAO_CATALOG_PATH}?tag=cn_concept"}, + ), + "ext_hy_ths": ( + {"url": "https://shy313.com/api/plugins/market_flow/exports/ths-industries"}, + {"url": f"{_FUYAO_BASE}{_FUYAO_CATALOG_PATH}?tag=industry"}, + ), +} +# 出厂 description 前缀 (历代文案) — 命中则同步为当前文案, 用户自定义描述不动 +_FACTORY_DESC_PREFIXES = ( + "同花顺概念分类", + "同花顺行业分类", +) - 设计: 数据获取改为用户在概念/行业页手动点「获取数据」触发, 避免启动时 - 网络请求阻塞, 也避免「自动拉取」与「用户自主控制」的预期冲突。 + +def _is_retired_factory_pull(config_id: str, pull: PullConfig | None) -> bool: + """现有 pull 是否为历代出厂值 (不含当前配方), 需要补齐/替换为当前配方。 + + True 的三种情形: + - pull 为 None / 空 URL: 出厂空壳 (#199 之后、本次改动之前的形态) + - URL 命中历代出厂源, 且未带 fanout (换源前的种子源 / 只有目录的过渡配方) + 用户自定义过 URL 或已带 fanout 的配置一律 False (绝不覆盖)。 + """ + if pull is None: + return True + url = (pull.url or "").strip() + if not url: + return True # 空 URL 的空壳配置: 由出厂空壳演进而来, 需补齐 + if pull.fanout: + return False # 已带两级配方 (当前形态或用户自建), 不碰 + return any(url == retired["url"] for retired in _RETIRED_FACTORY_PULLS.get(config_id, ())) + + +async def ensure_builtin_presets(data_dir: Path) -> None: + """启动时: 为缺失的预设创建配置, 并把历代出厂空壳/旧配方补齐为当前配方。 安全保证: - - 已存在则完全跳过 (绝不覆盖用户数据) - - 只写 config.json, 失败只记 warning 不阻断启动 + - 已存在且是用户自定义配置 → 跳过, 绝不覆盖用户数据 + - 仅当 pull 为空 / 命中历代出厂配方指纹时, 用当前完整配方替换 + - description 仅当命中出厂前缀时同步, 用户自定义描述不动 + - 只写 config.json (不拉数据), 失败只记 warning 不阻断启动 + + 数据获取: 由拉取调度器按 schedule_minutes 执行, 或用户在数据页点「拉取 → 执行」。 """ store = ExtConfigStore(data_dir) - for config in _presets(): - existing = store.get(config.id) - if existing is not None: - # 用户已有此表 (老用户 / 自己重建过) → 一律不动 - continue try: - store.upsert(config) - logger.info("内置扩展表 %s 配置已就绪 (待用户手动获取数据)", config.id) - except Exception as e: + existing = store.get(config.id) + if existing is None: + store.upsert(config) + logger.info("内置扩展表 %s 配置已就绪 (含出厂拉取配方)", config.id) + continue + + changed = False + if _is_retired_factory_pull(config.id, existing.pull): + existing.pull = config.pull + changed = True + if not (existing.description or "").strip() or \ + (existing.description or "").startswith(_FACTORY_DESC_PREFIXES): + if existing.description != config.description: + existing.description = config.description + changed = True + + if changed: + store.upsert(existing) + logger.info("内置扩展表 %s 出厂配方已更新为当前版本", config.id) + except Exception as e: # noqa: BLE001 — 启动契约: 预设写入失败不阻断 logger.warning("内置扩展表 %s 配置写入失败 (不影响启动): %s", config.id, e) - - -async def fetch_preset(config_id: str, data_dir: Path) -> int: - """手动触发某个预设的数据拉取 (供 API 调用)。 - - Raises: - ValueError: config_id 不是内置预设 - Exception: 网络请求/解析/写入失败 (由 API 层转 HTTP 错误) - """ - config = get_preset(config_id) - if config is None: - raise ValueError(f"未知的内置预设: {config_id}") - - flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows - - # 确保 config.json 存在 (用户可能从未启动过 ensure_builtin_presets) - store = ExtConfigStore(data_dir) - if store.get(config_id) is None: - store.upsert(config) - - n = await _seed_one(config, flatten, data_dir) - logger.info("内置扩展表 %s 手动拉取成功: %d 行", config_id, n) - return n diff --git a/backend/app/services/ext_pull.py b/backend/app/services/ext_pull.py index 6c08ddb4..a3f78373 100644 --- a/backend/app/services/ext_pull.py +++ b/backend/app/services/ext_pull.py @@ -117,19 +117,6 @@ def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]: # 拉取执行 # --------------------------------------------------------------------------- -def _apply_preset_flatten(config_id: str, rows: list[dict]) -> list[dict]: - """对内置预设 (概念/行业) 应用结构转换, 与 fetch_preset 保持一致。 - - 延迟导入避免与 ext_presets 形成循环依赖。 - 非预设 id 原样返回。 - """ - if config_id not in ("ext_gn_ths", "ext_hy_ths"): - return rows - from app.services.ext_presets import _flatten_concept_rows, _flatten_industry_rows - flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows - return flatten(rows) - - def _format_date_value(day: date, date_format: str) -> str: """按配置格式把交易日序列化为接口参数值。 @@ -160,17 +147,31 @@ def _apply_auth(config_id: str, auth: dict | None, url: str, headers: dict[str, header → {header: }, query → ?{param}=。Key 只存 secrets.json, 不落 config.json; 配置了鉴权但未设置 Key 时 fail-closed 直接报错, 避免不带凭据请求被服务端记成无效调用。返回 (可能追加了参数的) url。 + + auth.key_ref: 复用已有凭据而非本表专属 Key。值为 secrets.json 的字段名 + (如 "fuyao_api_key"), 环境变量兜底取该名的大写形式。同一数据源被行情插件 + 与扩展表共用时, 用户只需在一处填 Key。 """ from urllib.parse import quote + from app import secrets_store + from app.services.ext_data import get_ext_api_key auth_type = str((auth or {}).get("type") or "none").lower() if auth_type == "none": return url - key = get_ext_api_key(config_id) - if not key: - raise ValueError(f"已配置 {auth_type} 鉴权但未设置 API Key, 请在拉取设置中填写") + key_ref = str((auth or {}).get("key_ref") or "").strip() + if key_ref: + key = secrets_store.get_env_backed_secret(key_ref, key_ref.upper()) + if not key: + raise ValueError( + f"已配置鉴权但未找到凭据 {key_ref} — 请在 设置 → 数据源 配置对应 API Key" + ) + else: + key = get_ext_api_key(config_id) + if not key: + raise ValueError(f"已配置 {auth_type} 鉴权但未设置 API Key, 请在拉取设置中填写") if auth_type == "bearer": headers[str(auth.get("header") or "Authorization")] = f"Bearer {key}" elif auth_type == "header": @@ -208,14 +209,21 @@ def _assert_rows_date(rows: list[dict], day: date) -> None: async def _request_json( pull: PullConfig, config_id: str, day: date | None = None, extra_params: dict[str, str] | None = None, + *, + url_override: str | None = None, ) -> Any: """发起一次拉取请求并返回解析后的 JSON。 正式拉取 (带日期参数) 与设置页"测试" (不带) 共用同一实现, 保证 UA 标识头与 API Key 鉴权注入只有一套口径。 extra_params: 追加的 query 参数 (分页页码/每页条数), 值经 URL 编码。 + url_override: 直接指定请求地址 (两级拉取的第二级请求用), 不再套用 + date_param 与 pull.url。 """ - url = _with_date_param(pull.url, pull.date_param, day, pull.date_format) if day else pull.url + if url_override is not None: + url = url_override + else: + url = _with_date_param(pull.url, pull.date_param, day, pull.date_format) if day else pull.url if extra_params: url = _append_query_params(url, extra_params) # 每配置超时 (PullConfig.timeout_seconds, 默认 30 与历史行为一致); @@ -251,15 +259,12 @@ def _append_query_params(url: str, params: dict[str, str]) -> str: def _parse_rows_payload(config: ExtConfig, pull: PullConfig, data: Any) -> list[dict]: - """单次响应 → 行列表 (提取/预设转换/字段映射)。与写入链共用, 不含日期校验。""" + """单次响应 → 行列表 (提取/字段映射)。与写入链共用, 不含日期校验。 + + 两级拉取 (pull.fanout) 不走这里 —— 它的行由多个响应聚合而来, 见 + fetch_rows_for_date 的分支。 + """ rows = _extract_rows(data, pull.response_path) - - # 内置预设 (概念/行业): 应用结构转换, 让产出 schema 与分析页一致。 - # 否则 raw 接口列 (concepts/industries 数组、name) 会直接覆盖正确的 part.parquet, - # 导致分析页因找不到维度字段 (所属概念/所属同花顺行业) 而"数据消失"。 - # 见 ext_presets._flatten_* —— 手动拉取 / 定时拉取都必须走同一套转换。 - rows = _apply_preset_flatten(config.id, rows) - # 字段映射 return _apply_field_map(rows, pull.field_map) @@ -267,15 +272,20 @@ def _parse_rows_payload(config: ExtConfig, pull: PullConfig, data: Any) -> list[ async def fetch_rows_for_date(config: ExtConfig, target_date: date) -> list[dict]: """按日期请求外部 API 并解析为行 (不写盘)。空数据返回 []。 - 与 fetch_and_ingest 共用同一解析链 (response_path/预设转换/字段映射/ - 关联字段校验), 历史回补与当日拉取不产生第二套口径。 + 与 fetch_and_ingest 共用同一解析链 (response_path/字段映射/关联字段校验), + 历史回补与当日拉取不产生第二套口径。 配置了 pull.page_param 时按页循环拉取 (仅 GET), 空页/短页/达 max_pages 停。 + 配置了 pull.fanout 时走两级拉取 (目录 → 逐条目明细), 见 ext_fanout。 """ pull = config.pull if not pull or not pull.url: raise ValueError("拉取未配置或 URL 为空") - if pull.page_param and pull.method.upper() == "GET": + if pull.fanout: + from app.services.ext_fanout import fetch_fanout_rows + + rows = await fetch_fanout_rows(config, target_date) + elif pull.page_param and pull.method.upper() == "GET": collected: list[dict] = [] page = pull.page_start for _ in range(pull.max_pages): diff --git a/backend/app/services/extend_history.py b/backend/app/services/extend_history.py index 0249ad4e..9cab0edf 100644 --- a/backend/app/services/extend_history.py +++ b/backend/app/services/extend_history.py @@ -37,31 +37,29 @@ def _invalidate(table: str | None = None) -> None: invalidate_data_cache(table) -def _resolve_universe(capset: CapabilitySet) -> list[str]: - """解析标的池 — 与 daily_pipeline 独立的副本。""" - if capset.has(Cap.KLINE_DAILY_BATCH): - try: - from app.tickflow.pools import get_pool - all_a = get_pool("CN_Equity_A", refresh=True) - if all_a: - return sorted(all_a) - except Exception as e: - logger.warning("CN_Equity_A pool unavailable: %s", e) +def _resolve_universe(capset: CapabilitySet, repo=None) -> list[str]: + """解析标的池 — 与 daily_pipeline 同口径: instruments 维表 + 自选 (单一来源)。 - from app.tickflow.pools import DEMO_SYMBOLS, get_pool as _get_pool - from app.config import settings + 不再拉 CN_Equity_A 池 (含 SW1 聚合兜底); 维表由盘后管道同步维护。 + repo 传入时过滤自选里的指数 symbol (指数日K走独立存储)。 + """ from pathlib import Path + import polars as pl - base: set[str] = set(DEMO_SYMBOLS) - base.update(_get_pool("watchlist")) - d = Path(settings.data_dir) - inst_path = d / "instruments" / "instruments.parquet" + + from app.config import settings + from app.tickflow.pools import get_pool + + base: set[str] = set(get_pool("watchlist")) + inst_path = Path(settings.data_dir) / "instruments" / "instruments.parquet" if inst_path.exists(): try: inst = pl.read_parquet(inst_path, columns=["symbol"]) base.update(inst["symbol"].to_list()) - except Exception as e: - logger.warning("instruments supplement failed: %s", e) + except Exception as e: # noqa: BLE001 + logger.warning("instruments 维表读取失败, 标的池仅剩自选: %s", e) + if repo is not None: + base -= set(repo.get_index_symbol_set()) return sorted(base) @@ -131,7 +129,7 @@ def run_extend_history( # 2. 解析标的池 emit("extend_history", 5, "解析标的池…") - universe = _resolve_universe(capset) + universe = _resolve_universe(capset, repo) if not universe: return {"error": "标的池为空"} emit("extend_history", 8, f"标的池: {len(universe)} 只") diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py index 556b619a..d5fe393b 100644 --- a/backend/app/services/financial_sync.py +++ b/backend/app/services/financial_sync.py @@ -53,6 +53,13 @@ def _financial_is_custom() -> bool: return custom_sources.provider_has_dataset(provider, "financial") +def _financial_capability_label() -> str: + """门禁日志标签: "财务数据 ← fuyao" / "…(档位不足)", 便于判断该切哪个源。""" + from app.data_providers.capabilities import capability_gate + _, label = capability_gate("financial") + return label + + def _fetch_table( table: str, symbols: list[str], @@ -62,7 +69,7 @@ def _fetch_table( """通过当前财务数据源拉取一张标准化财务表。""" is_custom = _financial_is_custom() if not is_custom and not capset.has(Cap.FINANCIAL): - logger.info("sync_%s skipped: no FINANCIAL capability", table) + logger.info("sync_%s skipped: %s 不可用", table, _financial_capability_label()) return pl.DataFrame() if not symbols: logger.warning("sync_%s skipped: no symbols", table) @@ -251,7 +258,7 @@ def sync_shares(data_dir: Path, capset: CapabilitySet) -> int: def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]: """同步所有财务表。返回 {table: rows}。""" if not capset.has(Cap.FINANCIAL) and not _financial_is_custom(): - logger.info("sync_all financials skipped: no FINANCIAL capability") + logger.info("sync_all financials skipped: %s 不可用", _financial_capability_label()) return {} symbols = _get_symbols(data_dir) @@ -332,7 +339,7 @@ class FinancialScheduler: self._data_dir = data_dir self._capset = capset if not capset.has(Cap.FINANCIAL) and not _financial_is_custom(): - logger.info("FinancialScheduler skipped: no FINANCIAL capability") + logger.info("FinancialScheduler skipped: %s 不可用", _financial_capability_label()) return # 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步" try: diff --git a/backend/app/services/index_sync.py b/backend/app/services/index_sync.py index 00640efc..de1c3a1c 100644 --- a/backend/app/services/index_sync.py +++ b/backend/app/services/index_sync.py @@ -205,6 +205,7 @@ def sync_and_persist_index_daily( on_chunk_done(current, total) 每个批次完成后回调。 """ if not capset.has(Cap.KLINE_DAILY_BATCH): + kline_sync._log_cap_unavailable("daily", "指数/ETF 日K同步") return 0 if symbols_override: @@ -340,6 +341,7 @@ def sync_and_persist_etf_daily( on_chunk_done(current, total) 每个批次完成后回调。 """ if not capset.has(Cap.KLINE_DAILY_BATCH): + kline_sync._log_cap_unavailable("daily", "ETF 日K同步") return 0 if symbols_override: diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index c0a477f1..aa78ca72 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -31,6 +31,20 @@ from app.tickflow.repository import KlineRepository, replace_with_retry logger = logging.getLogger(__name__) + +def _log_cap_unavailable(cap_id: str, action: str) -> None: + """门禁未通过时记一条带生效源的日志。 + + 原实现是静默 return —— 切换数据源后「什么都没发生」且无迹可循。 + 带源标签后可一眼看出是路由到不可用源, 还是 TickFlow 档位不足。 + """ + try: + from app.data_providers.capabilities import capability_gate + _, label = capability_gate(cap_id) + except Exception: # noqa: BLE001 — 仅日志, 失败不应影响主流程 + label = cap_id + logger.info("%s 跳过: %s 不可用", action, label) + # ── SDK 批量调用守护 deadline ──────────────────────────────────────────── # TickFlow SDK 自带 timeout=30s×3 重试, 但只保护其覆盖的请求阶段; 真挂死在 # 不可中断的网络读里时调用永不返回 → 持有独占槽的重任务线程被连坐卡死, @@ -365,6 +379,7 @@ def sync_and_persist_daily_batch( # 自定义源未配置 daily → 回退 TickFlow if not capset.has(Cap.KLINE_DAILY_BATCH): + _log_cap_unavailable("daily", "日K同步") return _finalize(0) limit = resolve_limit(capset, Cap.KLINE_DAILY_BATCH, default_batch=100) @@ -653,6 +668,7 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository, # 自定义源未配置 adj_factor → 回退 TickFlow if not capset.has(Cap.ADJ_FACTOR): + _log_cap_unavailable("adj_factor", "除权因子同步") return 0, [] tf = get_client() diff --git a/backend/app/services/market_overview_builder.py b/backend/app/services/market_overview_builder.py index 4060d0bd..5f95ca56 100644 --- a/backend/app/services/market_overview_builder.py +++ b/backend/app/services/market_overview_builder.py @@ -70,14 +70,9 @@ def _board(symbol: str) -> str: return "其他" -def _score(value: float, low: float, high: float) -> int: - if high <= low: - return 50 - if not math.isfinite(value): - # 非有限输入不可映射: 取中性 50, 不得让 round(inf)/round(nan) 抛异常打断看板计算 - # (与 app/services/regime_builder.py 的同名实现保持一致) - return 50 - return max(0, min(100, round((value - low) / (high - low) * 100))) +# 注: 原 _score(value, low, high) 已迁至 app.services.emotion_score.score_of —— +# 情绪评分的区间与权重现由用户可配置, 归一化函数须与配置同处一个模块。 +# regime_builder 保留同名实现 (其区间是 A 股分位数校准的另一套口径, 不共用)。 # ================================================================ @@ -568,27 +563,33 @@ def build_market_overview( mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]] mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0) mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0 - mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50 - radar = [ - {"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)}, - {"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)}, - {"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)}, - {"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)}, - {"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)}, - {"key": "mainline", "label": "主线", "value": mainline_score}, - ] - emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50 - if emotion_score >= 70: - emotion_label = "强势" - elif emotion_score >= 55: - emotion_label = "偏暖" - elif emotion_score >= 45: - emotion_label = "震荡" - elif emotion_score >= 30: - emotion_label = "偏冷" - else: - emotion_label = "冰点" + # 情绪评分 (维度/权重/区间/档位) 走可配置口径: 定义在 app.services.emotion_score, + # 用户可在看板「情绪雷达」卡片上改权重与区间。指标名即该模块的 term.key。 + from app.services import preferences + from app.services.emotion_score import compute as compute_emotion + + emotion = compute_emotion({ + "avg_index_pct": avg_index_pct, + "up_pct": up_pct, + "avg_pct": avg_pct, + "median_pct": median_pct, + "strong_diff_pct": strong_diff_pct, + "avg_vol_ratio": avg_vol_ratio, + "high_vol_pct": high_vol_pct, + "limit_up": limit_up, + "seal_rate": seal_rate, + "max_boards": max_boards, + "tier2_count": tier2_count, + "down_pct": down_pct, + "strong_down_pct": strong_down_pct, + # 主线仅在有条目时才有意义: 无条目时该维度取中性 50 (与历史实现一致) + **({"mainline_avg": mainline_avg, "mainline_cover_pct": mainline_cover_pct} if mainline_items else {}), + }, preferences.get_emotion_score_config()) + + radar = emotion["radar"] + emotion_score = emotion["score"] + emotion_label = emotion["label"] return _json_safe({ "as_of": str(as_of), diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index efd897ab..6e375e57 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -435,6 +435,38 @@ def get_mainline_filter_config() -> dict: } +def get_emotion_score_config() -> dict | None: + """情绪评分口径配置 (维度权重/子项权重与区间/档位阈值)。 + + 返回原始 dict 或 None (未自定义 = 用出厂口径)。**不在此处做结构校验与补全** —— + 合并与归一化统一由 emotion_score.merge_config 负责, 避免两处各写一套默认值而漂移。 + """ + v = load().get("emotion_score_config") + return v if isinstance(v, dict) else None + + +def set_emotion_score_config(cfg: dict | None) -> dict | None: + """保存情绪评分口径。传 None 或 {} = 恢复出厂口径 (清除该键)。 + + 只做形状校验, 值的合法性 (负数权重/区间倒置/阈值乱序) 由 emotion_score 层 + 在每次计算时归一化 —— 存进去什么由用户负责, 算出来一定安全。 + """ + from app.services.emotion_score import merge_config + + if not cfg: + with _SAVE_LOCK: + current = load() + current.pop("emotion_score_config", None) + atomic_write_text(_path(), json.dumps(current, indent=2, ensure_ascii=False)) + _invalidate_cache() + return None + + # 落盘前过一遍 merge_config: 校验形状并剔除未知字段, 避免脏配置长期驻留 + normalized = merge_config(cfg) + save({"emotion_score_config": normalized}) + return normalized + + def set_mainline_filter_config(cfg: dict) -> dict: """保存主线过滤配置(白名单字段, 部分更新)。修改后需重算主线生效。""" updates: dict = {} diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index ee7823da..948431f0 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -383,7 +383,10 @@ class QuoteService: if not self.is_realtime_allowed(): if preferences.get_realtime_quotes_enabled(): self._save_enabled(False) - logger.info("实时行情未启动:当前档位(none)无实时行情权限") + # 带生效源: 便于判断「该切哪个源」, 而不是只看到 TickFlow 档位 + from app.data_providers.capabilities import capability_gate + _, label = capability_gate("realtime") + logger.info("实时行情未启动: %s 不可用", label) return if preferences.get_realtime_quotes_enabled(): self.start() diff --git a/backend/app/services/task_registry.py b/backend/app/services/task_registry.py new file mode 100644 index 00000000..21126585 --- /dev/null +++ b/backend/app/services/task_registry.py @@ -0,0 +1,178 @@ +"""进行中任务注册表 — 长任务(回测/挖掘/优化/滚动验证)跨设备接回。 + +前端在启动任务时 fire-and-forget 上报 (status=running, id=kind+参数指纹), +终态时按同一 id 关闭; 其他设备打开对应页面时, 本地无 reconnect 键则从注册表 +取最近 running 项回填本地键, 复用既有 tryReconnect 机制 — 任务执行与重连 +逻辑零改动, 上报失败也只是退化为原来的单机行为。 + +文件: data/user_data/task_registry.json + {"items": [{id, kind, label, payload, status, started_at, updated_at}, ...]} + +容量与时效: ≤20 条 (超限裁 updated_at 最旧); running 超过 24h 视为孤儿 +(进程重启遗留), 读取时静默剔除并落盘回收。 +""" +from __future__ import annotations + +import json +import logging +import time +from pathlib import Path +from typing import Any + +from app.services.fs_utils import atomic_write_text + +logger = logging.getLogger(__name__) + +TASK_KINDS = frozenset({"backtest", "mining", "optimizer", "walkforward"}) +TASK_STATUSES = frozenset({"running", "done", "error"}) +MAX_ITEMS = 20 +ORPHAN_RUNNING_SECONDS = 24 * 3600 +_FILENAME = "task_registry.json" + +_cache: dict[str, Any] | None = None +_cache_sig: tuple[int, int] | None = None + + +def _path(data_dir: Path) -> Path: + p = data_dir / "user_data" / _FILENAME + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def _signature(p: Path) -> tuple[int, int] | None: + try: + st = p.stat() + return (st.st_mtime_ns, st.st_size) + except OSError: + return None + + +def _sanitize_items(data: Any) -> list[dict]: + if not isinstance(data, dict): + return [] + items = data.get("items") + if not isinstance(items, list): + return [] + out: list[dict] = [] + for it in items: + if not isinstance(it, dict): + continue + if it.get("kind") not in TASK_KINDS or it.get("status") not in TASK_STATUSES: + continue + if not isinstance(it.get("id"), str) or not it["id"]: + continue + out.append({ + "id": it["id"], + "kind": it["kind"], + "status": it["status"], + "label": it["label"] if isinstance(it.get("label"), str) else "", + "payload": it["payload"] if isinstance(it.get("payload"), dict) else {}, + "started_at": it.get("started_at") or 0, + "updated_at": it.get("updated_at") or 0, + }) + return out + + +def _sorted_trimmed(items: list[dict]) -> list[dict]: + ordered = sorted(items, key=lambda x: x.get("updated_at") or 0, reverse=True) + return ordered[:MAX_ITEMS] + + +def _prune_orphans(items: list[dict]) -> tuple[list[dict], int]: + now = time.time() + kept: list[dict] = [] + orphans = 0 + for it in items: + if ( + it["status"] == "running" + and it["updated_at"] + and now - it["updated_at"] > ORPHAN_RUNNING_SECONDS + ): + orphans += 1 + continue + kept.append(it) + return kept, orphans + + +def _load_raw(data_dir: Path) -> list[dict] | None: + global _cache, _cache_sig + p = _path(data_dir) + sig = _signature(p) + if sig is None: + return [] + if _cache is not None and sig == _cache_sig: + return _cache["items"] + try: + data = json.loads(p.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 — 损坏文件视为空注册表 + logger.warning("task registry 文件损坏, 视为空: %s", e) + return [] + items = _sorted_trimmed(_sanitize_items(data)) + _cache, _cache_sig = {"items": items}, sig + return items + + +def _save(data_dir: Path, items: list[dict]) -> None: + global _cache, _cache_sig + trimmed = _sorted_trimmed(items) + p = _path(data_dir) + atomic_write_text(p, json.dumps({"items": trimmed}, ensure_ascii=False)) + _cache, _cache_sig = {"items": trimmed}, _signature(p) + + +def list_items(data_dir: Path, kind: str | None = None) -> list[dict]: + """按 updated_at 倒序返回 (可选 kind 过滤); 顺带回收孤儿 running 项。""" + items = _load_raw(data_dir) + if items is None: + return [] + kept, orphans = _prune_orphans(items) + if orphans: + _save(data_dir, kept) + items = kept + if kind is None: + return items + return [it for it in items if it["kind"] == kind] + + +def upsert( + data_dir: Path, + task_id: str, + *, + kind: str, + status: str, + label: str = "", + payload: dict | None = None, +) -> dict: + if kind not in TASK_KINDS: + raise ValueError(f"未知的任务类型: {kind}") + if status not in TASK_STATUSES: + raise ValueError(f"未知的任务状态: {status}") + items = _load_raw(data_dir) or [] + now = time.time() + entry = { + "id": task_id, + "kind": kind, + "status": status, + "label": label, + "payload": payload or {}, + "started_at": now, + "updated_at": now, + } + for i, it in enumerate(items): + if it["id"] == task_id: + entry["started_at"] = it.get("started_at") or now + items[i] = entry + break + else: + items.append(entry) + _save(data_dir, items) + return entry + + +def delete(data_dir: Path, task_id: str) -> bool: + items = _load_raw(data_dir) or [] + remaining = [it for it in items if it["id"] != task_id] + if len(remaining) == len(items): + return False + _save(data_dir, remaining) + return True diff --git a/backend/app/strategy/prompts/strategy-guide.md b/backend/app/strategy/prompts/strategy-guide.md index 0c8800da..74d377d7 100644 --- a/backend/app/strategy/prompts/strategy-guide.md +++ b/backend/app/strategy/prompts/strategy-guide.md @@ -140,6 +140,7 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: - `LOOKBACK_DAYS` 决定引擎加载多少天的数据,设为策略逻辑需要的最大回看天数 - 优先使用 Polars 的 `with_columns`、`over("symbol")`、`group_by`、`join`、`filter` 实现历史逻辑,避免把数据转成 Python list/dict 循环 - 只有遇到表达式难以描述的复杂状态机时,才使用 `partition_by("symbol")` + `to_dicts()` 逐股票分析 +- **`to_dicts()` 逐行攒下的命中行重建 DataFrame 必须显式带 schema**:写 `pl.DataFrame(hits, schema=df.schema)`,**不能裸写 `pl.DataFrame(hits)`**。原因:从 dict 列表建表只扫描前 100 行推断列类型(默认 `infer_schema_length=100`),若某列在前 100 个命中行里全为 `None`,该列会被推断成 Null 类型,之后某行这一列出现数值时直接报 `ComputeError: could not append value ... to the builder`——是否触发取决于命中行的排列顺序,数据一变就可能复现,非常隐蔽;显式带 schema 同时保住 date/整型等原始列类型不被重新推断 - **返回所有匹配行,不要过滤 `latest`**;策略引擎会自动取最新日,回测引擎需要全区间命中 - 未声明 `filter_history()` 的策略走普通 `filter()` 路径,不受影响 - **date 类型参数必须先转换再与 `date` 列比较**:params 里的 `"type": "date"` 参数从 JSON 传来是字符串(如 `"2024-01-01"`),而数据中 `date` 列是 Polars Date 类型,**不能直接比较**,否则报错。必须先转换: @@ -288,7 +289,7 @@ REQUIRED_FEATURES = {"ma20", "momentum_20d", "vol_ratio_5d"} 6. Polars 策略允许 `import polars as pl` 和 `from datetime import date/datetime`;矩阵策略只允许 NumPy 和 `app.backtest.matrix` 协议/算子 7. 禁止使用 `open()`, `exec()`, `eval()`, `os`, `sys`, `subprocess` 8. **贴合用户需求优先**:第3/4节的指标列和信号列仅供参考,能用则用;如果用户需求需要自定义计算(如"前高""上次涨停价""N日内某个事件后X天"),直接在 `filter_history()` 中自行设计和计算,不需要局限于已有列 -9. `filter_history()` 中优先用 Polars 向量化语法;仅在复杂状态机无法清晰表达时,才用 `partition_by("symbol")` 逐股票分析 +9. `filter_history()` 中优先用 Polars 向量化语法;仅在复杂状态机无法清晰表达时,才用 `partition_by("symbol")` 逐股票分析;`to_dicts()` 逐行分析的命中行必须以 `pl.DataFrame(hits, schema=df.schema)` 重建,禁止裸 `pl.DataFrame(hits)`(dict 推断类型漂移,见第 2 节关键要点) ## 7. 策略示例 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1229247c..fa35ea34 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -26,13 +26,15 @@ dependencies = [ "pypinyin>=0.50", # 股票名拼音首字母搜索 (同花顺式 payh → 平安银行) # TickFlow 官方 SDK "tickflow[all]>=0.1.23", + # TdxQuant 内置数据源插件的原生 TCP 客户端 (wheel 自带跨平台库, 无需系统依赖) + "tdxaidata>=1.2.2", # Scheduling "apscheduler>=3.10", # Config "pyyaml>=6.0", "python-dotenv>=1.0", # AI(可选,但默认装上) - "openai>=1.40", # OpenAI 兼容适配器复用 openai SDK + "openai>=1.66", # OpenAI 兼容适配器复用 openai SDK; 1.66+ 提供 Responses API "httpx>=0.27", "platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写) "winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心) diff --git a/backend/tests/test_ai_probe.py b/backend/tests/test_ai_probe.py new file mode 100644 index 00000000..ed07ad03 --- /dev/null +++ b/backend/tests/test_ai_probe.py @@ -0,0 +1,161 @@ +"""AI 草稿连通性测试端点 (POST /api/settings/ai/test) 的契约测试。 + +核心契约: 测试只读表单值, 与保存完全解耦 —— 测试失败不得写任何配置, +状态栏也不得把未验证的草稿展示为已生效。失败以 200 + ok=false 回报, +由前端内联展示, 不触发全局 toast。 +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app import secrets_store +from app.api import settings as settings_api +from app.services import ai_provider + + +def test_probe_requires_base_url_model_and_key(monkeypatch): + monkeypatch.setattr(secrets_store, "get_ai_key", lambda: "") + + with pytest.raises(ValueError, match="API 地址"): + import asyncio + asyncio.run(ai_provider.probe_ai_config( + provider="openai_compat", api_key="sk-x", base_url="", model="m", + )) + with pytest.raises(ValueError, match="模型"): + import asyncio + asyncio.run(ai_provider.probe_ai_config( + provider="openai_compat", api_key="sk-x", base_url="https://x.example/v1", model="", + )) + with pytest.raises(RuntimeError, match="API Key"): + import asyncio + asyncio.run(ai_provider.probe_ai_config( + provider="openai_compat", api_key="", base_url="https://x.example/v1", model="m", + )) + + +def test_probe_codex_unavailable_raises(monkeypatch): + import asyncio + + monkeypatch.setattr(ai_provider, "codex_cli_available", lambda: False) + with pytest.raises(RuntimeError, match="Codex CLI 不可用"): + asyncio.run(ai_provider.probe_ai_config( + provider="codex_cli", api_key="", base_url="", model="", + )) + + +@pytest.mark.asyncio +async def test_probe_chat_protocol_uses_given_key_and_base(monkeypatch): + calls: list[dict] = [] + + class _Completions: + async def create(self, **kwargs): + calls.append(kwargs) + message = SimpleNamespace(content="OK") + return SimpleNamespace(choices=[SimpleNamespace(message=message)]) + + class _Chat: + completions = _Completions() + + captured_client: dict = {} + + class _Client: + def __init__(self, **kwargs): + captured_client.update(kwargs) + self.chat = _Chat() + + import openai + monkeypatch.setattr(openai, "AsyncOpenAI", _Client) + + text = await ai_provider.probe_ai_config( + provider="openai_compat", + api_key="sk-draft", + base_url="https://draft.example/v1", + model="draft-model", + ) + + assert text == "OK" + assert captured_client["api_key"] == "sk-draft" + assert "draft.example" in captured_client["base_url"] + assert calls == [{ + "model": "draft-model", + "messages": [{"role": "user", "content": "Reply exactly: OK"}], + }] + + +@pytest.mark.asyncio +async def test_probe_responses_protocol_calls_responses(monkeypatch): + calls: list[dict] = [] + + class _Responses: + async def create(self, **kwargs): + calls.append(kwargs) + return SimpleNamespace(output_text="ok") + + class _Client: + def __init__(self, **kwargs): + self.responses = _Responses() + + import openai + monkeypatch.setattr(openai, "AsyncOpenAI", _Client) + + text = await ai_provider.probe_ai_config( + provider="openai_compat", + api_key="sk-draft", + base_url="https://draft.example/v1", + model="draft-model", + protocol="responses", + ) + + assert text == "ok" + assert calls[0]["model"] == "draft-model" + assert calls[0]["input"] == [{"role": "user", "content": "Reply exactly: OK"}] + + +@pytest.mark.asyncio +async def test_test_endpoint_failure_reports_error_without_saving(monkeypatch): + """关键回归: 测试失败 → ok=false 回报, 且不调用任何 secrets 写入。""" + saved: list = [] + monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.append(updates)) + + async def boom(**kwargs): + raise RuntimeError("模型或接口地址不存在") + + monkeypatch.setattr(ai_provider, "probe_ai_config", boom) + + result = await settings_api.test_ai_settings(settings_api.AiTestIn( + provider="openai_compat", + base_url="https://bad.example/v1", + api_key="sk-bad", + model="no-such-model", + )) + + assert result["ok"] is False + assert "不存在" in result["error"] + assert saved == [] # 未写入任何配置 + + +@pytest.mark.asyncio +async def test_test_endpoint_falls_back_to_saved_key(monkeypatch): + """表单 Key 留空时回退已保存 Key (改模型/协议不必重输)。""" + seen: dict = {} + + async def fake_probe(**kwargs): + seen.update(kwargs) + return "OK" + + monkeypatch.setattr(ai_provider, "probe_ai_config", fake_probe) + monkeypatch.setattr(settings_api.secrets_store, "get_ai_key", lambda: "sk-saved") + + result = await settings_api.test_ai_settings(settings_api.AiTestIn( + provider="openai_compat", + base_url="https://api.example/v1", + api_key=None, + model="m1", + protocol="responses", + )) + + assert result["ok"] is True + assert seen["api_key"] == "sk-saved" + assert seen["protocol"] == "responses" diff --git a/backend/tests/test_ai_responses.py b/backend/tests/test_ai_responses.py new file mode 100644 index 00000000..32e798c2 --- /dev/null +++ b/backend/tests/test_ai_responses.py @@ -0,0 +1,613 @@ +"""Responses API 协议适配层 (app/services/ai_responses.py) 的契约测试。 + +伪事件流/伪响应全部用 SimpleNamespace 鸭子类型, 只对齐 SDK 暴露的字段名 +(event.type / event.delta / response.status / item.call_id 等) — 适配层 +本身也只依赖这些字段, 与 _iter_openai_text 的宽松风格一致。 +""" +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from app import secrets_store +from app.api import settings as settings_api +from app.config import settings as app_settings +from app.services import ai_provider, ai_responses + +# --------------------------------------------------------------------------- +# 事件 / 响应工厂 +# --------------------------------------------------------------------------- + + +def _evt(type_: str, **kw): + return SimpleNamespace(type=type_, **kw) + + +def _text_delta(s: str): + return _evt("response.output_text.delta", delta=s) + + +def _reasoning_delta(s: str = "内部推理"): + return _evt("response.reasoning_summary_text.delta", delta=s) + + +def _completed(output=None, status="completed", incomplete_reason=None): + resp = SimpleNamespace(status=status, output=list(output or []), error=None, output_text="") + if incomplete_reason is not None: + resp.incomplete_details = SimpleNamespace(reason=incomplete_reason) + return _evt("response.completed", response=resp) + + +async def _fake_stream(*events): + for e in events: + yield e + + +def _message_item(text: str): + part = SimpleNamespace(type="output_text", text=text) + return SimpleNamespace(type="message", content=[part]) + + +def _call_item(call_id: str, name: str, arguments: str): + return SimpleNamespace(type="function_call", call_id=call_id, name=name, arguments=arguments) + + +class _FakeApiError(Exception): + """带 status_code/body 的伪上游错误, 喂给重试判定逻辑。""" + + def __init__(self, status_code: int, body: dict): + super().__init__(json.dumps(body)) + self.status_code = status_code + self.body = body + + +def _fake_client(result=None, stream=None, error=None): + """伪 AsyncOpenAI: client.responses.create(**kwargs)。返回 (client, calls)。""" + calls: list[dict] = [] + + async def create(**kwargs): + calls.append(kwargs) + if error is not None: + raise error + if stream is not None: + return stream + return result + + return SimpleNamespace(responses=SimpleNamespace(create=create)), calls + + +# --------------------------------------------------------------------------- +# 消息 / 工具转换 +# --------------------------------------------------------------------------- + + +def test_chat_input_converts_tool_round_trip_shapes(): + messages = [ + {"role": "system", "content": "你是助手"}, + {"role": "user", "content": "查一下茅台"}, + { + "role": "assistant", + "content": "先查行情", + "tool_calls": [ + {"id": "call_1", "type": "function", + "function": {"name": "get_quote", "arguments": '{"symbol": "600519"}'}}, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": '{"ok": true}'}, + ] + + items = ai_responses.chat_input(messages) + + assert items == [ + {"role": "system", "content": "你是助手"}, + {"role": "user", "content": "查一下茅台"}, + {"role": "assistant", "content": "先查行情"}, + {"type": "function_call", "call_id": "call_1", "name": "get_quote", + "arguments": '{"symbol": "600519"}'}, + {"type": "function_call_output", "call_id": "call_1", "output": '{"ok": true}'}, + ] + + +def test_chat_input_plain_assistant_and_missing_role(): + items = ai_responses.chat_input([ + {"role": "assistant", "content": "纯文本回复"}, + {"content": "无角色消息"}, + ]) + assert items == [ + {"role": "assistant", "content": "纯文本回复"}, + {"role": "user", "content": "无角色消息"}, + ] + + +def test_responses_tools_flattens_and_passes_through_flat(): + chat_schema = [{ + "type": "function", + "function": {"name": "get_quote", "description": "查行情", "parameters": {"type": "object"}}, + }] + flat_schema = [{ + "type": "function", + "name": "get_kline", + "parameters": {"type": "object"}, + }] + + assert ai_responses.responses_tools(chat_schema) == [{ + "type": "function", "name": "get_quote", + "description": "查行情", "parameters": {"type": "object"}, + }] + # 已扁平的 schema 原样接受(个别网关直接吐扁平形态); description 缺省可省 + assert ai_responses.responses_tools(flat_schema) == [{ + "type": "function", "name": "get_kline", "parameters": {"type": "object"}, + }] + # 非 dict / 无 function 体的条目静默跳过 + assert ai_responses.responses_tools([None, {"type": "other"}]) == [] + + +# --------------------------------------------------------------------------- +# kwargs 与自适应重试 +# --------------------------------------------------------------------------- + + +def test_responses_kwargs_maps_param_names(monkeypatch): + stored = {"ai_provider": "openai", "ai_reasoning_effort": "medium"} + monkeypatch.setattr(secrets_store, "load", lambda: stored) + + kwargs = ai_responses.responses_kwargs(temperature=0.3, max_tokens=1200) + assert kwargs == { + "max_output_tokens": 1200, + "temperature": 0.3, + "reasoning": {"effort": "medium"}, + } + + # 兼容 provider 不下发 reasoning (对齐 _openai_kwargs 的 provider 门控) + stored["ai_provider"] = "openai_compat" + kwargs = ai_responses.responses_kwargs(temperature=None, max_tokens=1200) + assert kwargs == {"max_output_tokens": 1200} + + +def test_responses_kwargs_omits_none_max_tokens(): + assert ai_responses.responses_kwargs(temperature=0.5, max_tokens=None) == {"temperature": 0.5} + + +def test_responses_kwargs_carries_tools(): + kwargs = ai_responses.responses_kwargs( + temperature=None, max_tokens=None, + tools=[{"type": "function", "function": {"name": "f", "parameters": {"type": "object"}}}], + ) + assert kwargs["tools"] == [{"type": "function", "name": "f", "parameters": {"type": "object"}}] + + +def test_responses_retry_kwargs_drops_rejected_params(): + temperature_reject = _FakeApiError(400, {"error": {"message": "temperature only 1 is allowed"}}) + reasoning_reject = _FakeApiError(400, {"error": {"message": "Unsupported parameter: 'reasoning'"}}) + + retry = ai_responses.responses_retry_kwargs( + temperature_reject, {"temperature": 0.3, "reasoning": {"effort": "high"}}, + ) + assert retry == {"reasoning": {"effort": "high"}} + + retry = ai_responses.responses_retry_kwargs( + reasoning_reject, {"temperature": 0.3, "reasoning": {"effort": "high"}}, + ) + assert retry == {"temperature": 0.3} + + # 非 400 / 无关 400 不重试 + assert ai_responses.responses_retry_kwargs( + _FakeApiError(500, {"error": {"message": "boom"}}), {"temperature": 0.3}, + ) is None + assert ai_responses.responses_retry_kwargs( + _FakeApiError(400, {"error": {"message": "model not found"}}), {"temperature": 0.3}, + ) is None + + +# --------------------------------------------------------------------------- +# iter_text: 事件流归一化 (与 _iter_openai_text 的防御等价) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_iter_text_yields_deltas_and_ignores_reasoning(): + stream = _fake_stream( + _reasoning_delta(), + _text_delta("完整"), + _text_delta("正文"), + _completed(), + ) + assert [p async for p in ai_responses.iter_text(stream)] == ["完整", "正文"] + + +@pytest.mark.asyncio +async def test_iter_text_done_event_fallback_without_deltas(): + """个别网关只发 output_text.done 不发 delta: done 全量兜底产出一次。""" + stream = _fake_stream( + _evt("response.output_text.done", text="一次成型"), + _completed(), + ) + assert [p async for p in ai_responses.iter_text(stream)] == ["一次成型"] + + +@pytest.mark.asyncio +async def test_iter_text_rejects_length_truncated_partial(): + stream = _fake_stream( + _text_delta("未完成正文"), + _completed(status="incomplete", incomplete_reason="max_output_tokens"), + ) + with pytest.raises(RuntimeError, match="输出达到长度上限"): + async for _ in ai_responses.iter_text(stream): + pass + + +@pytest.mark.asyncio +async def test_iter_text_rejects_reasoning_only_length_exhaustion(): + stream = _fake_stream( + _reasoning_delta(), + _completed(status="incomplete", incomplete_reason="max_output_tokens"), + ) + with pytest.raises(RuntimeError, match="推理达到输出长度上限"): + async for _ in ai_responses.iter_text(stream): + pass + + +@pytest.mark.asyncio +async def test_iter_text_rejects_stream_without_content(): + with pytest.raises(RuntimeError, match="未返回正文内容"): + async for _ in ai_responses.iter_text(_fake_stream(_completed())): + pass + + +@pytest.mark.asyncio +async def test_iter_text_failed_event_raises_with_detail(): + resp = SimpleNamespace( + status="failed", output=[], error=SimpleNamespace(message="quota exceeded"), + ) + stream = _fake_stream(_evt("response.failed", response=resp)) + with pytest.raises(RuntimeError, match="quota exceeded"): + async for _ in ai_responses.iter_text(stream): + pass + + +# --------------------------------------------------------------------------- +# 非流式: run_text / run_message_once +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_run_text_extracts_text_with_and_without_helper_attr(monkeypatch): + resp = SimpleNamespace( + status="completed", + output_text="聚合文本", + output=[_message_item("聚合文本")], + error=None, + ) + client, calls = _fake_client(result=resp) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + assert await ai_responses.run_text( + [{"role": "user", "content": "hi"}], temperature=0.3, max_tokens=100, timeout=5, + ) == "聚合文本" + assert calls[0]["input"] == [{"role": "user", "content": "hi"}] + assert calls[0]["max_output_tokens"] == 100 + + # output_text 便利属性缺失时逐 item 聚合 + resp2 = SimpleNamespace(status="completed", output=[_message_item("逐条"), _message_item("聚合")]) + client2, _ = _fake_client(result=resp2) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client2) + assert await ai_responses.run_text( + [{"role": "user", "content": "hi"}], temperature=None, max_tokens=None, timeout=5, + ) == "逐条聚合" + + +@pytest.mark.asyncio +async def test_run_text_incomplete_raises(monkeypatch): + resp = SimpleNamespace( + status="incomplete", + output_text="半截", + output=[], + incomplete_details=SimpleNamespace(reason="max_output_tokens"), + ) + client, _ = _fake_client(result=resp) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + with pytest.raises(RuntimeError, match="输出达到长度上限"): + await ai_responses.run_text( + [{"role": "user", "content": "hi"}], temperature=None, max_tokens=10, timeout=5, + ) + + +@pytest.mark.asyncio +async def test_run_message_once_returns_chat_shape_calls(monkeypatch): + resp = SimpleNamespace( + status="completed", + output_text="", + output=[_call_item("call_1", "get_quote", '{"symbol":"600519"}')], + ) + client, calls = _fake_client(result=resp) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + message = await ai_responses.run_message_once( + [{"role": "user", "content": "查"}], + temperature=0.3, max_tokens=None, timeout=5, + tools=[{"type": "function", "function": {"name": "get_quote"}}], + ) + tc = message.tool_calls[0] + assert (tc.id, tc.function.name, tc.function.arguments) == ( + "call_1", "get_quote", '{"symbol":"600519"}', + ) + # 工具 schema 以扁平形态下发 + assert calls[0]["tools"] == [{"type": "function", "name": "get_quote"}] + + +@pytest.mark.asyncio +async def test_run_message_once_retries_without_rejected_temperature(monkeypatch): + ok_resp = SimpleNamespace(status="completed", output_text="OK", output=[_message_item("OK")]) + error = _FakeApiError(400, {"error": {"message": "temperature only 1 is allowed"}}) + client, calls = _fake_client(result=ok_resp, error=error) + # 首次抛 400, 摘掉 temperature 后重试成功 + state = {"raised": False} + + async def create(**kwargs): + calls.append(kwargs) + if not state["raised"]: + state["raised"] = True + raise error + return ok_resp + + client = SimpleNamespace(responses=SimpleNamespace(create=create)) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + text = await ai_responses.run_message_once( + [{"role": "user", "content": "hi"}], temperature=0.3, max_tokens=None, timeout=5, + ) + assert text.content == "OK" + assert calls[0]["temperature"] == 0.3 + assert "temperature" not in calls[1] + + +# --------------------------------------------------------------------------- +# stream_round: 助手工具轮事件流 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stream_round_accumulates_tool_calls_from_fragments(monkeypatch): + stream = _fake_stream( + _evt("response.output_item.added", + item=SimpleNamespace(id="fc_1", type="function_call", call_id="call_1", + name="get_quote", arguments="")), + _text_delta("先查一下"), + _evt("response.function_call_arguments.delta", item_id="fc_1", delta='{"sym'), + _evt("response.function_call_arguments.delta", item_id="fc_1", delta='bol":"600519"}'), + _evt("response.output_item.done", + item=SimpleNamespace(id="fc_1", type="function_call", call_id="call_1", + name="get_quote", arguments='{"symbol":"600519"}')), + _completed(output=[_call_item("call_1", "get_quote", '{"symbol":"600519"}')]), + ) + client, _ = _fake_client(stream=stream) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + events = [e async for e in ai_responses.stream_round( + [{"role": "user", "content": "查茅台"}], + [{"type": "function", "function": {"name": "get_quote"}}], + temperature=0.3, timeout=5, + )] + + assert events[0] == {"type": "text", "delta": "先查一下"} + assert events[-1] == { + "type": "round_end", + "tool_calls": [{"id": "call_1", "name": "get_quote", "arguments": '{"symbol":"600519"}'}], + "finish_reason": "tool_calls", + } + + +@pytest.mark.asyncio +async def test_stream_round_completed_output_is_authoritative(monkeypatch): + """completed.response.output 优先于分片累积(网关乱序/缺 done 时兜底)。""" + stream = _fake_stream( + _evt("response.output_item.added", + item=SimpleNamespace(id="fc_1", type="function_call", call_id="call_1", + name="", arguments="")), + _evt("response.function_call_arguments.delta", item_id="fc_1", delta="半截参数"), + _completed(output=[_call_item("call_x", "get_kline", '{"days":5}')]), + ) + client, _ = _fake_client(stream=stream) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + events = [e async for e in ai_responses.stream_round( + [{"role": "user", "content": "查K线"}], [], temperature=None, timeout=5, + )] + assert events[-1]["tool_calls"] == [ + {"id": "call_x", "name": "get_kline", "arguments": '{"days":5}'}, + ] + + +@pytest.mark.asyncio +async def test_stream_round_text_only_round_ends_empty(monkeypatch): + stream = _fake_stream( + _text_delta("回答完毕"), + _completed(), + ) + client, _ = _fake_client(stream=stream) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + events = [e async for e in ai_responses.stream_round( + [{"role": "user", "content": "hi"}], [], temperature=None, timeout=5, + )] + assert events == [ + {"type": "text", "delta": "回答完毕"}, + {"type": "round_end", "tool_calls": [], "finish_reason": "stop"}, + ] + + +@pytest.mark.asyncio +async def test_stream_round_length_truncation_without_calls_raises(monkeypatch): + stream = _fake_stream( + _text_delta("半截"), + _completed(status="incomplete", incomplete_reason="max_output_tokens"), + ) + client, _ = _fake_client(stream=stream) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + with pytest.raises(RuntimeError, match="输出达到长度上限"): + async for _ in ai_responses.stream_round( + [{"role": "user", "content": "hi"}], [], temperature=None, timeout=5, + ): + pass + + +@pytest.mark.asyncio +async def test_stream_round_empty_response_raises(monkeypatch): + client, _ = _fake_client(stream=_fake_stream(_completed())) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + with pytest.raises(RuntimeError, match="未返回正文内容"): + async for _ in ai_responses.stream_round( + [{"role": "user", "content": "hi"}], [], temperature=None, timeout=5, + ): + pass + + +# --------------------------------------------------------------------------- +# 协议配置与入口接线 +# --------------------------------------------------------------------------- + + +def test_current_ai_protocol_config_and_fallback(monkeypatch): + stored: dict = {} + monkeypatch.setattr(secrets_store, "load", lambda: stored) + + assert ai_provider.current_ai_protocol() == "chat_completions" + + stored["ai_protocol"] = "responses" + assert ai_provider.current_ai_protocol() == "responses" + assert ai_provider.is_responses_protocol() is True + + stored["ai_protocol"] = "bogus" + assert ai_provider.current_ai_protocol() == "chat_completions" + + # Codex CLI 恒为 chat_completions, 存储值不生效 + stored["ai_provider"] = "codex_cli" + stored["ai_protocol"] = "responses" + assert ai_provider.current_ai_protocol() == "chat_completions" + + +@pytest.mark.asyncio +async def test_generate_ai_text_routes_to_responses(monkeypatch): + async def fake_run_text(messages, *, temperature, max_tokens, timeout): + return "responses 回复" + + monkeypatch.setattr(secrets_store, "load", lambda: {"ai_protocol": "responses"}) + monkeypatch.setattr(ai_responses, "run_text", fake_run_text) + + text = await ai_provider.generate_ai_text( + [{"role": "user", "content": "hi"}], temperature=0.3, max_tokens=100, timeout=5, + ) + assert text == "responses 回复" + + +@pytest.mark.asyncio +async def test_stream_ai_text_routes_to_responses(monkeypatch): + async def fake_stream_text(messages, *, temperature, max_tokens, timeout): + yield "流式" + yield "回复" + + monkeypatch.setattr(secrets_store, "load", lambda: {"ai_protocol": "responses"}) + monkeypatch.setattr(ai_responses, "stream_text", fake_stream_text) + + chunks = [c async for c in ai_provider.stream_ai_text( + [{"role": "user", "content": "hi"}], temperature=0.5, max_tokens=100, timeout=5, + )] + assert chunks == ["流式", "回复"] + + +@pytest.mark.asyncio +async def test_assistant_round_routes_to_responses(monkeypatch): + from app.custom.assistant import streaming + + async def fake_stream_round(messages, tools, *, temperature, timeout): + yield {"type": "text", "delta": "助手回复"} + yield {"type": "round_end", "tool_calls": [], "finish_reason": "stop"} + + monkeypatch.setattr(secrets_store, "load", lambda: {"ai_protocol": "responses"}) + monkeypatch.setattr(streaming, "is_responses_protocol", lambda: True) + monkeypatch.setattr(streaming.ai_responses, "stream_round", fake_stream_round) + + events = [e async for e in streaming.stream_openai_round( + [{"role": "user", "content": "hi"}], [], temperature=0.3, timeout=5, + )] + assert events == [ + {"type": "text", "delta": "助手回复"}, + {"type": "round_end", "tool_calls": [], "finish_reason": "stop"}, + ] + + +@pytest.mark.asyncio +async def test_stream_round_awaits_async_close(monkeypatch): + """SDK 2.x 的 AsyncStream.close 是协程: 必须真正 await, 而非 to_thread 丢弃。""" + closed: list[bool] = [] + + class _ClosableStream: + def __init__(self, events): + self._inner = _fake_stream(*events) + + def __aiter__(self): + return self + + async def __anext__(self): + return await self._inner.__anext__() + + async def close(self): + closed.append(True) + + stream = _ClosableStream([_text_delta("OK"), _completed()]) + client, _ = _fake_client(stream=stream) + monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client) + monkeypatch.setattr(secrets_store, "load", lambda: {}) + + events = [e async for e in ai_responses.stream_round( + [{"role": "user", "content": "hi"}], [], temperature=None, timeout=5, + )] + assert closed == [True] + assert events[-1]["finish_reason"] == "stop" + + +def test_save_ai_settings_persists_protocol(monkeypatch): + saved: dict = {} + monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.update(updates)) + monkeypatch.setattr(settings_api.secrets_store, "load", lambda: saved) + original = app_settings.ai_protocol + try: + result = settings_api.save_ai_settings(settings_api.AiSettingsIn( + provider="openai_compat", + base_url="https://example.com/v1", + api_key="sk-test", + model="gpt-x", + protocol="responses", + )) + assert saved["ai_protocol"] == "responses" + assert result["ai_protocol"] == "responses" + assert ai_provider.current_ai_protocol() == "responses" + finally: + app_settings.ai_protocol = original + + +def test_save_ai_settings_rejects_unknown_protocol(monkeypatch): + saved: dict = {} + monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.update(updates)) + monkeypatch.setattr(settings_api.secrets_store, "load", lambda: saved) + + from fastapi import HTTPException + with pytest.raises(HTTPException): + settings_api.save_ai_settings(settings_api.AiSettingsIn( + provider="openai_compat", protocol="grpc", + )) + assert "ai_protocol" not in saved diff --git a/backend/tests/test_custom_assistant_sessions.py b/backend/tests/test_custom_assistant_sessions.py new file mode 100644 index 00000000..58745351 --- /dev/null +++ b/backend/tests/test_custom_assistant_sessions.py @@ -0,0 +1,97 @@ +"""AI 助手会话历史服务端存储 (跨设备同步) 的服务与 API 测试。 + +契约: GET 空结构、PUT 保存往返、结构裁剪 (无 id 会话剔除 / 消息截尾 200 / +会话上限 20)、损坏文件按空处理、API 挂载在 assistant 扩展路由下。 +""" +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.custom.assistant import sessions as assistant_sessions +from app.custom.assistant.routes import build_router + + +@pytest.fixture +def client(tmp_path: Path) -> TestClient: + app = FastAPI() + app.include_router(build_router()) + app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)) + return TestClient(app) + + +def _session(sid: str, n_msgs: int = 1) -> dict: + return { + "id": sid, + "title": f"会话{sid}", + "createdAt": 1700000000000, + "messages": [ + {"id": f"m{i}", "role": "user", "content": f"hi {i}", "ts": i} + for i in range(n_msgs) + ], + } + + +def test_api_roundtrip_and_empty_state(client: TestClient) -> None: + assert client.get("/api/custom/assistant/sessions").json() == { + "sessions": [], + "activeId": "", + } + + r = client.put( + "/api/custom/assistant/sessions", + json={"sessions": [_session("s1"), _session("s2")], "activeId": "s2"}, + ) + assert r.status_code == 200 and r.json() == {"ok": True, "sessions": 2} + + got = client.get("/api/custom/assistant/sessions").json() + assert [s["id"] for s in got["sessions"]] == ["s1", "s2"] + assert got["activeId"] == "s2" + + +def test_sanitize_trims_and_drops(client: TestClient, tmp_path: Path) -> None: + # 无 id/非字符串 id 的会话剔除 + bad = [{"title": "no id"}, {"id": 123, "messages": []}] + # 消息超 200 截尾 + long = _session("long", n_msgs=250) + # 会话超 20 截断 (服务层: API 层 max_length=20 会先拒绝更大的载荷) + many = [_session(f"s{i}") for i in range(25)] + + n = assistant_sessions.save_sessions( + tmp_path, {"sessions": [*bad, long, *many], "activeId": "s0"} + ) + assert n == assistant_sessions.MAX_SESSIONS + + got = assistant_sessions.load_sessions(tmp_path) + ids = [s["id"] for s in got["sessions"]] + assert len(ids) == 20 + assert "long" in ids # 输入顺序靠前, 保留 + assert len(next(s for s in got["sessions"] if s["id"] == "long")["messages"]) == 200 + + # API 层: 结构非法的会话静默剔除, 合法照常保存 + r = client.put( + "/api/custom/assistant/sessions", + json={"sessions": [*bad, _session("ok1")], "activeId": "ok1"}, + ) + assert r.status_code == 200 and r.json() == {"ok": True, "sessions": 1} + ids2 = [s["id"] for s in client.get("/api/custom/assistant/sessions").json()["sessions"]] + assert ids2 == ["ok1"] + + +def test_corrupted_file_treated_as_empty(tmp_path: Path) -> None: + p = tmp_path / "user_data" / "assistant_sessions.json" + p.parent.mkdir(parents=True) + p.write_text("{not json", encoding="utf-8") + assert assistant_sessions.load_sessions(tmp_path) == {"sessions": [], "activeId": ""} + + +def test_put_rejects_oversized_session_list(client: TestClient) -> None: + r = client.put( + "/api/custom/assistant/sessions", + json={"sessions": [_session(f"s{i}") for i in range(21)]}, + ) + assert r.status_code == 422 # 超过 max_length=20, 防御异常客户端 diff --git a/backend/tests/test_emotion_score.py b/backend/tests/test_emotion_score.py new file mode 100644 index 00000000..f61de9fc --- /dev/null +++ b/backend/tests/test_emotion_score.py @@ -0,0 +1,200 @@ +"""情绪评分口径 — 默认口径还原 + 配置归一化契约。 + +「默认口径必须与历史硬编码算法逐位一致」是这里的核心约束: 情绪分是看板与 +复盘快照的对外数字, 改动计算模块不能悄悄挪动它。原算法已从 +market_overview_builder 移除, 故本文件内联一份等价实现做对照。 +""" +from __future__ import annotations + +import math +import random + +from app.services.emotion_score import ( + DEFAULT_CONFIG, + DIMENSION_ORDER, + compute, + dimension_value, + label_of, + merge_config, + public_config, + score_of, +) + + +def _legacy_score(value: float, low: float, high: float) -> int: + """改造前 market_overview_builder._score 的逐字复制。""" + if high <= low: + return 50 + if not math.isfinite(value): + return 50 + return max(0, min(100, round((value - low) / (high - low) * 100))) + + +def _legacy_radar_and_score(m: dict) -> tuple[list[dict], int]: + """改造前看板情绪雷达的逐字复制 (含 mainline 预计算那一版)。""" + radar = [ + {"key": "index", "label": "指数", "value": _legacy_score(m["avg_index_pct"], -2.5, 2.5)}, + {"key": "profit", "label": "赚钱", "value": round( + _legacy_score(m["up_pct"], 20, 80) * 0.45 + + _legacy_score(m["avg_pct"], -0.02, 0.02) * 0.25 + + _legacy_score(m["median_pct"], -0.02, 0.02) * 0.20 + + _legacy_score(m["strong_diff_pct"], -8, 8) * 0.10)}, + {"key": "money", "label": "量能", "value": round( + _legacy_score(m["avg_vol_ratio"], 0.6, 1.8) * 0.70 + + _legacy_score(m["high_vol_pct"], 2, 12) * 0.30)}, + {"key": "speculation", "label": "投机", "value": round( + _legacy_score(m["limit_up"], 5, 90) * 0.25 + + _legacy_score(m["seal_rate"], 30, 85) * 0.35 + + _legacy_score(m["max_boards"], 1, 8) * 0.25 + + _legacy_score(m["tier2_count"], 0, 30) * 0.15)}, + {"key": "resilience", "label": "抗跌", "value": 100 - round( + _legacy_score(m["down_pct"], 20, 80) * 0.55 + + _legacy_score(m["strong_down_pct"], 1, 12) * 0.45)}, + {"key": "mainline", "label": "主线", "value": m["mainline_score"]}, + ] + return radar, round(sum(r["value"] for r in radar) / len(radar)) + + +def _sample(rng: random.Random) -> dict: + m = { + "avg_index_pct": rng.uniform(-6, 6), "up_pct": rng.uniform(0, 100), + "avg_pct": rng.uniform(-0.05, 0.05), "median_pct": rng.uniform(-0.05, 0.05), + "strong_diff_pct": rng.uniform(-20, 20), "avg_vol_ratio": rng.uniform(0.3, 3), + "high_vol_pct": rng.uniform(0, 40), "limit_up": rng.randint(0, 200), + "seal_rate": rng.uniform(0, 100), "max_boards": rng.randint(0, 12), + "tier2_count": rng.randint(0, 60), "down_pct": rng.uniform(0, 100), + "strong_down_pct": rng.uniform(0, 25), "mainline_avg": rng.uniform(-0.03, 0.08), + "mainline_cover_pct": rng.uniform(0, 30), + } + m["mainline_score"] = round( + _legacy_score(m["mainline_avg"], -0.005, 0.03) * 0.65 + + _legacy_score(m["mainline_cover_pct"], 1, 12) * 0.35) + return m + + +def test_default_matches_legacy_bit_for_bit() -> None: + """出厂口径与历史硬编码算法逐位一致 (随机 5000 组)。 + + 取整顺序在国内很敏感: 抗跌维度必须「先取整再取反」(100 - round(x)), + 写成 round(100 - x) 会在 x 的小数部分恰为 .5 时差 1 分。 + """ + rng = random.Random(7) + for _ in range(5000): + m = _sample(rng) + legacy_radar, legacy_score = _legacy_radar_and_score(m) + res = compute(m, None) + assert res["radar"] == legacy_radar, f"雷达不一致: {m}" + assert res["score"] == legacy_score, f"总分不一致: {m}" + + +def test_label_mapping_matches_legacy_over_full_range() -> None: + """档位映射覆盖 0..100 全整数区间, 与历史阈值一致。""" + def legacy_label(score: int) -> str: + if score >= 70: + return "强势" + if score >= 55: + return "偏暖" + if score >= 45: + return "震荡" + if score >= 30: + return "偏冷" + return "冰点" + + th = DEFAULT_CONFIG["thresholds"] + for score in range(101): + assert label_of(score, th) == legacy_label(score), f"{score} 档位不一致" + + +def test_score_of_handles_non_finite() -> None: + """非有限输入取中性 50, 不得抛异常打断看板计算。""" + for bad in (float("nan"), float("inf"), None, "abc"): + assert score_of(bad, 0, 1) == 50 + assert score_of(0.5, 0, 1) == 50 + assert score_of(5, 0, 1) == 100 # 超出上界夹到 100 + assert score_of(-5, 0, 1) == 0 # 低于下界夹到 0 + + +def test_merge_config_fills_defaults_and_ignores_unknown() -> None: + """部分配置 → 其余字段补默认; 未知 key 丢弃 (不因脏配置崩)。""" + merged = merge_config({ + "dimensions": { + "index": {"weight": 3.0, "terms": [{"key": "avg_index_pct", "low": -5}]}, + "no_such_dim": {"weight": 9}, + }, + "thresholds": {"strong": 80}, + "unknown_top": 1, + }) + assert merged["dimensions"]["index"]["weight"] == 3.0 + assert merged["dimensions"]["index"]["terms"][0]["low"] == -5 + assert merged["dimensions"]["index"]["terms"][0]["high"] == 2.5 # 未提及 → 默认 + assert "no_such_dim" not in merged["dimensions"] + assert "unknown_top" not in merged + assert merged["thresholds"]["strong"] == 80 + assert merged["thresholds"]["warm"] == DEFAULT_CONFIG["thresholds"]["warm"] + + +def test_thresholds_normalized_to_descending() -> None: + """用户把阈值填乱序 → 自动重排为降序, 保证档位区间非空。""" + merged = merge_config({"thresholds": {"strong": 20, "warm": 90, "neutral": 50, "cold": 10}}) + th = merged["thresholds"] + assert th["strong"] >= th["warm"] >= th["neutral"] >= th["cold"] + + +def test_thresholds_clamped_to_range() -> None: + """阈值越界 (负数/超 100) 夹回 0..100。""" + merged = merge_config({"thresholds": {"strong": 999, "cold": -50}}) + th = merged["thresholds"] + assert 0 <= th["cold"] <= 100 + assert 0 <= th["strong"] <= 100 + + +def test_dimension_weight_affects_score() -> None: + """改维度权重必须改变总分 (否则配置形同虚设)。""" + m = _sample(random.Random(1)) + base = compute(m, None)["score"] + heavy = merge_config({"dimensions": {"index": {"weight": 10.0}}}) + assert compute(m, heavy)["score"] != base + + +def test_term_weight_within_dimension_is_normalized() -> None: + """子项权重和不为 1 时按比例归一化, 不因总和变化而畸变。 + + 把「指数」维度 (单子项) 的权重从 1 改成 7, 该维度得分应不变 —— 单子项 + 归一化后权重的绝对大小不影响自身得分。 + """ + dim = merge_config(None)["dimensions"]["index"] + before = dimension_value(dim, {"avg_index_pct": 1.0}) + scaled = merge_config({"dimensions": {"index": {"terms": [ + {"key": "avg_index_pct", "weight": 7.0}]}}})["dimensions"]["index"] + assert dimension_value(scaled, {"avg_index_pct": 1.0}) == before + + +def test_missing_metric_takes_neutral() -> None: + """指标缺失 → 该维度取中性 50 (与历史「无主线数据取 50」一致)。""" + dim = merge_config(None)["dimensions"]["mainline"] + assert dimension_value(dim, {}) == 50 + + +def test_public_config_shape() -> None: + """配置接口返回结构稳定 (前端表单完全依赖它渲染)。""" + payload = public_config(None) + assert payload["dimension_order"] == list(DIMENSION_ORDER) + assert set(payload["config"]) == {"dimensions", "thresholds"} + assert payload["defaults"] == DEFAULT_CONFIG + names = [l["name"] for l in payload["labels"]] + assert "强势" in names and "冰点" in names + # 每个维度都带 label / weight / terms, 每个 term 带完整六字段 + for key in DIMENSION_ORDER: + dim = payload["config"]["dimensions"][key] + assert dim["label"] and "weight" in dim and dim["terms"] + for term in dim["terms"]: + assert set(term) == {"key", "label", "weight", "low", "high", "unit"} + + +def test_compute_returns_dimension_details() -> None: + """compute 额外返回 dimensions (带权重与 invert), 供配置面板展示。""" + res = compute(_sample(random.Random(3)), None) + assert len(res["dimensions"]) == len(DIMENSION_ORDER) + resilience = next(d for d in res["dimensions"] if d["key"] == "resilience") + assert resilience["invert"] is True + assert res["label"] in ("强势", "偏暖", "震荡", "偏冷", "冰点") diff --git a/backend/tests/test_ext_fanout.py b/backend/tests/test_ext_fanout.py new file mode 100644 index 00000000..d620e05c --- /dev/null +++ b/backend/tests/test_ext_fanout.py @@ -0,0 +1,293 @@ +"""通用两级拉取 (ext_fanout) 单测 — 目录 → 逐条目明细 → 反转为「成员 → 类别」。 + +全程 httpx.MockTransport, 不出网。覆盖: 信封解包报错、反转聚合与目录顺序、 +单条目失败容忍(重试后跳过)、全部条目失败报错、必填项校验、 +以及端到端落盘 (config.json + parquet, 走 fetch_and_ingest 通用路径)。 +""" +from __future__ import annotations + +import asyncio +from pathlib import Path + +import httpx +import polars as pl +import pytest + +from app.services.ext_data import ( + ExtConfig, + ExtConfigStore, + ExtField, + PullConfig, + rows_to_parquet, +) +from app.services.ext_fanout import clean_label, fetch_fanout_rows + +_CATALOG = "/api/a-share-index/catalog/ths-index-list" +_CONSTITUENTS = "/api/a-share-index/constituents/ths-stock-list" +_AUTH_HEADER = {"X-api-key": "test-key"} + + +def _ok(items: list[dict]) -> httpx.Response: + return httpx.Response( + 200, json={"code": 0, "message": "success", "data": {"item": items}} + ) + + +def _mock_transport( + catalog_by_tag: dict[str, list[dict]], + constituents_by_code: dict[str, list[dict]], + fail_codes: tuple[str, ...] = (), +) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == _CATALOG: + tag = request.url.params.get("tag", "cn_concept") + return _ok(catalog_by_tag.get(tag, [])) + if request.url.path == _CONSTITUENTS: + code = request.url.params.get("thscode", "") + if code in fail_codes: + raise httpx.ConnectError(f"injected failure: {code}") + return _ok(constituents_by_code.get(code, [])) + return httpx.Response(404, text="not found") + + return httpx.MockTransport(handler) + + +_CATALOG_CN_CONCEPT = [ + {"thscode": "B1.TI", "name": "白酒概念"}, + {"thscode": "B2.TI", "name": "新能源汽车"}, +] +_CONSTITUENTS_BY_CODE = { + "B1.TI": [ + {"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台"}, + {"thscode": "000858.SZ", "ticker": "000858", "name": "五粮液"}, + ], + "B2.TI": [ + {"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台"}, + {"thscode": "300750.SZ", "ticker": "300750", "name": "宁德时代"}, + ], +} + + +def _concept_config(url_tag: str = "cn_concept", **fanout_overrides) -> ExtConfig: + """测试用概念表配置: 结构与出厂预设同构, 便于断言 schema。""" + fanout = { + "url": f"https://fuyao.aicubes.cn{_CONSTITUENTS}", + "param": "thscode", + "id_field": "thscode", + "label_field": "name", + "items_path": "data.item", + "member_id_field": "thscode", + "member_label_field": "name", + "symbol_col": "股票代码", + "name_col": "股票简称", + "value_col": "所属概念", + "separator": ";", + "concurrency": 8, + "retries": 1, + } + fanout.update(fanout_overrides) + return ExtConfig( + id="ext_gn_ths", + label="扩展概念", + mode="snapshot", + fields=[ + ExtField("symbol", "string", "标的代码"), + ExtField("code", "string", "代码"), + ExtField("股票代码", "string", "股票代码"), + ExtField("股票简称", "string", "股票简称"), + ExtField("所属概念", "string", "所属概念"), + ], + symbol_map={"type": "mapped", "col": "股票代码"}, + code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"}, + pull=PullConfig( + url=f"https://fuyao.aicubes.cn{_CATALOG}?tag={url_tag}", + response_path="data.item", + auth={"type": "header", "header": "X-api-key"}, + fanout=fanout, + ), + ) + + +@pytest.fixture +def mock_http(monkeypatch: pytest.MonkeyPatch): + """把 ext_pull 的出站请求替换为 MockTransport (鉴权与标识头口径保持不变)。 + + 同时注入测试 Key: _apply_auth 在缺凭据时 fail-closed 报错, 这是生产要的 + 行为, 测 fanout 逻辑时需补上凭据。 + """ + import app.services.ext_pull as ext_pull + import app.services.ext_data as ext_data + + monkeypatch.setattr( + ext_data, "get_ext_api_key", lambda config_id: "test-key" + ) + + def install(transport: httpx.MockTransport): + real_request = ext_pull._request_json + + async def patched(pull, config_id, day=None, extra_params=None, *, url_override=None): + # 复用真实实现的 URL/鉴权拼装, 只把 transport 换成 Mock + from app.services.ext_pull import _apply_auth, _append_query_params, _with_date_param + url = url_override or ( + _with_date_param(pull.url, pull.date_param, day, pull.date_format) if day else pull.url + ) + if extra_params: + url = _append_query_params(url, extra_params) + headers = {"User-Agent": "test", "X-TSP-Client": "tick-stock-panel"} + url = _apply_auth(config_id, pull.auth, url, headers) + async with httpx.AsyncClient(timeout=30, transport=transport) as client: + resp = await client.request(pull.method.upper(), url, headers=headers) + resp.raise_for_status() + return resp.json() + + monkeypatch.setattr(ext_pull, "_request_json", patched) + return real_request + + return install + + +# ---- 值清洗 ---- + + +def test_clean_label_drops_missing_value_placeholders() -> None: + for placeholder in (None, float("nan"), "nan", "None", " null ", ""): + assert clean_label(placeholder) == "" + assert clean_label(" 银行 ") == "银行" + + +# ---- 必填项校验 ---- + + +def test_fanout_missing_required_keys_raises() -> None: + config = _concept_config() + config.pull.fanout.pop("member_id_field") + with pytest.raises(ValueError, match="缺少必填项"): + asyncio.run(fetch_fanout_rows(config)) + + +# ---- 信封解包 ---- + + +def test_fanout_envelope_error_raises(mock_http) -> None: + mock_http( + httpx.MockTransport( + lambda r: httpx.Response(200, json={"code": 2001, "message": "invalid key"}) + ) + ) + with pytest.raises(ValueError, match="code=2001"): + asyncio.run(fetch_fanout_rows(_concept_config())) + + +# ---- 反转聚合 ---- + + +def test_fanout_inverts_by_catalog_order(mock_http) -> None: + mock_http(_mock_transport({"cn_concept": _CATALOG_CN_CONCEPT}, _CONSTITUENTS_BY_CODE)) + + rows = asyncio.run(fetch_fanout_rows(_concept_config())) + by_symbol = {r["股票代码"]: r for r in rows} + assert len(by_symbol) == 3 + # 类别名跟随目录顺序拼接 (B1 在前 B2 在后), 与请求完成顺序无关 + assert by_symbol["600519.SH"]["所属概念"] == "白酒概念;新能源汽车" + assert by_symbol["600519.SH"]["股票简称"] == "贵州茅台" + assert by_symbol["000858.SZ"]["所属概念"] == "白酒概念" + assert by_symbol["300750.SZ"]["所属概念"] == "新能源汽车" + + +def test_fanout_tolerates_entry_failure(mock_http) -> None: + """单条目重试后仍失败 → 跳过该条目, 其余正常返回。""" + mock_http( + _mock_transport( + {"cn_concept": _CATALOG_CN_CONCEPT}, + _CONSTITUENTS_BY_CODE, + fail_codes=("B2.TI",), + ) + ) + rows = asyncio.run(fetch_fanout_rows(_concept_config())) + by_symbol = {r["股票代码"]: r for r in rows} + assert set(by_symbol) == {"600519.SH", "000858.SZ"} # B2 成员被跳过 + assert by_symbol["600519.SH"]["所属概念"] == "白酒概念" + + +def test_fanout_all_entries_failed_raises(mock_http) -> None: + mock_http( + _mock_transport( + {"cn_concept": _CATALOG_CN_CONCEPT}, + _CONSTITUENTS_BY_CODE, + fail_codes=("B1.TI", "B2.TI"), + ) + ) + with pytest.raises(ValueError, match="全部条目第二级拉取失败"): + asyncio.run(fetch_fanout_rows(_concept_config())) + + +def test_fanout_empty_catalog_raises(mock_http) -> None: + mock_http(_mock_transport({"cn_concept": []}, {})) + with pytest.raises(ValueError, match="第一级返回 0 个可用条目"): + asyncio.run(fetch_fanout_rows(_concept_config())) + + +def test_fanout_dedupes_repeated_labels(mock_http) -> None: + """同一标的在同一类别下重复出现 → 只记一次。""" + mock_http( + _mock_transport( + {"cn_concept": [{"thscode": "B1.TI", "name": "白酒概念"}]}, + { + "B1.TI": [ + {"thscode": "600519.SH", "name": "贵州茅台"}, + {"thscode": "600519.SH", "name": "贵州茅台"}, + ] + }, + ) + ) + rows = asyncio.run(fetch_fanout_rows(_concept_config())) + assert rows[0]["所属概念"] == "白酒概念" + + +# ---- 端到端落盘 (通用写入链) ---- + + +def test_fanout_rows_persist_via_generic_writer( + tmp_path: Path, mock_http +) -> None: + mock_http(_mock_transport({"cn_concept": _CATALOG_CN_CONCEPT}, _CONSTITUENTS_BY_CODE)) + config = _concept_config() + + rows = asyncio.run(fetch_fanout_rows(config)) + n = rows_to_parquet(rows, config, tmp_path) + assert n == 3 + + out = tmp_path / "ext_data" / "ext_gn_ths" / "part.parquet" + assert out.exists(), "快照 parquet 未落盘" + + df = pl.read_parquet(out) + row = df.filter(pl.col("symbol") == "600519.SH").to_dicts()[0] + assert row["所属概念"] == "白酒概念;新能源汽车" + assert row["股票简称"] == "贵州茅台" + assert row["code"] == "600519" + + +def test_fanout_industry_separator(mock_http) -> None: + """行业配方用 "-" 拼接层级, 概念用 ";": 同一引擎, 差别只在配方。""" + mock_http( + _mock_transport( + {"industry": [{"thscode": "H1.TI", "name": "金融"}]}, + {"H1.TI": [{"thscode": "600519.SH", "name": "贵州茅台"}]}, + ) + ) + config = _concept_config( + url_tag="industry", value_col="所属同花顺行业", separator="-" + ) + rows = asyncio.run(fetch_fanout_rows(config)) + assert rows[0]["所属同花顺行业"] == "金融" + + +def test_pull_config_roundtrip_preserves_fanout(tmp_path: Path) -> None: + """fanout 随 config.json 持久化与读回, 不丢字段。""" + config = _concept_config() + store = ExtConfigStore(tmp_path) + store.upsert(config) + + loaded = ExtConfigStore(tmp_path).get("ext_gn_ths") + assert loaded is not None and loaded.pull is not None + assert loaded.pull.fanout == config.pull.fanout diff --git a/backend/tests/test_ext_pages.py b/backend/tests/test_ext_pages.py new file mode 100644 index 00000000..6e0350bf --- /dev/null +++ b/backend/tests/test_ext_pages.py @@ -0,0 +1,138 @@ +"""运行时页面包 API 测试 — 上传校验/生命周期/bundle 分发。 + +TestClient 最小 app 风格(同 test_ext_market_level): 不起完整后端, +app.state.repo 只提供 store.data_dir。 +""" +from __future__ import annotations + +import io +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.datastructures import UploadFile + +from app.api.ext_pages import router + + +@pytest.fixture +def client(tmp_path: Path) -> TestClient: + app = FastAPI() + app.include_router(router) + app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)) + return TestClient(app) + + +def _bundle_js() -> bytes: + # 最小合法 ES 模块产物: default 导出 FrontendExtension 形状 + return ( + "const extension = { id: 'demo.page', apiVersion: 1, " + "routes: [{ id: 'demo-page', path: '/demo-page', component: () => null }], " + "navigation: [{ id: 'demo-page', routeId: 'demo-page', label: '演示页', icon: null }] };\n" + "export default extension;\n" + ).encode() + + +def _upload(client: TestClient, pack_id: str, data: bytes, filename: str = "extension.js"): + return client.post( + "/api/ext-pages/upload", + data={"id": pack_id}, + files={"file": (filename, io.BytesIO(data), "text/javascript")}, + ) + + +def test_upload_and_lifecycle(client: TestClient, tmp_path: Path): + r = _upload(client, "demo_page", _bundle_js()) + assert r.status_code == 200, r.text + meta = r.json() + assert meta["id"] == "demo_page" + assert meta["enabled"] is False # 上传后未验证, 默认停用 + assert meta["size"] == len(_bundle_js()) and len(meta["sha256"]) == 64 + assert (tmp_path / "ext_pages" / "demo_page" / "bundle.js").exists() + + # 清单回填 → 启用 + r = client.put( + "/api/ext-pages/demo_page/manifest", + json={"label": "演示页", "path": "/demo-page", "error": ""}, + ) + assert r.status_code == 200 and r.json()["enabled"] is True + + # 列表 + r = client.get("/api/ext-pages") + assert [m["id"] for m in r.json()] == ["demo_page"] + + # 分发: MIME + no-store + ETag + r = client.get("/api/ext-pages/demo_page/bundle.js") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/javascript") + assert r.headers["cache-control"] == "no-store" + assert "etag" in r.headers + assert b"export default" in r.content + + # 停用 / 删除 + assert client.put("/api/ext-pages/demo_page/enabled", json={"enabled": False}).json()["enabled"] is False + assert client.delete("/api/ext-pages/demo_page").json() == {"ok": True} + assert client.get("/api/ext-pages/demo_page/bundle.js").status_code == 404 + + +def test_upload_rejects_bad_suffix_and_id(client: TestClient): + assert _upload(client, "x", b"a,b", "data.csv").status_code == 400 + # 路径穿越/非法 ID 一律拒绝(空 ID 由 FastAPI 表单校验 422), 不得落盘 + for bad in ("../evil", "a/b", "bad id", "x" * 0, ""): + assert _upload(client, bad, _bundle_js()).status_code in (400, 422) + + +def test_upload_size_cap_413(client: TestClient): + big = b"//" + b"x" * (5 * 1024 * 1024 + 10) + assert _upload(client, "big", big).status_code == 413 + + +def test_upload_empty_file_400(client: TestClient): + assert _upload(client, "empty", b"").status_code == 400 + + +def test_upload_overwrites_same_id(client: TestClient): + _upload(client, "same", _bundle_js()) + v2 = _bundle_js() + b"// v2\n" + _upload(client, "same", v2) + r = client.get("/api/ext-pages/same/bundle.js") + assert r.content == v2 # 覆盖式更新 + + +def test_manifest_error_disables(client: TestClient): + _upload(client, "bad", _bundle_js()) + r = client.put( + "/api/ext-pages/bad/manifest", + json={"label": "", "path": "", "error": "apiVersion 不匹配"}, + ) + assert r.json()["enabled"] is False and r.json()["error"] == "apiVersion 不匹配" + + +def test_missing_pack_404(client: TestClient): + assert client.get("/api/ext-pages/none/bundle.js").status_code == 404 + assert client.put("/api/ext-pages/none/enabled", json={"enabled": True}).status_code == 404 + + +def test_write_bundle_capped_stops_reading(tmp_path: Path): + """越限立即停止读取: 计数流在 413 后不应被继续消费。""" + import asyncio + + from app.api.ext_pages import _write_bundle_capped + + class CountingStream: + def __init__(self, data: bytes): + self._buf = io.BytesIO(data) + self.reads = 0 + + # UploadFile 经 run_in_threadpool 同步调用 file.read, 这里保持同步签名 + def read(self, n: int = -1) -> bytes: + self.reads += 1 + return self._buf.read(n) + + stream = CountingStream(b"x" * 200) + with pytest.raises(Exception) as exc: + asyncio.run(_write_bundle_capped(UploadFile(stream), tmp_path / "dummy.bin", max_bytes=64)) + assert getattr(exc.value, "status_code", None) == 413 + assert stream.reads == 1 # 超限块读出后不再继续读 diff --git a/backend/tests/test_ext_preset_dimension_values.py b/backend/tests/test_ext_preset_dimension_values.py index 82133a31..a18ccf20 100644 --- a/backend/tests/test_ext_preset_dimension_values.py +++ b/backend/tests/test_ext_preset_dimension_values.py @@ -1,28 +1,12 @@ +"""维度值清洗口径。 + +类别名里的 NaN/None 占位值清洗已随 fanout 迁移到 ext_fanout.clean_label +(见 test_ext_fanout.py); 此处保留消费侧的维度值清洗测试。 +""" from __future__ import annotations -from app.services.ext_presets import _flatten_concept_rows, _flatten_industry_rows from app.services.market_overview_builder import _dimension_values -def test_concept_flatten_drops_missing_value_placeholders(): - rows = _flatten_concept_rows([{ - "symbol": "600000.SH", - "name": "浦发银行", - "concepts": ["银行", "nan", None, float("nan"), " null ", "金融科技"], - }]) - - assert rows[0]["所属概念"] == "银行;金融科技" - - -def test_industry_flatten_drops_missing_value_placeholders(): - rows = _flatten_industry_rows([{ - "symbol": "600000.SH", - "name": "浦发银行", - "industries": ["金融", "None", "银行"], - }]) - - assert rows[0]["所属同花顺行业"] == "金融-银行" - - def test_overview_dimension_values_ignore_legacy_nan_group(): assert _dimension_values("人工智能;nan;芯片;NULL") == ["人工智能", "芯片"] diff --git a/backend/tests/test_ext_presets_startup.py b/backend/tests/test_ext_presets_startup.py index 49a142a5..7da5cc3f 100644 --- a/backend/tests/test_ext_presets_startup.py +++ b/backend/tests/test_ext_presets_startup.py @@ -1,20 +1,22 @@ -"""内置概念/行业 preset 启动不得自动拉取 (#199)。 +"""内置概念/行业 preset 启动契约。 -ensure_builtin_presets 的契约是「只创建 config.json, 不拉取数据, 等待用户手动获取」; -但 preset 出厂 PullConfig.enabled=True 会让 PullScheduler.refresh 在启动时立即调度 -_run_loop 并马上执行一次网络拉取 (启用后立即执行一次), 与契约矛盾。 +出厂即带【完整可用的拉取配方】(URL / 鉴权 / 两级请求 fanout), 不是空壳 — +「数据」页展开表 → 拉取 就能看到一份真实可跑的配方, 供接入自有源照抄。 -回归断言: 内置 preset 出厂 pull.enabled 必须为 False —— scheduler 的 enabled 过滤 -(ext_pull.refresh) 会因此跳过它们; 手动获取走 fetch_preset 独立路径, 不经过本开关 -(该路径行为由 test_ext_preset_dimension_values / test_ext_pull_refresh 覆盖)。 +ensure_builtin_presets 的契约: + - 只创建/补齐 config.json, 不拉取数据 (数据由调度器或用户手动触发) + - 空壳 / 历代出厂配方 → 补齐/替换为当前配方 + - 用户自定义过 pull (URL 非出厂值) 或 description → 一律不碰 + - 失败只记 warning, 不阻断启动 """ from __future__ import annotations import asyncio from pathlib import Path -from app.services.ext_data import ExtConfigStore +from app.services.ext_data import ExtConfigStore, PullConfig from app.services.ext_presets import ( + _RETIRED_FACTORY_PULLS, _concept_preset, _industry_preset, ensure_builtin_presets, @@ -23,37 +25,117 @@ from app.services.ext_presets import ( _PRESET_IDS = ("ext_gn_ths", "ext_hy_ths") -def test_builtin_presets_ship_disabled() -> None: - """出厂 preset 的 pull.enabled 必须为 False, 否则启动即网络拉取 (#199)。""" +def test_builtin_presets_ship_with_complete_pull() -> None: + """出厂 preset 自带完整拉取配方: URL / 鉴权 / 两级请求一应俱全。""" for preset in (_concept_preset(), _industry_preset()): - assert preset.pull is not None - assert preset.pull.url, "禁用归禁用, 手动获取仍需 url 配方" - assert preset.pull.enabled is False, f"{preset.id} 启动即自动拉取, 违反启动契约" + pull = preset.pull + assert pull is not None, f"{preset.id} 出厂未带拉取配方" + assert pull.url.startswith("https://"), f"{preset.id} 缺少第一级 URL" + assert pull.response_path, f"{preset.id} 缺少响应路径" + assert pull.auth and pull.auth.get("type") == "header", f"{preset.id} 缺少鉴权配置" + # 凭据复用 fuyao 行情插件, 用户只需在一处填 Key + assert pull.auth.get("key_ref") == "fuyao_api_key", f"{preset.id} 未复用 fuyao 凭据" + + fanout = pull.fanout + assert fanout, f"{preset.id} 缺少两级拉取配方" + assert fanout["url"].startswith("https://"), f"{preset.id} fanout 缺少第二级 URL" + for key in ("param", "id_field", "member_id_field", + "symbol_col", "name_col", "value_col"): + assert fanout.get(key), f"{preset.id} fanout 缺少 {key}" + assert fanout.get("separator"), f"{preset.id} fanout 缺少拼接符" -def test_ensure_builtin_presets_writes_disabled_configs(tmp_path: Path) -> None: - """全新数据目录: 启动只落禁用的 pull 配置, scheduler 扫描后无任务可建。""" +def test_ensure_builtin_presets_writes_complete_configs(tmp_path: Path) -> None: + """全新数据目录: 启动即落完整配方, 两张表都带 fanout。""" asyncio.run(ensure_builtin_presets(tmp_path)) store = ExtConfigStore(tmp_path) for cid in _PRESET_IDS: config = store.get(cid) assert config is not None, f"{cid} 配置未创建" - assert config.pull is not None - assert config.pull.enabled is False + assert config.pull is not None, f"{cid} 出厂配方未写入" + assert config.pull.url, f"{cid} 第一级 URL 为空" + assert config.pull.fanout, f"{cid} 两级配方未写入" -def test_ensure_builtin_presets_keeps_existing_user_config(tmp_path: Path) -> None: - """老用户/已存在的配置一律不动: 即使保留 enabled=True 也不被覆盖。""" +def test_ensure_builtin_presets_fills_empty_shell(tmp_path: Path) -> None: + """空壳配置 (无 pull, #199 之后的形态) → 补齐为完整配方。""" asyncio.run(ensure_builtin_presets(tmp_path)) store = ExtConfigStore(tmp_path) config = store.get("ext_gn_ths") - assert config is not None and config.pull is not None - config.pull.enabled = True + assert config is not None + config.pull = None store.upsert(config) asyncio.run(ensure_builtin_presets(tmp_path)) refreshed = ExtConfigStore(tmp_path).get("ext_gn_ths") assert refreshed is not None and refreshed.pull is not None - assert refreshed.pull.enabled is True, "已存在配置被静默改写, 违反「绝不覆盖」原则" + assert refreshed.pull.fanout, "空壳配置未被补齐为完整配方" + + +def test_ensure_builtin_presets_keeps_custom_user_pull(tmp_path: Path) -> None: + """用户自定义过 pull (URL 不等于历代出厂值) → 一律不动, 即使 enabled=True。""" + asyncio.run(ensure_builtin_presets(tmp_path)) + store = ExtConfigStore(tmp_path) + config = store.get("ext_gn_ths") + assert config is not None + + config.pull = PullConfig( + url="https://example.com/my-own-concepts", enabled=True + ) + store.upsert(config) + + asyncio.run(ensure_builtin_presets(tmp_path)) + + refreshed = ExtConfigStore(tmp_path).get("ext_gn_ths") + assert refreshed is not None and refreshed.pull is not None + assert refreshed.pull.url == "https://example.com/my-own-concepts" + assert refreshed.pull.enabled is True, "自定义拉取配置被静默改写, 违反「绝不覆盖」原则" + + +def test_ensure_builtin_presets_replaces_retired_factory_pulls(tmp_path: Path) -> None: + """pull.url 命中历代废弃出厂值 → 替换为带 fanout 的当前配方。""" + asyncio.run(ensure_builtin_presets(tmp_path)) + store = ExtConfigStore(tmp_path) + + for cid in _PRESET_IDS: + config = store.get(cid) + assert config is not None + for retired in _RETIRED_FACTORY_PULLS[cid]: + config.pull = PullConfig(url=retired["url"], enabled=True) + store.upsert(config) + + asyncio.run(ensure_builtin_presets(tmp_path)) + + refreshed = ExtConfigStore(tmp_path).get(cid) + assert refreshed is not None and refreshed.pull is not None + assert refreshed.pull.fanout, ( + f"{cid} 旧出厂配方 {retired['url']} 未替换为带 fanout 的当前配方" + ) + + +def test_ensure_builtin_presets_syncs_retired_factory_description(tmp_path: Path) -> None: + """description 命中出厂前缀 → 同步为当前文案; 用户自定义描述不动。""" + asyncio.run(ensure_builtin_presets(tmp_path)) + store = ExtConfigStore(tmp_path) + + config = store.get("ext_gn_ths") + assert config is not None + old_factory_desc = "同花顺概念分类 (首次启动自动拉取, 可在扩展数据页手动更新)" + config.description = old_factory_desc + store.upsert(config) + + asyncio.run(ensure_builtin_presets(tmp_path)) + + refreshed = ExtConfigStore(tmp_path).get("ext_gn_ths") + assert refreshed is not None + assert refreshed.description == _concept_preset().description, "旧出厂文案未同步" + + # 用户自定义描述 (非出厂前缀) → 不动 + refreshed.description = "我自己的概念表, 别动" + store.upsert(refreshed) + asyncio.run(ensure_builtin_presets(tmp_path)) + final = ExtConfigStore(tmp_path).get("ext_gn_ths") + assert final is not None + assert final.description == "我自己的概念表, 别动" diff --git a/backend/tests/test_packaging_spec.py b/backend/tests/test_packaging_spec.py index 2f867135..2f80a05f 100644 --- a/backend/tests/test_packaging_spec.py +++ b/backend/tests/test_packaging_spec.py @@ -29,3 +29,11 @@ def test_spec_declares_builtin_plugin_fuyao(): """fuyao 插件双声明不被回归 (同类动态发现问题, 见 spec 内注释)。""" assert "app.plugins.fuyao.provider" in SPEC_CODE assert "app/plugins/fuyao" in SPEC_CODE + + +def test_spec_declares_builtin_plugin_tdx(): + """tdx 插件双声明不被回归 (与 fuyao 同一处理方式; probe 子进程入口也要可导入)。""" + assert "app.plugins.tdxquant.provider" in SPEC_CODE + assert "app.plugins.tdxquant.client" in SPEC_CODE + assert "app.plugins.tdxquant.probe" in SPEC_CODE + assert '"plugins" / "tdxquant" / "plugin.yaml"' in SPEC_CODE # 源路径必须指向现存目录 diff --git a/backend/tests/test_resolve_universe_index.py b/backend/tests/test_resolve_universe_index.py index ef27d0e2..a64492e3 100644 --- a/backend/tests/test_resolve_universe_index.py +++ b/backend/tests/test_resolve_universe_index.py @@ -1,4 +1,5 @@ -"""_resolve_universe 指数过滤测试。""" +"""_resolve_universe 维表单一来源 + 指数过滤测试。""" +import polars as pl import pytest from app.jobs import daily_pipeline @@ -10,20 +11,59 @@ def repo(tmp_path): return KlineRepository(DataStore(tmp_path)) +class _Capset: + def __init__(self, has_batch: bool = False): + self._has_batch = has_batch + + def has(self, cap): + return self._has_batch + + +def _write_instruments(tmp_path, symbols): + out = tmp_path / "instruments" / "instruments.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame({"symbol": symbols}).write_parquet(out) + + def test_resolve_universe_excludes_index_symbols(repo, monkeypatch, tmp_path): """自选里的指数不进入股票日K/分钟K同步池。""" - class _Capset: - def has(self, cap): - return False - + _write_instruments(tmp_path, ["600000.SH"]) monkeypatch.setattr( daily_pipeline, "get_pool", lambda name, refresh=False: ["600000.SH", "000001.SH"] if name == "watchlist" else [], ) - monkeypatch.setattr(daily_pipeline, "DEMO_SYMBOLS", []) monkeypatch.setattr(daily_pipeline.settings, "data_dir", tmp_path) monkeypatch.setattr(repo, "get_index_symbol_set", lambda: {"000001.SH"}) universe = daily_pipeline._resolve_universe(_Capset(), repo) assert "600000.SH" in universe assert "000001.SH" not in universe + + +def test_resolve_universe_never_fetches_pools(repo, monkeypatch, tmp_path): + """维表单一来源: 即使有 batch 能力也不拉 CN_Equity_A 池 (SW1 兜底随之消失)。""" + def _guard(name, refresh=False): + if name != "watchlist": + raise AssertionError(f"不得拉取标的池: {name}") + return ["600000.SH"] + + _write_instruments(tmp_path, ["000001.SZ"]) + monkeypatch.setattr(daily_pipeline, "get_pool", _guard) + monkeypatch.setattr(daily_pipeline.settings, "data_dir", tmp_path) + monkeypatch.setattr(repo, "get_index_symbol_set", lambda: set()) + + universe = daily_pipeline._resolve_universe(_Capset(has_batch=True), repo) + assert universe == ["000001.SZ", "600000.SH"] + + +def test_resolve_universe_watchlist_only_when_no_table(repo, monkeypatch, tmp_path): + """维表缺失时不伪造标的: 池=自选, 宁缺毋假。""" + monkeypatch.setattr( + daily_pipeline, "get_pool", + lambda name, refresh=False: ["600000.SH"] if name == "watchlist" else [], + ) + monkeypatch.setattr(daily_pipeline.settings, "data_dir", tmp_path) + monkeypatch.setattr(repo, "get_index_symbol_set", lambda: set()) + + universe = daily_pipeline._resolve_universe(_Capset(), repo) + assert universe == ["600000.SH"] diff --git a/backend/tests/test_secrets_external_edit.py b/backend/tests/test_secrets_external_edit.py new file mode 100644 index 00000000..2e3a13d8 --- /dev/null +++ b/backend/tests/test_secrets_external_edit.py @@ -0,0 +1,72 @@ +"""secrets.json 作为统一配置源的外部编辑契约。 + +用户可以直接用编辑器修改 secrets.json (AI + 数据源 Key 同文件), 保存后 +下一次请求即读取最新内容 —— 不需要重启服务。这里锁定三条: +① file_path() 指向 data/user_data/secrets.json; +② 外部改写文件后 load()/get_ai_config() 立刻反映新值 (mtime+size 签名缓存失效); +③ GET /api/settings 暴露 secrets_path 供设置页展示。 +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from app import secrets_store + + +@pytest.fixture() +def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + from app.config import settings + + monkeypatch.setattr(settings, "data_dir", tmp_path, raising=False) + yield tmp_path + + +def test_file_path_points_to_user_data_secrets(data_dir: Path) -> None: + assert secrets_store.file_path() == data_dir / "user_data" / "secrets.json" + + +def test_external_edit_is_picked_up_without_restart(data_dir: Path) -> None: + secrets_store.save({"ai_model": "old-model", "ai_base_url": "https://old.example/v1"}) + assert secrets_store.get_ai_config("ai_model") == "old-model" + + # 模拟用户编辑器直接覆写文件 (绕过 secrets_store.save)。 + # 内容长度与 mtime 都变化, 签名缓存必然失效。 + path = data_dir / "user_data" / "secrets.json" + path.write_text(json.dumps({ + "ai_model": "brand-new-model", + "ai_base_url": "https://new.example/v1", + "ai_api_key": "sk-new", + }, indent=2), encoding="utf-8") + + assert secrets_store.get_ai_config("ai_model") == "brand-new-model" + assert secrets_store.get_ai_config("ai_base_url") == "https://new.example/v1" + assert secrets_store.get_ai_key() == "sk-new" + + +def test_external_edit_with_same_size_still_reloads(data_dir: Path) -> None: + """同长度改写: 签名含 mtime_ns, 不能因 size 相同而吃到旧缓存。""" + import os + import time + + path = data_dir / "user_data" / "secrets.json" + secrets_store.save({"ai_model": "model-aaaa"}) + assert secrets_store.get_ai_config("ai_model") == "model-aaaa" + + path.write_text(json.dumps({"ai_model": "model-bbbb"}), encoding="utf-8") + # 强制 mtime 前进 (文件系统时间戳粒度可能吞掉快速连续写入) + future = time.time() + 5 + os.utime(path, (future, future)) + + assert secrets_store.get_ai_config("ai_model") == "model-bbbb" + + +def test_get_settings_exposes_secrets_path(data_dir: Path) -> None: + from app.api import settings as settings_api + + payload = settings_api.get_settings() + + assert payload["secrets_path"].endswith("secrets.json") + assert str(data_dir) in payload["secrets_path"] or Path(payload["secrets_path"]).name == "secrets.json" diff --git a/backend/tests/test_sponsor_models.py b/backend/tests/test_sponsor_models.py new file mode 100644 index 00000000..7c490db7 --- /dev/null +++ b/backend/tests/test_sponsor_models.py @@ -0,0 +1,134 @@ +"""sponsor-models 代理端点的精简字段契约测试。 + +上游 /v1/models 原始体量大 (144KB, 含分层 tiers/dimensions 等细粒度定价); +端点只透传前端展示需要的 id / context_length / discount_rate / 首档单价, +并以 models 纯 id 列表保持向后兼容。 +""" +from __future__ import annotations + +import httpx +import pytest + +from app.api import settings as settings_api + + +def _fake_upstream(payload: dict): + """把 httpx.AsyncClient 换成返回固定 /models 响应的伪实现。""" + + class _Resp: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return payload + + class _Client: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url): + assert url == "https://llm.runninghub.ai/v1/models" + return _Resp() + + return _Client + + +@pytest.mark.asyncio +async def test_sponsor_models_returns_trimmed_pricing(monkeypatch): + payload = { + "object": "list", + "data": [ + { + "id": "deepseek/deepseek-v4.1-flash", + "object": "model", + "owned_by": "runninghub", + "context_length": 1048576, + "pricing": { + "billingType": "TOKEN_INPUT_TIERED", + "discountRate": 0.6, + "input": {"amount": 0.000295, "unit": "USD/1K", "tiers": []}, + "output": {"amount": 0.001177, "unit": "USD/1K", "tiers": []}, + "dimensions": {"cache_read": {"amount": 0.00001}}, + }, + }, + { + "id": "openai/gpt-6.1-sol", + "object": "model", + "context_length": 1050000, + "pricing": {"discountRate": 1.0, "input": {"amount": 0.002}, "output": {"amount": 0.01}}, + }, + {"id": "", "object": "model"}, # 空 id 剔除 + {"object": "model"}, # 无 id 剔除 + "garbage", # 非 dict 剔除 + ], + } + monkeypatch.setattr(httpx, "AsyncClient", _fake_upstream(payload)) + + result = await settings_api.list_sponsor_models() + + assert result["models"] == ["deepseek/deepseek-v4.1-flash", "openai/gpt-6.1-sol"] + assert result["items"] == [ + { + "id": "deepseek/deepseek-v4.1-flash", + "context_length": 1048576, + "discount_rate": 0.6, + "input_price": 0.000295, + "output_price": 0.001177, + }, + { + "id": "openai/gpt-6.1-sol", + "context_length": 1050000, + "discount_rate": 1.0, + "input_price": 0.002, + "output_price": 0.01, + }, + ] + + +@pytest.mark.asyncio +async def test_sponsor_models_tolerates_missing_pricing(monkeypatch): + """个别模型无 pricing 块: discount_rate 回退 1.0(无折扣), 价格为 None。""" + payload = {"data": [{"id": "vendor/plain-model", "context_length": None}]} + monkeypatch.setattr(httpx, "AsyncClient", _fake_upstream(payload)) + + result = await settings_api.list_sponsor_models() + + assert result["items"] == [{ + "id": "vendor/plain-model", + "context_length": None, + "discount_rate": 1.0, + "input_price": None, + "output_price": None, + }] + + +@pytest.mark.asyncio +async def test_sponsor_models_maps_upstream_error_to_502(monkeypatch): + from fastapi import HTTPException + + class _Broken: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url): + raise httpx.ConnectError("boom") + + monkeypatch.setattr(httpx, "AsyncClient", _Broken) + + with pytest.raises(HTTPException) as exc_info: + await settings_api.list_sponsor_models() + assert exc_info.value.status_code == 502 diff --git a/backend/tests/test_task_registry.py b/backend/tests/test_task_registry.py new file mode 100644 index 00000000..e3e67754 --- /dev/null +++ b/backend/tests/test_task_registry.py @@ -0,0 +1,106 @@ +"""进行中任务注册表 (跨设备接回) 的服务与 API 测试。 + +契约: upsert 幂等 (同 id 更新不新增)、容量上限、孤儿 running 回收、 +API 生命周期 (PUT/GET/DELETE) 与非法输入拒绝。存取均为小文件原子写。 +""" +from __future__ import annotations + +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api import task_registry as task_registry_api +from app.services import task_registry + + +@pytest.fixture +def client(tmp_path: Path) -> TestClient: + app = FastAPI() + app.include_router(task_registry_api.router) + app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)) + return TestClient(app) + + +def test_upsert_idempotent_and_list_sorted(tmp_path: Path) -> None: + task_registry.upsert(tmp_path, "backtest:abc", kind="backtest", status="running", payload={"qs": "x=1"}) + task_registry.upsert( + tmp_path, "backtest:abc", kind="backtest", status="done", payload={"qs": "x=1"} + ) + task_registry.upsert(tmp_path, "mining:m1", kind="mining", status="running", payload={"run_id": "r1"}) + + items = task_registry.list_items(tmp_path) + assert [it["id"] for it in items] == ["mining:m1", "backtest:abc"] # updated_at 倒序 + assert items[1]["status"] == "done" # 同 id 更新而非新增 + assert items[1]["started_at"] > 0 + + only_bt = task_registry.list_items(tmp_path, kind="backtest") + assert [it["id"] for it in only_bt] == ["backtest:abc"] + + +def test_capacity_trim_keeps_newest(tmp_path: Path) -> None: + for i in range(task_registry.MAX_ITEMS + 5): + task_registry.upsert( + tmp_path, f"backtest:t{i}", kind="backtest", status="done" + ) + time.sleep(0.001) # 保证 updated_at 严格递增 + items = task_registry.list_items(tmp_path) + assert len(items) == task_registry.MAX_ITEMS + assert items[-1]["id"] == "backtest:t5" # t0..t4 最旧的被裁掉 + assert items[0]["id"] == f"backtest:t{task_registry.MAX_ITEMS + 4}" + + +def test_orphan_running_pruned_on_read(tmp_path: Path) -> None: + import json + + task_registry.upsert(tmp_path, "backtest:old", kind="backtest", status="running") + # 直接改文件把 updated_at 拨到 25h 前 (模拟进程重启遗留的孤儿 running) + p = tmp_path / "user_data" / task_registry._FILENAME + doc = json.loads(p.read_text(encoding="utf-8")) + doc["items"][0]["updated_at"] = time.time() - (task_registry.ORPHAN_RUNNING_SECONDS + 3600) + p.write_text(json.dumps(doc), encoding="utf-8") + + items = task_registry.list_items(tmp_path) + assert items == [], "孤儿 running 项应在读取时被回收" + # 回收已落盘: 重复读不再触发 + assert task_registry.list_items(tmp_path) == [] + + +def test_api_lifecycle(client: TestClient) -> None: + assert client.get("/api/task-registry").json() == {"items": []} + + r = client.put( + "/api/task-registry/backtest:k1", + json={"kind": "backtest", "status": "running", "label": "demo", "payload": {"qs": "a=1"}}, + ) + assert r.status_code == 200 and r.json()["item"]["id"] == "backtest:k1" + + listed = client.get("/api/task-registry", params={"kind": "backtest"}).json()["items"] + assert len(listed) == 1 and listed[0]["label"] == "demo" + + r = client.put( + "/api/task-registry/backtest:k1", + json={"kind": "backtest", "status": "done", "payload": {"qs": "a=1"}}, + ) + assert r.status_code == 200 + assert client.get("/api/task-registry").json()["items"][0]["status"] == "done" + + assert client.delete("/api/task-registry/backtest:k1").json()["removed"] is True + assert client.get("/api/task-registry").json()["items"] == [] + assert client.delete("/api/task-registry/backtest:k1").json()["removed"] is False + + +def test_api_rejects_bad_input(client: TestClient) -> None: + assert client.put( + "/api/task-registry/bad id!", json={"kind": "backtest", "status": "running"} + ).status_code == 400 # id 含非法字符 + assert client.put( + "/api/task-registry/backtest:x", json={"kind": "nope", "status": "running"} + ).status_code == 400 # 未知 kind + assert client.put( + "/api/task-registry/backtest:x", json={"kind": "backtest", "status": "weird"} + ).status_code == 400 # 未知 status + assert client.get("/api/task-registry", params={"kind": "nope"}).status_code == 400 diff --git a/backend/tests/test_tdxquant_provider.py b/backend/tests/test_tdxquant_provider.py new file mode 100644 index 00000000..744398a5 --- /dev/null +++ b/backend/tests/test_tdxquant_provider.py @@ -0,0 +1,625 @@ +"""TdxQuantProvider 契约与单位标准化测试。 + +不依赖真实网络与 tdxaidata 安装: 用假 TdxQuantClient 返回样例快照/K线/除权事件, +验证字段映射、单位口径 (CONTRIBUTING §3: 万元→元、股→手、每10股→每股、 +小数制涨跌幅)、lib 物化与 token 注入、重试语义、能力声明与设置页试拉。 +""" + +from __future__ import annotations + +import math +from datetime import date, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +from app.plugins.tdxquant import client as tc +from app.plugins.tdxquant import provider as tp +from app.plugins.tdxquant.provider import TdxQuantProvider + +_BEIJING = ZoneInfo("Asia/Shanghai") + + +# ---------------------------------------------------------------- fakes +class _FakeClient: + """按测试需要预置返回值; 记录调用供批大小/参数断言。""" + + def __init__(self): + self.universe: list[str] = [] + self.universe_error: Exception | None = None + self.snapshot_results: list[object] = [] # 每次 snapshot_batch 弹出一个(dict 或 Exception) + self.snapshot_calls: list[list[str]] = [] + self.his: dict[str, object] = {} # code -> raw dict | Exception + self.his_default: object = {} + self.his_calls: list[dict] = [] + self.events: dict[str, object] = {} # code -> list[dict] | Exception + self.event_calls: list[dict] = [] + + def close(self) -> None: + pass + + def stock_universe(self) -> list[str]: + if self.universe_error: + raise self.universe_error + return list(self.universe) + + def snapshot_batch(self, codes): + self.snapshot_calls.append(list(codes)) + if not self.snapshot_results: + return {} + item = self.snapshot_results.pop(0) + if isinstance(item, Exception): + raise item + return item + + def his_dats(self, code, end_d, count, period="1d"): + self.his_calls.append({"code": code, "end": end_d, "count": count, "period": period}) + item = self.his.get(code, self.his_default) + if isinstance(item, Exception): + raise item + return item + + def divid_factors(self, code, start_d=None, end_d=None): + self.event_calls.append({"code": code, "start": start_d, "end": end_d}) + item = self.events.get(code, []) + if isinstance(item, Exception): + raise item + return item + + +def _provider(fake: _FakeClient) -> TdxQuantProvider: + p = TdxQuantProvider() + p._client = fake + return p + + +def _snap_row(**over) -> dict: + row = { + "Now": "1263.00", "LastClose": "1255.79", "Open": "1255.42", + "Max": "1282.00", "Min": "1255.42", "Volume": "35110", + "Amount": "445322.34", "RefreshTime": "153017", + "Buyp": ["1263.00", "1262.99"], "Buyv": ["73", "2"], + "Sellp": ["1263.06", "1263.11"], "Sellv": ["1", "3"], + } + row.update(over) + return row + + +# ---------------------------------------------------------------- client: lib 物化 +def _make_source_lib(root: Path) -> Path: + src = root / "src_lib" + src.mkdir() + (src / "TdxAiData.dll").write_bytes(b"FAKE_DLL_BYTES") + (src / "libTdxAiData.so").write_bytes(b"FAKE_SO_BYTES") + (src / "libTdxAiData.dylib").write_bytes(b"FAKE_DYLIB_BYTES") # macOS + (src / "TdxAiData.ini").write_text( + "[TcHqHost]\nHostNum=2\nPrimaryHost=0\n\n[Token]\ntoken=OLD\n", encoding="utf-8" + ) + (src / "user.ini").write_text("[Token]\ntoken=OLD\n", encoding="utf-8") + return src + + +def test_materialize_lib_writes_token_into_both_ini(tmp_path): + src = _make_source_lib(tmp_path) + lib_root = tmp_path / "tdxquant_lib" + dll = tc.materialize_lib("TDX-NEW-KEY", lib_root, source=src) + assert dll.name == tc.LIB_DLL_NAME + assert (lib_root / tc.LIB_DLL_NAME).exists() + for ini_name in ("TdxAiData.ini", "user.ini"): + text = (lib_root / ini_name).read_text(encoding="utf-8") + assert "token=TDX-NEW-KEY" in text + assert "token=OLD" not in text + # 其它配置段保留 + assert "[TcHqHost]" in (lib_root / "TdxAiData.ini").read_text(encoding="utf-8") + assert "HostNum=2" in (lib_root / "TdxAiData.ini").read_text(encoding="utf-8") + + +def test_materialize_lib_marker_skips_recopy(tmp_path, monkeypatch): + src = _make_source_lib(tmp_path) + lib_root = tmp_path / "tdxquant_lib" + tc.materialize_lib("TDX-KEY-A", lib_root, source=src) + + copies = [] + real_copy = tc.shutil.copy2 + + def _spy(src_p, dst_p): + copies.append(str(src_p)) + return real_copy(src_p, dst_p) + + monkeypatch.setattr(tc.shutil, "copy2", _spy) + tc.materialize_lib("TDX-KEY-A", lib_root, source=src) # 同 Key 同 DLL → 复用 + assert copies == [] + tc.materialize_lib("TDX-KEY-B", lib_root, source=src) # 换 Key → 重物化 + assert copies + assert "token=TDX-KEY-B" in (lib_root / "user.ini").read_text(encoding="utf-8") + + +def test_write_token_ini_adds_missing_section(tmp_path): + ini = tmp_path / "user.ini" + ini.write_text("[Other]\nfoo=1\n", encoding="utf-8") + tc.write_token_ini(ini, "K1") + text = ini.read_text(encoding="utf-8") + assert "token=K1" in text and "[Other]" in text and "foo=1" in text + + +# ---------------------------------------------------------------- client: 重试语义 +def _client_with_raw(monkeypatch, returns: list[object], sleep_log: list[float]): + c = object.__new__(tc.TdxQuantClient) + c.api_key = "k" + state = {"i": 0} + + def _raw_value(_method, *args): + item = returns[min(state["i"], len(returns) - 1)] + state["i"] += 1 + if isinstance(item, Exception): + raise item + return item + + c._tqs = SimpleNamespace(_clean=lambda raw: raw) + c._native_cls = SimpleNamespace(get_instance=lambda: SimpleNamespace(get_his_dats=_raw_value)) + c._universe, c._universe_at = [], 0.0 + monkeypatch.setattr(tc.time, "sleep", lambda s: sleep_log.append(s)) + return c + + +def test_raw_retries_transient_exception(monkeypatch): + sleeps: list[float] = [] + c = _client_with_raw( + monkeypatch, + [RuntimeError("Invalid server response"), {"Date": ["20260101"]}], + sleeps, + ) + out = c.his_dats("600519.SH", date(2026, 1, 10), 5) + assert out["Date"] == ["20260101"] + assert sleeps # 失败后有退避 + + +def test_raw_gives_up_on_persistent_none(monkeypatch): + sleeps: list[float] = [] + c = _client_with_raw(monkeypatch, [None, None], sleeps) + with pytest.raises(tc.TdxQuantError): + c.his_dats("600519.SH", date(2026, 1, 10), 5) + + +def test_snapshot_batch_retries_transient_failure(monkeypatch): + sleeps: list[float] = [] + c = _client_with_raw(monkeypatch, [], sleeps) + state = {"i": 0} + + def _snap(stock_list=None, field_list=None): + state["i"] += 1 + if state["i"] == 1: + raise RuntimeError("Invalid server response") + return {"600519.SH": {"Now": "10.0"}} + + c._tqs = type("T", (), {"get_market_snapshot_batch": staticmethod(_snap)})() + out = c.snapshot_batch(["600519.SH"]) + assert out == {"600519.SH": {"Now": "10.0"}} + assert state["i"] == 2 and sleeps + + +def test_raw_retries_none_then_data(monkeypatch): + sleeps: list[float] = [] + c = _client_with_raw(monkeypatch, [None, {"Date": ["20260101"]}], sleeps) + assert c.his_dats("600519.SH", date(2026, 1, 10), 5)["Date"] == ["20260101"] + + +# ---------------------------------------------------------------- provider: 快照映射 +class _FixedDT(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 10, 11, 15, 0, 0, tzinfo=tz) + + +def _map_row(monkeypatch, **over) -> dict: + monkeypatch.setattr(tp, "datetime", _FixedDT) + return tp._map_snapshot_row("600519.SH", _snap_row(**over), 999) + + +def test_snapshot_row_units(monkeypatch): + rec = _map_row(monkeypatch) + assert rec["symbol"] == "600519.SH" + assert rec["last_price"] == 1263.00 + assert rec["prev_close"] == 1255.79 + assert rec["high"] == 1282.00 and rec["low"] == 1255.42 # Max/Min → high/low + assert rec["volume"] == 35110 # 快照已为手, 零换算 + assert rec["amount"] == pytest.approx(445322.34 * 10_000) # 万元 → 元 + # 涨跌幅小数制: (1263.00-1255.79)/1255.79 + assert rec["change_pct"] == pytest.approx((1263.00 - 1255.79) / 1255.79) + assert rec["change_amount"] == pytest.approx(1263.00 - 1255.79) + assert rec["name"] is None and rec["turnover_rate"] is None # 不伪造 + # RefreshTime 北京墙钟 → epoch ms + expect = datetime(2026, 10, 11, 15, 30, 17, tzinfo=_BEIJING) + assert rec["timestamp"] == int(expect.timestamp() * 1000) + + +def test_snapshot_row_bad_refresh_time_falls_back(monkeypatch): + monkeypatch.setattr(tp, "datetime", _FixedDT) + rec = tp._map_snapshot_row("600519.SH", _snap_row(RefreshTime="99:99"), 123456) + assert rec["timestamp"] == 123456 + + +def test_compact_date_accepts_iso_and_compact(): + assert tp._compact_date("20260924") == date(2026, 9, 24) + assert tp._compact_date("2024-12-20 00:00:00") == date(2024, 12, 20) # DataFrame 归一形态 + assert tp._compact_date(None) is None + assert tp._compact_date("bad") is None + + +def test_snapshot_row_dropped_without_prices(monkeypatch): + monkeypatch.setattr(tp, "datetime", _FixedDT) + assert tp._map_snapshot_row("600519.SH", _snap_row(Now=None, LastClose=None), 1) is None + + +# ---------------------------------------------------------------- provider: realtime +def test_realtime_batches_and_merges(monkeypatch): + monkeypatch.setattr(tp, "datetime", _FixedDT) + fake = _FakeClient() + fake.universe = [f"60000{i}.SH" for i in range(tp._SNAPSHOT_BATCH)] + ["000001.SZ", "000002.SZ"] + fake.snapshot_results = [ + {"600000.SH": _snap_row()}, + {"000001.SZ": _snap_row(Now="11.59", LastClose="11.78")}, + ] + records = _provider(fake).get_realtime() + assert [len(c) for c in fake.snapshot_calls] == [tp._SNAPSHOT_BATCH, 2] # 600 只/批 + assert len(records) == 2 + assert {r["symbol"] for r in records} == {"600000.SH", "000001.SZ"} + + +def test_realtime_soft_fail_on_universe_error(): + fake = _FakeClient() + fake.universe_error = tc.TdxQuantError("get_stock_list(上证主板): boom") + assert _provider(fake).get_realtime() == [] + + +def test_realtime_isolates_failed_batch(monkeypatch): + monkeypatch.setattr(tp, "datetime", _FixedDT) + fake = _FakeClient() + fake.universe = [f"6000{i:03d}.SH" for i in range(tp._SNAPSHOT_BATCH)] + ["000001.SZ"] + fake.snapshot_results = [ + tc.TdxQuantError("snapshot_batch(600 只): invalid"), # 首批失败被隔离 + {"000001.SZ": _snap_row(Now="11.59", LastClose="11.78")}, + ] + records = _provider(fake).get_realtime() + assert [r["symbol"] for r in records] == ["000001.SZ"] + + +def test_realtime_indices_maps_sh_index_code(monkeypatch): + monkeypatch.setattr(tp, "datetime", _FixedDT) + fake = _FakeClient() + fake.snapshot_results = [{"999999.SH": _snap_row(Now="3813.79", LastClose="3811.90")}] + records = _provider(fake).get_realtime_indices(["000001.SH", "399001.SZ"]) + assert fake.snapshot_calls == [["999999.SH", "399001.SZ"]] # 000001.SH → 999999.SH + assert records and records[0]["last_price"] == 3813.79 + + +def test_realtime_indices_none_on_failure(): + fake = _FakeClient() + fake.snapshot_results = [tc.TdxQuantError("snapshot_batch(1 只): down")] + assert _provider(fake).get_realtime_indices(["000001.SH"]) is None + + +# ---------------------------------------------------------------- provider: daily +def _his_raw(*, dates=("20260102", "20260105"), volume="3123935.00", amount="386731.09") -> dict: + return { + "Date": list(dates), "Time": ["0"] * len(dates), + "Open": ["10.0"] * len(dates), "High": ["11.0"] * len(dates), + "Low": ["9.5"] * len(dates), "Close": ["10.5"] * len(dates), + "Volume": [volume] * len(dates), "Amount": [amount] * len(dates), + } + + +def test_daily_units_and_window_filter(): + fake = _FakeClient() + fake.his["600519.SH"] = _his_raw(dates=("20251231", "20260102", "20260105", "20260106")) + df = _provider(fake).get_daily( + ["600519.SH"], + datetime(2026, 1, 1), datetime(2026, 1, 5), + ) + assert df["date"].to_list() == [date(2026, 1, 2), date(2026, 1, 5)] # 窗口过滤 + row = df.row(0, named=True) + assert row["close"] == 10.5 + assert row["volume"] == math.floor(3123935.0 / 100) # 股 → 手 + assert row["amount"] == pytest.approx(386731.09 * 10_000) # 万元 → 元 + call = fake.his_calls[0] + assert call["period"] == "1d" + assert call["count"] >= 30 # count 模式根数下限 + + +def test_daily_etf_asset_type_unsupported_reports_empty(): + fake = _FakeClient() + chunks = list( + _provider(fake).iter_daily( + ["510300.SH"], datetime(2026, 1, 1), datetime(2026, 1, 31), asset_type="etf" + ) + ) + assert chunks == [] + + +def test_daily_symbol_error_isolated_but_iteration_continues(): + fake = _FakeClient() + fake.his["600000.SH"] = tc.TdxQuantError("get_his_dats: invalid") + fake.his["600001.SH"] = _his_raw() + done: list[tuple[int, int]] = [] + df = _provider(fake).get_daily( + ["600000.SH", "600001.SH"], datetime(2025, 12, 1), datetime(2026, 1, 31), + on_chunk_done=lambda cur, total: done.append((cur, total)), + ) + assert df.height == 2 # 只有 600001 的两根 + assert done == [(1, 2), (2, 2)] # 空批次也推进度 + + +# ---------------------------------------------------------------- provider: adj_factor +def _closes_raw(pairs: list[tuple[str, float]]) -> dict: + return { + "Date": [d for d, _ in pairs], "Close": [str(c) for _, c in pairs], + "Open": ["1"] * len(pairs), "High": ["1"] * len(pairs), "Low": ["1"] * len(pairs), + "Volume": ["100"] * len(pairs), "Amount": ["1"] * len(pairs), + } + + +def test_adj_factor_per10_to_per_share_and_formula(): + # 10派30.876元 → 每股 D=3.0876; P=30.876 → ref=(30.876-3.0876)/1=27.7884 → 27.79 + fake = _FakeClient() + fake.events["600519.SH"] = [ + {"Date": "20240619", "Type": "1", "Bonus": 30.876, "AllotPrice": 0.0, + "ShareBonus": 0.0, "Allotment": 0.0}, + ] + fake.his["600519.SH"] = _closes_raw([("20240618", 30.876), ("20240619", 28.0), ("20240620", 28.5)]) + df = _provider(fake).get_adj_factors( + ["600519.SH"], datetime(2024, 1, 1), datetime(2024, 12, 31) + ) + assert df.height == 1 + row = df.row(0, named=True) + assert row["trade_date"] == date(2024, 6, 19) + assert row["dividend"] == pytest.approx(3.0876) # 每 10 股 → 每股 + assert row["prev_close"] == 30.876 + ref = math.floor((30.876 - 3.0876) * 100 + 0.5) / 100 # half-up 两位 + assert row["ex_factor"] == pytest.approx(30.876 / ref) + # 涨跌停自检: 复权后除权日涨跌 28.0/(30.876/factor)-1 ≈ 0.1% 在板限内 → 保留 + + +def test_adj_factor_limit_check_drops_bad_factor(): + # 异常事件: 分红与价格严重不匹配 → 复权后除权日涨跌幅超板限 → 自检剔除 + fake = _FakeClient() + fake.events["600519.SH"] = [ + {"Date": "20240619", "Type": "1", "Bonus": 200.0, "AllotPrice": 0.0, + "ShareBonus": 0.0, "Allotment": 0.0}, + ] + fake.his["600519.SH"] = _closes_raw([("20240618", 30.876), ("20240619", 28.0)]) + df = _provider(fake).get_adj_factors( + ["600519.SH"], datetime(2024, 1, 1), datetime(2024, 12, 31) + ) + assert df.is_empty() + + +def test_adj_factor_future_event_without_prev_close_skipped(): + fake = _FakeClient() + fake.events["600519.SH"] = [ + {"Date": "20991231", "Type": "1", "Bonus": 30.876, "AllotPrice": 0.0, + "ShareBonus": 0.0, "Allotment": 0.0}, + ] + fake.his["600519.SH"] = _closes_raw([("20240618", 30.876)]) + df = _provider(fake).get_adj_factors( + ["600519.SH"], datetime(2024, 1, 1), datetime(2026, 12, 31) + ) + assert df.is_empty() # 无除权日前收盘 → 留给滚动增量 + + +def test_adj_factor_schema_columns(): + # 真实量级事件: P=10, 10派2 + 10送1 + 10配2(配股价5) → 参考价 8.31, 除权日收盘 8.35 + fake = _FakeClient() + fake.events["600000.SH"] = [ + {"Date": "20240619", "Type": "1", "Bonus": 2.0, "AllotPrice": 5.0, + "ShareBonus": 1.0, "Allotment": 2.0}, + ] + fake.his["600000.SH"] = _closes_raw([("20240618", 10.0), ("20240619", 8.35)]) + df = _provider(fake).get_adj_factors( + ["600000.SH"], datetime(2024, 1, 1), datetime(2024, 12, 31) + ) + assert df.columns == list(tp._ADJ_SCHEMA) + row = df.row(0, named=True) + assert row["dividend"] == pytest.approx(0.2) # 10派2 → 每股 0.2 + assert row["bonus"] == pytest.approx(0.1) # 10送1 → 每股 0.1 + assert row["allot"] == pytest.approx(0.2) # 10配2 → 每股 0.2 + assert row["allot_price"] == 5.0 + + +# ---------------------------------------------------------------- provider: depth5 +def test_depth_batch_maps_five_levels(monkeypatch): + monkeypatch.setattr(tp, "datetime", _FixedDT) + fake = _FakeClient() + fake.snapshot_results = [{ + "600519.SH": _snap_row(), + "600000.SH": _snap_row(Buyp=None, Sellp=None), # 无五档 → 整标的省略 + }] + out = _provider(fake).get_depth_batch(["600519.SH", "600000.SH"]) + assert set(out) == {"600519.SH"} + entry = out["600519.SH"] + assert entry["bid_prices"] == [1263.00, 1262.99] + assert entry["bid_volumes"] == [73, 2] # 手, 透传为 int + assert entry["ask_prices"] == [1263.06, 1263.11] + assert entry["ask_volumes"] == [1, 3] + expect = datetime(2026, 10, 11, 15, 30, 17, tzinfo=_BEIJING) + assert entry["timestamp"] == int(expect.timestamp() * 1000) + + +# ---------------------------------------------------------------- availability / 能力 +def test_availability_missing_module(monkeypatch): + monkeypatch.setattr(tp, "_module_available", lambda: False) + ok, reason = tp.availability() + assert not ok and "tdxaidata" in reason + + +def test_availability_missing_key(monkeypatch): + monkeypatch.setattr(tp, "_module_available", lambda: True) + monkeypatch.setattr(tp, "get_api_key", lambda: "") + ok, reason = tp.availability() + assert not ok and tp.API_KEY_ENV in reason + + +def test_availability_ok(monkeypatch): + monkeypatch.setattr(tp, "_module_available", lambda: True) + monkeypatch.setattr(tp, "get_api_key", lambda: "TDX-xxx") + assert tp.availability() == (True, "ok") + + +def test_datasets_declaration(): + p = TdxQuantProvider() + assert set(p.config.datasets) == {"realtime", "depth5", "daily", "adj_factor", "minute", "full_minute"} + assert p.name == "tdxquant" and p.config.builtin is True + for absent in ("financial",): + assert absent not in p.config.datasets # 未声明 → provider_has_dataset False → 回退 tickflow + + +# ---------------------------------------------------------------- minute / full_minute +def _minute_raw(rows: list[tuple[str, str, float, float, float, float, float, float]]) -> dict: + """(Date, Time, O, H, L, C, Volume股, Amount万元) → 原生 1m 列式 dict。""" + return { + "Date": [r[0] for r in rows], "Time": [r[1] for r in rows], + "Open": [r[2] for r in rows], "High": [r[3] for r in rows], + "Low": [r[4] for r in rows], "Close": [r[5] for r in rows], + "Volume": [r[6] for r in rows], "Amount": [r[7] for r in rows], + } + + +def test_minute_units_and_wallclock_datetime(): + fake = _FakeClient() + fake.his = {"600519.SH": _minute_raw([ + ("20261009", "093100", 1255.0, 1256.0, 1254.9, 1255.5, 123_400.0, 15_500.0), + ("20261009", "150000", 1263.0, 1263.0, 1263.0, 1263.0, 38_600.00, 4869.62), + ])} + df = _provider(fake).get_minute( + ["600519.SH"], datetime(2026, 10, 8), datetime(2026, 10, 9, 23, 59) + ) + assert fake.his_calls[0]["period"] == "1m" + assert df.height == 2 + rows = df.sort("datetime").to_dicts() + assert rows[0]["datetime"] == datetime(2026, 10, 9, 9, 31, 0) # 北京墙钟 naive + assert rows[1]["datetime"] == datetime(2026, 10, 9, 15, 0, 0) + assert rows[0]["volume"] == 1234.0 # 股 → floor(/100) 手 + assert rows[1]["volume"] == 386.0 + assert rows[1]["amount"] == pytest.approx(48_696_200.0) # 万元 → 元 + + +def test_minute_window_filter_and_gap_skip(): + fake = _FakeClient() + fake.his = {"600519.SH": _minute_raw([ + ("20261008", "150000", 1250.0, 1250.0, 1250.0, 1250.0, 100_000.0, 12_500.0), # 窗口外 + ("20261009", "093100", 1255.0, 1256.0, 1254.9, 1255.5, 123_400.0, 15_500.0), + ("20261009", "093200", None, None, None, None, None, None), # 停牌缺口 + ])} + df = _provider(fake).get_minute( + ["600519.SH"], datetime(2026, 10, 9), datetime(2026, 10, 9, 23, 59) + ) + assert df.height == 1 + assert df.to_dicts()[0]["datetime"] == datetime(2026, 10, 9, 9, 31, 0) + + +def test_minute_freq_and_asset_guards(): + fake = _FakeClient() + p = _provider(fake) + assert p.get_minute(["600519.SH"], None, datetime.now(), freq="5m").is_empty() + assert p.get_minute(["510300.SH"], None, datetime.now(), asset_type="etf").is_empty() + assert fake.his_calls == [] # 未发起取数 + + +def test_minute_transport_error_soft(): + fake = _FakeClient() + fake.his = {"600519.SH": tc.TdxQuantError("boom")} + df = _provider(fake).get_minute(["600519.SH"], None, datetime.now()) + assert df.is_empty() # 软失败不阻断 + + +def test_get_intraday_batch_today_only(): + fake = _FakeClient() + today = date.today().strftime("%Y%m%d") + yday = (date.today() - timedelta(days=1)).strftime("%Y%m%d") + fake.his = {"600519.SH": _minute_raw([ + (yday, "150000", 1250.0, 1250.0, 1250.0, 1250.0, 100_000.0, 12_500.0), # 前日余量 → 过滤 + (today, "093100", 1255.0, 1256.0, 1254.9, 1255.5, 123_400.0, 15_500.0), + (today, "093200", 1255.5, 1257.0, 1255.0, 1256.0, 88_800.0, 11_160.0), + ])} + df = _provider(fake).get_intraday_batch(["600519.SH"]) + assert df.height == 2 # 只保留今日行 + assert fake.his_calls[0]["period"] == "1m" + + +def test_get_intraday_latest_universe_default(): + fake = _FakeClient() + fake.universe = ["600000.SH", "000001.SZ"] + today = date.today().strftime("%Y%m%d") + for sym in fake.universe: + fake.his[sym] = _minute_raw([ + (today, "093100", 10.0, 10.1, 9.9, 10.0, 50_000.0, 500.0), + (today, "093200", 10.0, 10.1, 9.9, 10.0, 50_000.0, 500.0), + ]) + df = _provider(fake).get_intraday_latest(count=3) + assert df.height == 4 # 每只 2 根 + assert set(df["symbol"].to_list()) == {"600000.SH", "000001.SZ"} + assert all(c["count"] <= 30 for c in fake.his_calls) + + +def test_test_dataset_minute(): + fake = _FakeClient() + today = date.today().strftime("%Y%m%d") + fake.his = {"600519.SH": _minute_raw([ + (today, "093100", 1255.0, 1256.0, 1254.9, 1255.5, 123_400.0, 15_500.0), + ])} + out = _provider(fake).test_dataset("minute", ["600519.SH"]) + assert out["rows"] == 1 + assert "datetime" in out["columns"] + + +# ---------------------------------------------------------------- provider: 试拉 +def test_test_dataset_realtime(monkeypatch): + monkeypatch.setattr(tp, "datetime", _FixedDT) + fake = _FakeClient() + fake.snapshot_results = [{"600519.SH": _snap_row()}] + out = _provider(fake).test_dataset("realtime") + assert out["rows"] == 1 and out["preview"][0]["symbol"] == "600519.SH" + + +def test_test_dataset_unsupported_dataset(): + out = _provider(_FakeClient()).test_dataset("financial") + assert "error" in out and "回退" in out["error"] + + +# ---------------------------------------------------------------- loader 集成 +def test_loader_manifest_parses_and_entry_importable(): + from importlib import import_module + + from app.data_providers.custom import loader + + manifest = loader.plugin_manifest("tdxquant") + assert manifest is not None + assert manifest["name"] == "tdxquant" + assert manifest["display_name"] == "TdxQuant (TdxAiData)" + assert set(manifest["datasets"]) == {"realtime", "depth5", "daily", "adj_factor", "minute", "full_minute"} + # 计费标识与 Key/积分管理页 (设置页积分角标 + 商城入口的契约来源) + assert manifest["billing"] == "points" + assert manifest["key_manage_url"].startswith("https://vip.tdx.com.cn/") + # 计费提示与使用要点 (设置页警示框 + 详情列表的契约来源) + assert "积分" in manifest["billing_note"] and "间隔" in manifest["billing_note"] + assert len(manifest["usage_notes"]) >= 4 + assert any("快照" in n for n in manifest["usage_notes"]) + module_path, _, class_name = manifest["entry"].partition(":") + provider_cls = getattr(import_module(module_path), class_name) + assert provider_cls.name == "tdxquant" + + +def test_loader_probe_plugin_key_dispatch(monkeypatch): + from app.data_providers.custom import loader + + monkeypatch.setattr(tp, "probe_api_key", lambda key: (key == "good", "ok" if key == "good" else "bad")) + assert loader.probe_plugin_key("tdxquant", "good") == (True, "ok") + assert loader.probe_plugin_key("tdxquant", "bad") == (False, "bad") + + +def test_close_resets_client(): + fake = _FakeClient() + p = _provider(fake) + p.close() + assert p._client is None diff --git a/backend/uv.lock b/backend/uv.lock index d28384b7..5acd6df2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2482,6 +2482,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, ] +[[package]] +name = "tdxaidata" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/a8/3b1a4341769c081b3bdbc4e6c9991ac05c49585e256965cd9402f066daa6/tdxaidata-1.2.2.tar.gz", hash = "sha256:794011131704a7827746b89da490e43345ddd442644d6100890d39063485171e", size = 4430612, upload-time = "2026-09-30T10:08:12.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/c1/29b840db422aa32c87baedf2d402ccb8cbba8964d33caff3b20e5b3ddad4/tdxaidata-1.2.2-py3-none-any.whl", hash = "sha256:ceaf0be18cb54cd197e79cca5590d4ba7d7c3620b899e818d472d032f2c33849", size = 4443080, upload-time = "2026-09-30T10:08:07.807Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -2538,6 +2547,7 @@ dependencies = [ { name = "python-multipart" }, { name = "pyyaml" }, { name = "sse-starlette" }, + { name = "tdxaidata" }, { name = "tickflow", extra = ["all"] }, { name = "uvicorn", extra = ["standard"] }, { name = "winotify", marker = "sys_platform == 'win32'" }, @@ -2590,6 +2600,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, { name = "sse-starlette", specifier = ">=2.0" }, + { name = "tdxaidata", specifier = ">=1.2.2" }, { name = "tickflow", extras = ["all"], specifier = ">=0.1.23" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, { name = "vectorbt", marker = "extra == 'backtest'", specifier = ">=0.26" }, diff --git a/docs/configuration.md b/docs/configuration.md index 13f52708..d34b7715 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,6 +64,7 @@ AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限 | 配置项 | 说明 | | :--- | :--- | | `AI_PROVIDER` | `openai_compat`(OpenAI 兼容,支持 DeepSeek / 通义 / OpenAI 等)或 `ollama`(本地模型) | +| `AI_PROTOCOL` | 请求协议: `chat_completions`(默认, 兼容面最广)或 `responses`(OpenAI 官方新协议, DeepSeek 官方亦已支持); 仅 openai 系 provider 生效, 也可在设置页切换 | | `AI_BASE_URL` | 接口地址,如 DeepSeek `https://api.deepseek.com/v1` | | `AI_API_KEY` | 留空则关闭 AI 功能 | | `AI_MODEL` | 模型名,如 `deepseek-chat` | diff --git a/docs/factor-system-design.md b/docs/factor-system-design.md index 1791df3a..10d16efe 100644 --- a/docs/factor-system-design.md +++ b/docs/factor-system-design.md @@ -804,7 +804,7 @@ researchUniverses() | price_limits.py:87-101 | numpy_limit_pct_vectors(当前名推断) | ✓ | | capabilities.py:44-45 | 复权口径一致性"不做路由耦合"注释 | ✓ | | index_const.py:12-15 | 核心四只代码(000001.SH/399001.SZ/399006.SZ/000680.SH) | ✓ | -| daily_pipeline.py:92-125 | _resolve_universe(CN_Equity_A 当前池) | ✓ | +| daily_pipeline.py | _resolve_universe(instruments 维表 + 自选,无池兜底) | ✓ | | kline_sync.py:358-367 | (symbol, trade_date) 去重 keep=last 原子合并 | ✓ | | scoring.py:13-51 / 53-66 / 91-104 / 108+ | VIRTUAL 依赖 35 项 / 预热表 / 依赖展开与预热推导 / scoring_value_expr | ✓(全文读取,附录A 由其逐字推导) | | monitor.py:1314-1322 | score_min/max 消费 result.scores | ✓ | diff --git a/docs/features.md b/docs/features.md index 3cdab61c..757f6400 100644 --- a/docs/features.md +++ b/docs/features.md @@ -161,6 +161,10 @@ ## 🧰 数据与扩展 +### 页面包(运行时扩展页面) + +页面可以打包成**单文件 ES 模块产物**(`frontend/page-pack-template` 一键构建),在「设置 → 页面包」上传即挂载为完整页面 — 路由、侧栏入口、菜单管理自动注册,**宿主无需重新构建**;本地 dev / 生产 / 桌面端一致生效。上传时即时试载校验契约(清单格式、路由冲突),坏包停用并显示原因;启停删除随时管理,刷新后生效。源码级扩展(`frontend/src/custom/`)与声明式分析页面仍是随构建的另外两条扩展路径。契约与安全边界见 [page-sdk.md](./page-sdk.md)。 + ### 数据源插件化 内置 [TickFlow](https://tickflow.org) 官方 SDK(日 K / 分钟 K / 指数 / 财务 / 实时行情),同时支持接入第三方数据源:YAML 声明自有 HTTP 接口,或开发插件源。多个数据集(日K / 除权因子 / 实时行情 / 分钟K / 五档盘口 / 财务,注册表持续扩展)按源声明自由组合,在「设置 → 数据源」切换。 @@ -195,6 +199,14 @@ timeseries 模式的表 (如人气排行) 默认只能从开启拉取之日起 数据量大的接口 (全市场排行、批量快照) 单次响应装不下时, 拉取配置可声明分页 (仅 GET): **页码参数名**(`page_param`, 如 `page`)、**每页条数参数名**(`page_size_param`, 如 `pageSize`)与**每页条数**(`page_size`)。配置后单次拉取按页循环请求并合并结果, 空页/不足一页自动判停; **最多页数**(`max_pages`, 默认 20, 上限 200)防止接口永远返回数据拖死循环。POST body 分页不支持 (请求体是固定模板)。 +##### 两级拉取协议 (目录 → 逐条目) + +有些数据源只提供「类别清单」+「单类别成员」两个端点 (同花顺概念/行业、多数板块类 API), 没有一次返回「标的 → 所属类别」全量映射的接口。这类源在拉取配置里声明 `fanout` 配方即可, 无需任何按表硬编码: 先请求 `pull.url` 拿第一级目录, 再按 `fanout.param` 并发拉每个条目的成员, 最后**反转为「标的 → 类别清单」**并与普通单请求路径产出同构的行。 + +配方字段 (除注明外均为字符串): `url` 第二级地址、`param` 第二级查询参数名、`id_field` 第一级条目中作为该参数值的字段、`label_field` 展示名字段 (可选, 仅用于告警)、`items_path` 第二级响应的成员数组 dot-path、`member_id_field` / `member_label_field` 成员里的代码与名称字段、`symbol_col` / `name_col` / `value_col` 输出列名、`separator` 多类别拼接符 (概念用 `;`, 行业层级用 `-`)、`concurrency` 并发上限 (默认 8)、`retries` 单条目失败重试次数 (默认 1, 用尽则跳过该条目并记 warning; 全部条目失败才报错)。鉴权与标识头沿用 `pull.auth` / 通用出站口径。 + +概念/行业两张内置表出厂即带完整配方, 在「数据」页展开本表 → **拉取** 可查看全部字段, 接入自有板块类源时照抄即可。 + ##### `/rows` 查询接口 (面向二次开发) 每张扩展表天然是标准 HTTP 接口 `GET /api/ext-data/{id}/rows`, 供页面之外的任意消费方调用: @@ -303,4 +315,4 @@ AI 客户端 ⇄ MCP stdio (薄桥) ⇄ HTTP + Bearer Token ⇄ 开放网关 ### 桌面客户端版本清单 -发布流水线会在每个 Release 附带 `latest.json`(版本号、三平台下载地址、sha256),设置 → 系统设置 → 检查更新即基于它(优先 GitHub API)提示新版本。 +发布流水线会在每个 Release 附带 `latest.json`(版本号、三平台下载地址、sha256),设置 → 关于 → 检查更新即基于它(优先 GitHub API)提示新版本。 diff --git a/docs/page-sdk.md b/docs/page-sdk.md new file mode 100644 index 00000000..66e494d4 --- /dev/null +++ b/docs/page-sdk.md @@ -0,0 +1,72 @@ +# 页面包开发指南 (Page Pack SDK v1) + +页面包是**运行时可挂载的页面产物**:把页面打包成单文件 ES 模块(`extension.js`),在「设置 → 页面包」上传,刷新后即成为完整页面——自动注册路由、侧栏入口,可在菜单设置里排序/隐藏。本地 dev、生产构建、桌面端三种运行形态一致生效,宿主无需重新构建。 + +## 快速开始 + +```bash +cp -r frontend/page-pack-template my-pack && cd my-pack +pnpm install && pnpm build # → dist/extension.js +``` + +打开宿主「设置 → 页面包」→ 填 ID(字母/数字/下划线)→ 选择 `dist/extension.js` → 上传并校验 → 刷新页面。 + +## 产物契约 (apiVersion 1) + +产物必须 `export default` 一个清单对象: + +```tsx +const extension = { + id: 'demo.hello', // 带命名空间且全局唯一; 冲突时整个包被拒 + apiVersion: 1 as const, + routes: [{ + id: 'hello', + path: '/demo-hello', // 非根绝对路径、静态(不支持 :param/通配), 不得覆盖核心或已有页面 + mount: (el, ctx) => { // 渲染进容器; 返回清理函数(可选) + const root = createRoot(el) + root.render() + return () => root.unmount() + }, + }], + navigation: [{ + id: 'hello', routeId: 'hello', label: '页面包演示', icon: 'Sparkles', + }], +} +export default extension +``` + +校验与冲突规则由宿主注册表统一执行(与源码级扩展 `frontend/src/custom/` 同一机制);上传时即时试载校验,启动时再次校验,失败会停用该包并在面板显示原因。 + +## 能力桥 ctx + +页面包**自包含**(React 等依赖由模板打进产物,与宿主不共享模块实例),宿主能力经 `ctx` 暴露: + +| 能力 | 说明 | +| --- | --- | +| `ctx.api` | 宿主完整 API 封装(同源 fetch、自动携带登录态、统一错误处理) | +| `ctx.QK` | 集中查询键(若你自行使用 TanStack Query 可作 key 规范) | +| `ctx.cn` | 样式类合并工具(clsx + tailwind-merge) | +| `ctx.format` | 数值/日期格式化工具集 | + +**禁止** import 宿主内部模块(`@/lib/...` 等别名在产物构建中不可解析);数据请求一律经 `ctx.api`,不得自行拼后端 URL 绕过封装。 + +## 图标白名单 + +`navigation[].icon` 按名称解析,白名单:`Activity BarChart3 Bell BookOpen Brain Calendar Database Globe LineChart PieChart Shield Sparkles Star Table TrendingUp Zap`(缺省 `Sparkles`)。需要扩充时在 `frontend/src/extensions/runtime.tsx` 的 `ICON_ALLOWLIST` 增补。 + +## 约束与边界 + +- **样式**:宿主 Tailwind 按需编译,任意类名不保证存在——布局关键样式用内联样式;主题色用宿主 CSS 变量(如 `var(--accent)`)。 +- **路由集成**:页面挂在宿主路由容器内(`/你的路径` 可直达、可刷新),但包内不共享宿主 Router 上下文——需要跳转宿主页面时用 `window.location` 或自行包 `MemoryRouter`。 +- **生效方式**:上传/启停后**刷新页面生效**(挂载发生在启动引导窗口),不做热插拔。 +- **大小**:上限 5MB/包;自包含 React 后典型产物 60–150KB(minify + 可自行加 brotli/gzip 由后端协商)。 +- **安全**:页面包是任意代码,与登录会话同权运行——只上传自己或可信来源的产物;这是单用户产品的既定边界,第三方插件沙箱不在 v1 范围。 +- **版本**:宿主升级破坏契约时提升 `apiVersion` 并保留旧版兼容期;产物内 `apiVersion` 不匹配会被明确拒绝。 + +## 与其他扩展方式的取舍 + +| 方式 | 适用 | 见 | +| --- | --- | --- | +| 页面包(运行时产物) | 免重建挂载、独立分发 | 本文档 | +| 源码级扩展 `frontend/src/custom//extension.tsx` | 随宿主一起构建维护、可用全部核心模块 | `docs/secondary-development.md` §3 | +| 声明式分析页面(零代码) | 扩展数据 + 模板化列表/分组页 | 设置 → 扩展页面 | diff --git a/docs/plugin-development.md b/docs/plugin-development.md index ce53134f..e394b012 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -34,6 +34,11 @@ hidden: false # (可选)true = 已加载但对设置 description: "数据源描述" install_hint: "pip install xxx" # 未装依赖时显示的安装提示 homepage: "https://example.com" # (可选)官网/申请地址, 显示在设置页 Key 配置说明中 +billing: points # (可选)计费标识: "points" → 设置页能力芯片/适配行标注「积分」 +billing_note: "消耗与频率/范围相关,间隔越短越高" # (可选)计费提示: 设置页按警示框展示 +usage_notes: # (可选)使用要点: 各能力规模/节奏, 详情卡以列表展示 + - "实时快照: 600 只/批, 全市场一轮约 10 批" +key_manage_url: "https://example.com/me" # (可选)Key/账务管理页, 显示在设置页 Key 配置区(如积分明细入口) ``` 只声明真实提供的数据集;未声明的数据集 `provider_has_dataset` 返回 False,自动回退 @@ -313,6 +318,11 @@ uv run --extra dev python -m ruff check app/plugins// tests/test_= 0.8 && z <= 1.5) { - document.documentElement.style.zoom = String(z) - } - } catch (e) { /* ignore */ } + var raw = localStorage.getItem('tf-page-zoom') + var z = raw == null ? 0.9 : JSON.parse(raw) + if (typeof z !== 'number' || z < 0.8 || z > 1.5) z = 0.9 + document.documentElement.style.zoom = String(z) + } catch (e) { + document.documentElement.style.zoom = '0.9' + } })() diff --git a/frontend/package.json b/frontend/package.json index 490915e3..add8f574 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.3.5", + "version": "0.3.6", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/page-pack-template/README.md b/frontend/page-pack-template/README.md new file mode 100644 index 00000000..d5cfa723 --- /dev/null +++ b/frontend/page-pack-template/README.md @@ -0,0 +1,25 @@ +# 页面包模板 (Page Pack Template) + +把一个页面打包成**单文件 ES 模块产物**(`dist/extension.js`),上传到运行中的面板(设置 → 页面包),刷新后即成为完整页面——自动注册路由与侧栏入口,可在菜单设置里排序/隐藏。本地 dev、Docker、桌面端一致生效,**宿主无需重新构建**。 + +## 打包 + +```bash +pnpm install +pnpm build # → dist/extension.js +``` + +产物自包含(React 等依赖由本模板打进 bundle,不与宿主共享模块实例),典型体积 60–150KB,上限 5MB。 + +## 上传 + +面板 **设置 → 页面包**:填 ID(字母/数字/下划线)→ 选择 `dist/extension.js` → 上传并校验(即时试载,清单不合法或路由冲突会被拒绝并显示原因)→ 启用 → 刷新页面。 + +## 开发要点 + +- `src/extension.tsx` — 产物清单(id / routes / navigation)与示例页面;`mount(el, ctx)` 渲染进容器,返回清理函数。 +- `src/tsp.ts` — 从 `window.__TSP_EXT__` 取宿主能力桥 `ctx`:`ctx.api`(宿主完整 API 封装)、`ctx.QK`、`ctx.cn`、`ctx.format`。 +- **禁止** import 宿主内部模块(`@/lib/...` 在产物构建中不可解析);数据请求一律经 `ctx.api`。 +- 样式:宿主 Tailwind 按需编译,任意类名不保证存在——布局关键样式用内联样式,主题色用宿主 CSS 变量(如 `var(--accent)`)。 + +完整契约(apiVersion、图标白名单、路由规则、安全边界)见 [`docs/page-sdk.md`](../../docs/page-sdk.md)。 diff --git a/frontend/page-pack-template/package.json b/frontend/page-pack-template/package.json new file mode 100644 index 00000000..08008568 --- /dev/null +++ b/frontend/page-pack-template/package.json @@ -0,0 +1,20 @@ +{ + "name": "tsp-page-pack", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/page-pack-template/src/extension.tsx b/frontend/page-pack-template/src/extension.tsx new file mode 100644 index 00000000..2cb67f01 --- /dev/null +++ b/frontend/page-pack-template/src/extension.tsx @@ -0,0 +1,86 @@ +/** + * 页面包示例 — 复制本模板改造你的页面。 + * + * 产物契约(v1): + * - default 导出清单对象; apiVersion 固定 1 + * - id 带命名空间 (如 demo.hello), 全局唯一, 与核心/其他扩展冲突会被拒 + * - routes[].path 非根绝对路径且静态 (不支持 :param / 通配) + * - mount(el, ctx): 把页面渲染进容器, 返回清理函数(可选) + * - navigation[].icon 用白名单图标名 (Sparkles/Zap/Star/... 见 docs/page-sdk.md) + * + * 样式约束: 宿主 Tailwind 按需编译, 不保证任意类名存在 — 布局关键样式 + * 用内联样式; 主题色可用宿主 CSS 变量 (如 var(--accent))。 + */ +import { useEffect, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { ctx } from './tsp' + +function HelloPage() { + const [count, setCount] = useState(0) + const [serverTime, setServerTime] = useState('') + const alive = useRef(true) + + useEffect(() => { + // 经能力桥调用宿主 API (同源、自带登录态); 这里取服务端时间做演示 + ctx.api?.health?.().then((h: { status?: string } | undefined) => { + if (alive.current) setServerTime(h?.status ? `服务状态: ${h.status}` : '') + }).catch(() => {}) + return () => { alive.current = false } + }, []) + + const card: React.CSSProperties = { + maxWidth: 640, + margin: '3rem auto', + padding: '2rem', + borderRadius: 16, + border: '1px solid rgba(125,125,125,0.25)', + background: 'rgba(125,125,125,0.06)', + fontFamily: 'inherit', + } + const button: React.CSSProperties = { + height: 36, + padding: '0 14px', + borderRadius: 10, + border: 'none', + background: 'var(--accent, #3b82f6)', + color: '#fff', + fontSize: 13, + cursor: 'pointer', + } + + return ( +

+
+

页面包演示页

+

+ 这是一个自包含的运行时页面: 打包成单文件 ES 模块后上传到「设置 → 页面包」, + 刷新即出现在侧栏。{serverTime} +

+ +
+
+ ) +} + +const extension = { + id: 'demo.hello', + apiVersion: 1 as const, + routes: [ + { + id: 'hello', + path: '/demo-hello', + mount: (el: HTMLElement) => { + const root = createRoot(el) + root.render() + return () => root.unmount() + }, + }, + ], + navigation: [ + { id: 'hello', routeId: 'hello', label: '页面包演示', icon: 'Sparkles' }, + ], +} + +export default extension diff --git a/frontend/page-pack-template/src/tsp.ts b/frontend/page-pack-template/src/tsp.ts new file mode 100644 index 00000000..d9ba8903 --- /dev/null +++ b/frontend/page-pack-template/src/tsp.ts @@ -0,0 +1,18 @@ +/** + * tsp.ts — 宿主能力桥读取。 + * + * window.__TSP_EXT__ 由宿主 main.tsx 在引导时安装; 页面包经 mount(el, ctx) + * 收到的 ctx 即本对象。api 为宿主完整 API 封装(同源 fetch, 自带登录态), + * QK 为集中查询键, cn 为样式合并工具, format 为格式化工具集。 + * + * 自包含说明: 页面包产物自带 React/ReactDOM (由本模板的 vite 构建打入), + * 与宿主不共享模块实例 —— 请勿 import 宿主内部模块, 只经由 ctx 取能力。 + */ +type Bridge = { + api: any + QK: any + cn: (...inputs: any[]) => string + format: any +} + +export const ctx = (window as unknown as { __TSP_EXT__: Bridge }).__TSP_EXT__ diff --git a/frontend/page-pack-template/vite.config.ts b/frontend/page-pack-template/vite.config.ts new file mode 100644 index 00000000..949c907c --- /dev/null +++ b/frontend/page-pack-template/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// 页面包构建: 单文件自包含 ES 模块 (react 等全部打入), 产物 dist/extension.js +// 直接上传到宿主「设置 → 页面包」。不做代码分割 — 宿主按整个文件加载挂载。 +export default defineConfig({ + plugins: [react()], + // lib 模式不会自动替换依赖里的 process.env.NODE_ENV, 浏览器无 process 全局 + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + build: { + outDir: 'dist', + lib: { + entry: 'src/extension.tsx', + formats: ['es'], + }, + rollupOptions: { + output: { + entryFileNames: 'extension.js', + chunkFileNames: '[name].js', + assetFileNames: '[name][extname]', + }, + }, + }, +}) diff --git a/frontend/public/donate-wechat.jpg b/frontend/public/donate-wechat.jpg new file mode 100644 index 0000000000000000000000000000000000000000..70cb13491e6e5298ee06436d5502c78d790aceae GIT binary patch literal 100506 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%0AZAAK&k0@_0stW|qS=GYVjQkhdtC18p?`BKEO102 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 + {/* 名称不换行 + 涨跌幅不压缩: 两列布局下单卡内容区仅 85px, + 「上证指数」41px + 涨跌幅 36px 已接近占满, 任其收缩或换行会把 + 指数名折成两行 (曾出现「上证指/数」)。px-1.5 是为此留出的余量。 */}
- {item.name} - {fmtIndexPct(pct)} + {item.name} + {fmtIndexPct(pct)}
{fmtIndexValue(value)} @@ -189,6 +172,30 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i ) } +// ===== 头部快捷行: 数据同步入口 (描边分段按钮, 与扩展快捷方式并排成组) ===== +// 点击直达 /data; 同步中转圈 / 刚完成绿勾, 空闲无角标保持安静。 +function QuickDataEntry({ syncing, justDone }: { syncing: boolean; justDone: boolean }) { + return ( + + {syncing + ? + : } + 数据 + {!syncing && justDone && } + + ) +} + // ===== 数据源能力健康卡 ===== // 能力路由架构下的侧栏状态: 不再展示「主数据源 + TickFlow 档位」(单源时代遗留 — // 五个能力各自路由, 拿日K的源代表全局是随意的), 改为回答「各能力当前是否都有源在供」。 @@ -250,8 +257,7 @@ function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefine onKeyDown={e => { if (e.key === 'Escape') setPopPos(null) }} className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70" > - - + {/* 能力方格 (按注册顺序: 实时/日K/分钟/除权/财务), 与悬浮卡逐格同色对应 */} {loading @@ -334,8 +340,7 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70" title={`AI 配置 — ${descText}`} > - - + {configured ? ( {model || '已接入模型'} @@ -395,8 +400,9 @@ export function Layout() { // 持久化到 localStorage; 迁移旧两态键 tf-nav-collapsed (收起 → 图标条)。 const [navState, setNavState] = useState(() => { try { + // 两态导航: expanded(14rem) / rail(3.5rem 图标条)。历史三态版本的 'hidden' 归一为展开 const v = localStorage.getItem('tf-nav-state') - if (v === 'expanded' || v === 'rail' || v === 'hidden') return v + if (v === 'expanded' || v === 'rail') return v return localStorage.getItem('tf-nav-collapsed') === '1' ? 'rail' : 'expanded' } catch { return 'expanded' } }) @@ -478,9 +484,9 @@ export function Layout() { // 开启实时行情时若存在排队中的挖掘任务 → 确认弹窗 (实时落盘会让排队任务开跑即失败) const [miningQueuedWarning, setMiningQueuedWarning] = useState(null) const miningWarnBackdrop = useDialogBackdrop(() => setMiningQueuedWarning(null)) - // 三态循环切换 (仅桌面): 展开 → 图标条 → 隐藏 → 展开 + // 两态切换 (仅桌面): 展开 <-> 图标条 const toggleNavCollapsed = () => { - setNavStatePersist(navState === 'expanded' ? 'rail' : navState === 'rail' ? 'hidden' : 'expanded') + setNavStatePersist(navState === 'expanded' ? 'rail' : 'expanded') } // 指数条: 固定核心四只 (产品契约, 不再可配置), 常驻显示 const sidebarIndexes = CORE_INDEXES @@ -556,15 +562,18 @@ export function Layout() { // 合并内置页面 + 可见的扩展分析菜单 type NavIcon = (props: BrandIconProps) => ReactNode - type NavItem = { to: string; label: string; icon: NavIcon; badge?: string } + // arch: 'ext' = 扩展域页面 (官方扩展/自定义分析), 核心域 nav 常量不带标。 + // 与菜单设置 ARCH_CLASS 同口径: nav 常量 8 项全部核心, 其余两个来源全部扩展。 + type NavItem = { to: string; label: string; icon: NavIcon; badge?: string; arch?: 'core' | 'ext' } const analysisNav: NavItem[] = (analysisMenus?.items ?? []) .filter(m => m.visible) - .map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 })) + .map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3, arch: 'ext' as const })) const extensionNav: NavItem[] = getFrontendExtensionNavigation().map(item => ({ to: item.route.path, label: item.label, icon: item.icon, badge: item.badge, + arch: 'ext' as const, })) const allNav: NavItem[] = [...nav, ...analysisNav, ...extensionNav] @@ -580,26 +589,51 @@ export function Layout() { const merged = [...ordered] for (const item of allNav) { if (seen.has(item.to)) continue - // 未保存过排序的新条目: 内置页插回默认位置(排在已保存的默认前驱之后), - // 分析/扩展菜单仍追加到末尾 - const defaultIndex = nav.findIndex(n => n.to === item.to) + // 未保存过排序的新条目: 默认基准内的路径插回默认位置(排在已出现的 + // 最近默认前驱之后), 分析菜单/未知扩展追加到末尾。基准含迁移到 + // custom/ 的扩展域页面, 保证老用户升级无感 (lib/navOrder)。 + const di = defaultNavIndex(item.to) let anchor = -1 - if (defaultIndex > 0) { - for (let i = defaultIndex - 1; i >= 0 && anchor < 0; i -= 1) { - anchor = merged.findIndex(n => n.to === nav[i].to) + if (Number.isFinite(di) && di > 0) { + for (let i = di - 1; i >= 0 && anchor < 0; i -= 1) { + anchor = merged.findIndex(n => defaultNavIndex(n.to) === i) } } if (anchor >= 0) merged.splice(anchor + 1, 0, item) - else if (defaultIndex >= 0) merged.unshift(item) + else if (di === 0) merged.unshift(item) else merged.push(item) } return merged })() - : allNav + // 无保存排序 (新装/从未调整过菜单): 按迁移前默认基准排序, 扩展域页面 + // 不沉底 — 老用户升级侧栏顺序与迁移前一致; 未知路径稳定追加在后。 + : [...allNav].sort((a, b) => compareByDefaultNav(a.to, b.to)) const hiddenIds = new Set(prefs?.nav_hidden ?? []) const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, ''))) + // 新装用户一次性种子: 扩展域页面默认隐藏, 菜单设置里可逐个开启。 + // 老用户 (升级前已完成 onboarding) 只写标记不动偏好, 升级前后侧栏一致。 + // 种子标记存 localStorage (每设备一次, 保存成功后才写); 隐藏偏好本体持久化在后端 nav_hidden。 + const extSeedRef = useRef(false) + useEffect(() => { + if (!prefs || !settingsState || extSeedRef.current) return + let seeded = false + try { seeded = localStorage.getItem('tf-ext-pages-seed') === '1' } catch { return } + if (seeded) { extSeedRef.current = true; return } + const next = seedHiddenForNewUser(settingsState.onboarding_completed, prefs.nav_hidden) + if (!next) { + extSeedRef.current = true + try { localStorage.setItem('tf-ext-pages-seed', '1') } catch {} + return + } + extSeedRef.current = true + api.saveNavHidden(next) + .then(() => { try { localStorage.setItem('tf-ext-pages-seed', '1') } catch {} }) + .then(() => qc.invalidateQueries({ queryKey: QK.preferences })) + .catch(() => { /* 保存失败不写标记, 下次启动重试; 结果集幂等, 重复触发无害 */ }) + }, [prefs, settingsState, qc]) + const doEnableRealtime = async () => { await toggleQuote.mutateAsync(true) // 仅在交易时段立即获取一次行情 @@ -704,10 +738,7 @@ export function Layout() { style={{ color: BRAND }} /> {!railMode && ( -
+
Tick Stock Panel
)} @@ -719,7 +750,7 @@ export function Layout() { 'flex items-center rounded-btn text-muted hover:text-foreground hover:bg-elevated/60 transition-colors duration-150 ease-smooth', railMode ? 'justify-center p-1.5' : 'ml-auto p-1.5', )} - title={railMode ? '隐藏菜单 (再点击左缘按钮可唤出)' : '收起菜单'} + title={railMode ? '展开菜单' : '收起菜单'} > {railMode ? @@ -741,17 +772,22 @@ export function Layout() { {!railMode && (
- -