Compare commits

..

52 Commits

Author SHA1 Message Date
fish 2f2b53e03e hjg 账号默认使用 DeepSeek Pro AI 配置 2026-07-07 22:17:00 +08:00
fish 490fb7ac0a 移除 serve 看板涨停梯队降级标签 2026-07-07 21:47:56 +08:00
fish 566a8be912 服务端同步改为按行合并去重 2026-07-07 21:30:54 +08:00
fish 476201abed 整理项目说明与许可文件 2026-07-07 21:15:01 +08:00
fish e21d2f69ee 更新项目说明文档 2026-07-07 21:13:55 +08:00
fish 0ad4b1f030 财务分析清理同步相关代码
移除能力检查、同步按钮、进度追踪等未用代码,
仅靠 status.available 判断数据是否存在。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 16:09:39 +08:00
fish 7a96d51f8a 财务分析移除 Expert 套餐能力门控
serve 只读模式,财务数据仅来自 local 同步,
不再依赖 TickFlow Expert 能力。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 16:04:47 +08:00
fish f408362c91 移除引导配置页面
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:53:00 +08:00
fish 008f92ffe3 登录页账号输入框默认改为空
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:45:29 +08:00
fish eae13737ff 登录页移除底部文案
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:45:13 +08:00
fish fcebba0a32 Docker容器改名
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:19:07 +08:00
fish e7cd30b30d 侧边栏增加 admin 用户管理入口
admin 用户可在侧边栏底部进入用户管理页,
支持新增/删除用户。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:05:35 +08:00
fish c1fa0461c1 复盘页添加日期选择功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:04:01 +08:00
fish 267b8557e8 概念/行业分析添加日期选择功能
- backend: market_snapshot 支持可选 as_of 参数
- frontend: 两页加 DatePicker,按选定日期查询数据

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:58:31 +08:00
fish 4cd74b06f1 指数页移除日K来源文案
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:54:37 +08:00
fish ef6e308ad1 指数页移除分时数据面板,K线图占满
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:51:50 +08:00
fish 26a7c02887 指数页隐藏副标题文案
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:49:57 +08:00
fish 5c4ae36adc 指数页移除同步按钮及相关代码
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:48:21 +08:00
fish 22415d3f25 概念/行业分析移除配置图标及相关代码
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:42:22 +08:00
fish 6c770f828e 连板梯队移除 SealedBadge 降级标签
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:35:16 +08:00
fish 833e4e90b9 连板梯队/概念/行业分析移除右上角刷新按钮
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:31:41 +08:00
fish 3a963a130f AI 分析报告按用户隔离存储
个股/财务/复盘报告改为 data/user_data/reports/{username}/ 目录下,
API 端从 request.state.username 读取对应用户的报告。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:29:10 +08:00
fish efdfe29ccc 概念/行业分析移除数据获取功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:26:06 +08:00
fish a415762a77 财务面板移除同步时间信息
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:21:31 +08:00
fish c08e7f2c54 AI 配置改为按用户独立存储
每个用户 AI 配置存 data/user_data/ai_settings/{username}.json,
读配置时用户级优先,无则回退到全局 secrets.json。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:10:32 +08:00
fish 9ecfc2bb71 侧边栏底部移除设置图标
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 13:00:00 +08:00
fish d2f428ec86 侧边栏底部改为登录用户名 + 暗夜模式开关
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:57:31 +08:00
fish fc69f51e23 serve 多用户认证体系
单密码→多用户:auth.json 格式迁移,login 支持用户名密码,
新增用户管理 API 和前端页面,会话关联 username,
auth middleware 将当前用户注入 request.state。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:49:20 +08:00
fish 22049582df AI 设置隐藏 Codex CLI 预设入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:42:10 +08:00
fish b2a4e90e74 AI 设置隐藏炸鸡中转站预设入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:40:07 +08:00
fish 6dfce96bb3 AI 设置独立为单独页面 /settings/ai
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:37:08 +08:00
fish 4384f5fa00 复盘隐藏定时入口并清理相关代码
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:35:03 +08:00
fish 331ff3067f 个股分析隐藏点位提醒入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:32:45 +08:00
fish 11dc81a189 个股分析移除顶部 Beta 标签和历史报告入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:30:04 +08:00
fish 431aae0882 看板跌停梯队改为真实跌停数据展示
移除监控中心告警组件,新增 DownLadderMini 组件调用
limit-ladder API(direction=down)获取真实跌停梯队数据,
配色用 red/bear 色系,与涨停梯队布局一致。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:25:33 +08:00
fish 0861e68fe4 看板监控中心标题改为跌停梯队
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:17:27 +08:00
fish a83da2b8b4 serve 侧边栏移除个股分析和复盘的 Beta 标签
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:12:42 +08:00
fish c71f45e147 serve 侧边栏品牌名改为 A股工作台
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:11:21 +08:00
fish fa89b382df serve 侧边栏移除 Quant · Terminal 副标题
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:09:14 +08:00
fish e843d7556d serve 品牌名改为「A股工作台」
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:08:53 +08:00
fish 580743853a serve 设为只读模式,禁用外部数据拉取
serve 端不再启动 pull_scheduler 和 financial_scheduler,
阻塞 ext_data 和 financials 的外部获取/同步 API 端点,
数据仅通过 local→serve 同步推送。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:04:46 +08:00
fish 817d130fd3 数据同步页面新增扩展数据(概念/行业)同步选项
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:01:14 +08:00
fish 8031f27f85 新增同步 ext_data(概念/行业) 和本地化财务数据展示
- 将 ext_data 加入 SYNCABLE_PARTS,概念/行业分类数据可从 local 同步到 serve
- 去掉 serve 财务分析接口的 Cap.FINANCIAL 能力门控,依赖本地 parquet 文件来展示数据
- serve 财务分析前端在无 API Key 但有同步数据时正常展示,隐藏同步按钮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 10:53:00 +08:00
fish 274b90eef6 新增 local→serve 数据同步功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 10:27:00 +08:00
fish 1ae2102b4c 隐藏策略与 API 配置菜单入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 10:01:56 +08:00
fish db978240da 将 serve/data 加入忽略文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 18:06:18 +08:00
fish 0be67be799 修复未使用变量的构建错误
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 17:58:54 +08:00
fish aa76c19aff 隐藏数据与监控中心菜单入口
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 17:57:06 +08:00
fish f8c946e8a9 移除交易功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 17:54:41 +08:00
fish a45aa5b033 移除自选和回测功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 17:41:08 +08:00
fish ab6282b94c 修复 gitignore 匹配 package-lock.json 路径
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 17:33:53 +08:00
fish 9904854cc1 删除实时行情、盘口深度和监控规则功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 17:32:39 +08:00
132 changed files with 2462 additions and 18222 deletions
+3 -1
View File
@@ -44,7 +44,7 @@ backend/static/
backend/app/static/
# ===== npm lockfile (不纳入版本控制) =====
frontend/package-lock.json
**/package-lock.json
# ===== IDE =====
.idea/
@@ -58,6 +58,8 @@ backend/data/**
!backend/data/.gitkeep
data/**
!data/.gitkeep
serve/data/**
!serve/data/.gitkeep
# 扩展数据: 全部纳入版本控制(含 config.json 表结构 + part.parquet 数据文件)
# 注意 gitignore 否定规则特性: 需先放行目录才能放行内部文件
!data/ext_data/
+49 -1
View File
@@ -1 +1,49 @@
# README
# A股智能量化工作台
本项目是一个面向个人散户与量化爱好者的 **A 股「选股 + 监控 + 回测」量化工作台**,采用前后端分离架构,支持 Docker 一键部署。
## 目录结构
```
stock/
├── local/ # 本地完整版(满血功能)
└── serve/ # 服务端轻量版(备份 + 基础查看)
```
### local/ — 本地完整版
自托管的满血工作站,所有功能本地运行:
- **选股**:20+ 内置策略,支持自定义信号与 AI 生成策略
- **回测**:基于 vectorbt 的向量化回测,支持 T+1、手续费、止损等真实约束
- **实时监控**:策略 / 个股信号 / 价格 / 异动四类规则,SSE 实时告警 + 飞书推送
- **个股分析**:日 K 图表、9 类关键价位、AI 四维分析
- **数据扩展**TickFlow 多源数据 + 第三方 HTTP / CSV / Excel 接入
详细说明与部署指南见 [`local/README.md`](./local/README.md)。
### serve/ — 服务端轻量版
`local/` 的云端伴侣,用于**数据备份**与**远程基础查看**:
- 接收 `local/` 推送的选股结果、复盘报告、历史 K 线等基础数据
- 支持多用户(admin / viewer)角色隔离
- 不包含回测、实时监控、实时行情等需要本地运行的能力
详细说明与部署指南见 [`serve/README.md`](./serve/README.md)。
## 快速选择
| 场景 | 使用目录 |
| :--- | :--- |
| 在自己的电脑 / 服务器上跑完整功能 | [`local/`](./local/README.md) |
| 只需要远程备份和查看基础数据 | [`serve/`](./serve/README.md) |
| 既要本地分析,又要远程查看 | 同时部署 `local/` + `serve/`,并通过 `SYNC_SERVE_URL` 同步 |
## 数据安全提示
两个目录下的 `data/` 文件夹均为运行时生成的用户数据,**不纳入 git 管理**。更新代码时 `git pull` 不会影响已有数据,但请勿使用 `git clean -fdx``git reset --hard`,以免误删数据。
## 开源协议
[MIT](./LICENSE)
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 tickflow-stock-panel contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+112 -106
View File
@@ -1,10 +1,10 @@
<div align="center">
# 📈 A股智能量化工作台
# 📈 A股智能量化工作台(本地完整版)
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
**自托管、满血功能的 A 股「选股 + 监控 + 回测」量化工作台**
**面向个人散户与量化爱好者而生**
**面向个人散户与量化爱好者,所有功能本地运行**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/)
@@ -17,26 +17,23 @@
<div align="center">
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[路线图](#-路线图)**
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[同步到服务端](#-同步到服务端)**
</div>
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
- 🇨🇳 **A 股专用**盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业
- 🆓 **开箱即用** — 留空 Key 即进 None 模式历史日 K 免费体验**无需付费**
- 🏠 **自托管零运维** — Docker 单容器部署数据完全掌握在自己手里
- 🔍 **三位一体** — 选股20 内置策略+ 实时监控 + 向量化回测Polars 毫秒级扫描全 A 股
- 🤖 **AI 加持** — 一句话生成策略代码任意 OpenAI 兼容接口均可接入留空即关闭
- 📡 **实时行情与监控** — 自选股实时行情、五档盘口、监控规则命中后 SSE 弹窗 + 飞书推送
- 🔌 **自由扩展**自有量化项目数据,与内置数据同台分析
- 🇨🇳 **A 股专用** — 盘后自动 AI 复盘并推送至飞书等;连板梯队、涨停动量、内置 ths 概念 / 行业
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
> ⚠️ 考虑到 tickflow 数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的 ths 概念/ths 行业(后续更新在这里)
>
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.comq群 109338242
觉得有用可以点个 Star,蟹蟹 🌹
@@ -44,7 +41,9 @@
## 🎯 项目定位
**面向个人散户与量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具
`local/` 目录下的版本是**本地完整版**,面向希望在自己的电脑或局域网中运行全部功能的用户
它包含完整的选股、回测、实时监控、个股分析、财务分析、连板梯队、概念/行业分析等全部能力,是项目功能的「满血」形态。同时支持把数据同步到远程 `serve/` 服务端,作为云端备份和基础数据查看入口。
---
@@ -96,19 +95,19 @@
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| `pnpm` | 9 | `npm i -g pnpm` |
### 方式 A:Dev 模式(二次开发推荐)
### 方式 ADev 模式二次开发推荐
```bash
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
cp .env.example .env # 按需填 TICKFLOW_API_KEY留空 = None 模式
./dev.sh # Windows: .\dev.ps1
```
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
自动检查 / 下载依赖、释放端口、同时起前后端Ctrl-C 一并关闭。默认
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
- 自定义端口`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
### 方式 B:Docker(部署最省心)
### 方式 BDocker部署最省心
```bash
cp .env.example .env
@@ -116,12 +115,14 @@ docker compose up --build
# 打开 http://localhost:3018
```
容器名:`TickFlow_Local`。默认端口 `3018`,数据持久化到 `./data/`
<details>
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
<summary><b>环境适配与高级选项老 CPU · 手动启动 · 回测依赖</b></summary>
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`
**老 CPU 兼容avx2/fma 缺失报错或 exit 132**桌面客户端安装包已内置兼容内核新老 CPU 通吃。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建会给 Polars 切到 `rtcompat` 运行时需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`
**手动分别启动:**
**手动分别启动**
```bash
# 后端
@@ -132,42 +133,42 @@ uv run uvicorn app.main:app --reload --port 3018
cd frontend && pnpm install && pnpm dev # http://localhost:3011
```
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
**回测依赖**vectorbt → numba 体积较大作为可选 extras`uv sync --extra backtest`。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
</details>
### 🔄 更新代码(已部署用户必读)
### 🔄 更新代码已部署用户必读
拉取新版本只需一条命令:
拉取新版本只需一条命令
```bash
git pull
```
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录乃至概念/行业扩展数据全部是程序运行时生成/拉取的用户数据`git pull` 物理上无法影响它们。新用户首次启动时概念/行业两份扩展数据会自动从远程接口拉取无需任何手动操作。
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
> ⚠️ **切勿使用以下命令"解决冲突"或"清理"它们会一次性删光 `data/` 下所有未被 git 跟踪的数据**
> - `git clean -fdx`最危险会删掉所有 `.gitignore` 忽略的文件
> - `git reset --hard`
> - 直接删除整个项目文件夹重新 `git clone`
>
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
> 若 `git pull` 报冲突通常是本地误改了被跟踪的文件请先 `git stash` 暂存再 pull或单独联系作者不要直接执行上面的命令。
### 🧭 跑起来后的第一次使用
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
2. **设置****立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
1. **设置 → 凭据与能力** → 点 **重新检测**确认档位标签
2. **设置****立即跑盘后管道**拉日 K + 计算 enriched 表None / Free 走 free-api当日数据盘后 1-2 小时可用
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细SSE 实时进度
5. **监控中心**配规则策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
---
## ✨ 核心功能
### 🔍 选股引擎(Screener)
### 🔍 选股引擎Screener
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
**20 个内置策略**每个策略一个独立 Python 文件基于 Polars 表达式向量化实现`backend/app/strategy/builtin/`):
| 类型 | 代表策略 |
| :---------- | :------------------------------------------------------- |
@@ -175,79 +176,81 @@ git pull
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
**扩展策略的三种方式:**
**扩展策略的三种方式**
| 方式 | 说明 |
| :---------------- | :---------------------------------------------------------------------------------------------------- |
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(`ast` 校验)→ 落入 `data/strategies/ai/` |
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
| **🎛️ 自定义信号** | 不写代码UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
| **🤖 AI 生成** | 一句话描述思路LLM 读 `strategy-guide.md` 生成完整策略文件`ast` 校验→ 落入 `data/strategies/ai/` |
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`引擎自动发现 |
### 📊 指标流水线(Indicators)
### 📊 指标流水线Indicators
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
原生 Polars 向量化全 A 股一次扫表落盘 enriched Parquet
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**:基于除权因子自动前复权,回测与指标口径一致
- **均线 / 趋势**MA5-60· EMA · MACD · 动量 · 布林带
- **震荡 / 波动**RSI · KDJ · ATR · 年化波动率 · 振幅
- **量能 / 涨跌停**量比 · 量均线 · 涨停信号 · 连板数
- **原子信号**MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**基于除权因子自动前复权回测与指标口径一致
### 🧪 回测引擎(Backtest)
### 🧪 回测引擎Backtest
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
基于 vectorbt**三种模式**个股 / 策略组合 / 自由信号组合),真实约束T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理最大持仓 · 敞口 · 等权 / 自定义仓位。SSE 流式进度支持切页重连输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
### 📡 监控中心(Monitor)
### 📡 监控中心Monitor
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
统一规则引擎一个页面管理**四类监控**策略 · 个股信号 · 价格涨跌 · 全市场异动):
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
- **触发记录详情**:每条记录展示命中的具体条件(`RSI>80`)与当前价位,一眼看清为何触发
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
- 多条件 AND/OR + 冷却期去重 + 严重级别info/warn/critical
- 多入口配置监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
- 命中后右下角弹窗可配声效+ 持久化到 `alerts.jsonl`菜单未读徽标
- **触发记录详情**每条记录展示命中的具体条件`RSI>80`与当前价位一眼看清为何触发
- **飞书 Webhook 推送**全局一处配置飞书群机器人地址启用推送的规则命中即推送到飞书群支持签名校验);可在设置页设「默认推送渠道」新建规则自动预填
- **SSE 实时推送**:本地运行时,命中告警通过 Server-Sent Events 实时推送到前端
### 📈 个股分析(Beta)
### 📈 个股分析Beta
以「行情 + 关键价位」为主体的单标的决策页:
以「行情 + 关键价位」为主体的单标的决策页
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
- **专用日 K 图表**主图 + 成交量 + 滑块默认近 6 个月
- **9 类关键价位**纯函数实时计算毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**技术 / 基本面 / 财务 / 消息面流式生成实战派交易员视角
### 🧰 数据与扩展
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
- **TickFlow 多源数据**日 K / 分钟 K / 指数 / 财务 / 实时行情
- **🔌 第三方接入重点**Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入自动 schema 发现 + 符号归一页面可视化配置**可与自有量化项目数据并入 DuckDB 同台分析**
- **盘后定时管道**APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
- **令牌桶限流**适配各档位 rpm / batch批量合并 + 增量拉取
---
## ⚙️ 配置
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
所有配置从根目录 `.env` 读取复制 `.env.example` 开始),也可在面板 **设置** 页修改。
### 数据源:TickFlow
### 数据源TickFlow
```ini
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
TICKFLOW_API_KEY= # 留空 = None 模式历史日K免费);填 Key = 按订阅档位解锁
```
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
留空即 None 模式通过 free-api 使用历史日 K当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式开启自选股实时监控。**实时行情按档位**
| 档位 | 实时能力 |
| :------- | :--------------------------------------- |
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
| None | 无实时行情,仅历史日 K |
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
| Starter+ | 全市场实时行情 |
| Pro | 分钟 K + 盘口 |
| Expert | WebSocket + 财务数据 |
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/)高等档位含较低档全部权益。
### AI(可选)
### AI可选
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
用于自然语言生成策略。**所有配置留空即跳过**不影响核心功能。支持任意 OpenAI 兼容接口
```ini
AI_PROVIDER=openai_compat # openai_compat | ollama
@@ -268,15 +271,33 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
### 访问密码
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
面板首次设置访问密码时出于安全考虑**仅允许本机或内网访问**防公网陌生人抢先设置锁死面板。公网服务器部署有两种方式设首个密码
1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取):
1. **环境变量预置推荐** — 在 `.env` 填入 `AUTH_PASSWORD`首次启动自动初始化哈希后写入 `auth.json`之后不再读取):
```ini
AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖
AUTH_PASSWORD=你的密码 # 至少 6 位仅首次生效已设过则不覆盖
```
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`浏览器开 `http://127.0.0.1:3018` 设密码
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI`设置 → 修改密码`
---
## 🔄 同步到服务端
本地完整版支持把核心数据同步到远程 `serve/` 服务端,作为:
- **异地备份**:防止本地磁盘损坏导致历史数据丢失
- **远程查看**:在手机或其他设备上通过浏览器查看基础行情与选股结果
配置方式:
```ini
SYNC_SERVE_URL=https://your-serve.example.com
SYNC_KEY=your-shared-secret
```
> 同步是**单向**的:本地 → 服务端。服务端只接收并展示基础数据,不包含本地版的实时行情、回测、监控等复杂能力。详细说明见 `../serve/README.md`。
---
@@ -285,12 +306,13 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
| 层 | 选型 |
| :----------- | :------------------------------------------------------------------------------------------------ |
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
| **回测** | vectorbt(全项目唯一 pandas 边界) |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
| **数据** | Polars计算· DuckDB查询· Parquet存储 |
| **回测** | vectorbt全项目唯一 pandas 边界 |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK |
| **AI**可选 | OpenAI 兼容接口DeepSeek / 通义 / Ollama 等 |
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
| **部署** | Docker 两阶段构建前端 dist 拷进后端镜像**单容器** |
| **桌面端** | PyInstaller + Inno SetupWindows 安装包) |
---
@@ -301,28 +323,12 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
| 6 | 个股分析专用日 K + 9 类关键价位 + AI 四维分析 | ✅ |
| **v2** | Webhook 推送QMT/掘金下单· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
---
## 📚 文档与贡献
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南AI 生成与手写规范
- [docs/](./docs) —— 策略构建步骤、示例
欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
---
## ⚠️ 免责声明
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
## 📄 License
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
## 社区
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
+201
View File
@@ -0,0 +1,201 @@
"""数据同步 — 将本地 data/ 打包推送到 serve 端。"""
from __future__ import annotations
import json
import logging
import tarfile
import tempfile
from datetime import datetime
from io import BytesIO
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/data/sync", tags=["data-sync"])
# 允许同步的子目录 — 覆盖 serve 功能面板所需的数据
SYNCABLE_PARTS = [
"kline_daily",
"kline_daily_enriched",
"kline_index_daily",
"kline_index_enriched",
"kline_etf_daily",
"kline_etf_enriched",
"kline_etf_minute",
"kline_minute",
"adj_factor",
"adj_factor_etf",
"instruments",
"instruments_index",
"instruments_etf",
"financials",
"pools",
"ext_data",
]
def _part_size_info(data_dir: Path, part: str) -> dict:
"""获取指定子目录的文件数和大小。"""
part_dir = data_dir / part
if not part_dir.exists():
return {"file_count": 0, "size_bytes": 0, "last_modified": None}
files = list(part_dir.rglob("*.parquet"))
if not files:
return {"file_count": 0, "size_bytes": 0, "last_modified": None}
total = sum(f.stat().st_size for f in files)
mtimes = [f.stat().st_mtime for f in files]
last_ts = datetime.fromtimestamp(max(mtimes)).isoformat() if mtimes else None
return {"file_count": len(files), "size_bytes": total, "last_modified": last_ts}
def _pack_parts(data_dir: Path, parts: list[str]) -> bytes:
"""将指定子目录打包为 tar.gz 字节流。"""
buf = BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for part in parts:
part_dir = data_dir / part
if not part_dir.exists():
continue
for fpath in sorted(part_dir.rglob("*.parquet")):
rel = fpath.relative_to(data_dir)
tar.add(fpath, arcname=str(rel))
buf.seek(0)
return buf.getvalue()
class SyncConfig(BaseModel):
serve_url: str = ""
sync_key: str = ""
enable_auto: bool = False
interval_minutes: int = 0
# ── 存储配置用的 JSON 文件 ──────────────────────────────────
CONFIG_FILE = "sync_config.json"
def _config_path(data_dir: Path) -> Path:
return data_dir / "user_data" / CONFIG_FILE
def _load_config(data_dir: Path) -> SyncConfig:
cp = _config_path(data_dir)
if cp.exists():
try:
return SyncConfig(**json.loads(cp.read_text()))
except Exception:
pass
return SyncConfig()
def _save_config(data_dir: Path, cfg: SyncConfig) -> None:
cp = _config_path(data_dir)
cp.parent.mkdir(parents=True, exist_ok=True)
cp.write_text(cfg.model_dump_json(indent=2))
# ── 路由 ──────────────────────────────────────────────────
@router.get("/status")
def sync_status(request: Request):
"""各数据目录的本地状态 + 同步配置。"""
data_dir: Path = request.app.state.datastore.data_dir
cfg = _load_config(data_dir)
parts = {}
for p in SYNCABLE_PARTS:
parts[p] = _part_size_info(data_dir, p)
return {
"parts": parts,
"config": cfg.model_dump(),
"last_sync": cfg.model_dump().get("_last_sync_time"),
}
class ConfigReq(BaseModel):
serve_url: str
sync_key: str
enable_auto: bool = False
interval_minutes: int = 0
@router.post("/config")
def update_sync_config(request: Request, body: ConfigReq):
"""更新同步配置。"""
data_dir: Path = request.app.state.datastore.data_dir
cfg = SyncConfig(
serve_url=body.serve_url.rstrip("/"),
sync_key=body.sync_key,
enable_auto=body.enable_auto,
interval_minutes=body.interval_minutes,
)
_save_config(data_dir, cfg)
return {"ok": True}
class PushReq(BaseModel):
parts: list[str]
@router.post("/push")
async def push_sync(request: Request, body: PushReq):
"""打包指定 parts 推送到 serve 端。"""
data_dir: Path = request.app.state.datastore.data_dir
cfg = _load_config(data_dir)
if not cfg.serve_url:
raise HTTPException(status_code=400, detail="未配置 serve_url,请先在同步设置中配置")
requested = [p for p in body.parts if p in SYNCABLE_PARTS]
if not requested:
raise HTTPException(status_code=400, detail="未指定有效的同步目录")
# 检查有没有数据可同步
available = [p for p in requested if (data_dir / p).exists()]
if not available:
raise HTTPException(status_code=400, detail="所选目录均无数据可同步")
# 打包
tarball = _pack_parts(data_dir, available)
size_bytes = len(tarball)
logger.info("packed %d parts -> %d bytes", len(available), size_bytes)
# 推送
upload_url = f"{cfg.serve_url}/api/data/sync/upload"
headers = {"X-Sync-Key": cfg.sync_key} if cfg.sync_key else {}
try:
async with httpx.AsyncClient(timeout=600) as client:
resp = await client.post(
upload_url,
files={"file": ("sync.tar.gz", tarball, "application/gzip")},
data={"parts": ",".join(available)},
headers=headers,
)
if resp.status_code != 200:
detail = resp.text[:500]
raise HTTPException(
status_code=502,
detail=f"serve 返回异常 ({resp.status_code}): {detail}",
)
result = resp.json()
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"连接 serve 失败: {exc}") from exc
# 记录上次同步时间
cfg_dict = cfg.model_dump()
cfg_dict["_last_sync_time"] = datetime.now().isoformat()
cfg = SyncConfig(**cfg_dict)
_save_config(data_dir, cfg)
logger.info("sync pushed: parts=%s files=%d", available, result.get("file_count", 0))
return {
"ok": True,
"parts_sent": available,
"size_bytes": size_bytes,
"serve_response": result,
}
+4
View File
@@ -95,6 +95,10 @@ class Settings(BaseSettings):
log_level: str = "INFO"
backtest_range_guard: bool = False
# Sync — 数据同步到 serve
sync_serve_url: str = ""
sync_key: str = ""
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
auth_password: str = ""
+3 -2
View File
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
from app.api import analysis, auth as auth_api, backtest, data, data_sync, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
from app.api.routes import router as core_router
from app.config import settings
from app.jobs import daily_pipeline
@@ -207,7 +207,7 @@ app.add_middleware(
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
_AUTH_WHITELIST_PREFIX = ("/api/auth/",)
_AUTH_WHITELIST_PREFIX = ("/api/auth/", "/api/data/sync/")
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
@@ -257,6 +257,7 @@ app.include_router(overview.router)
app.include_router(analysis.router)
app.include_router(pipeline.router)
app.include_router(data.router)
app.include_router(data_sync.router)
app.include_router(ext_data.router)
app.include_router(financials.router)
app.include_router(stock_analysis.router)
+1 -1
View File
@@ -7,7 +7,7 @@ services:
dockerfile: Dockerfile
args:
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
container_name: TickFlow_Stock_Panel
container_name: TickFlow_Local
ports:
- "${PORT:-3018}:3018"
env_file:
+2
View File
@@ -44,6 +44,7 @@ import {
BookOpenCheck,
ExternalLink,
X,
Upload,
} from 'lucide-react'
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
@@ -78,6 +79,7 @@ const nav = [
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/trading', label: '交易', icon: Cable },
{ to: '/data', label: '数据', icon: Database },
{ to: '/sync', label: '数据同步', icon: Upload },
] as const
function fmtIndexValue(v: number | null | undefined) {
+44
View File
@@ -1752,6 +1752,14 @@ export const api = {
method: 'POST',
body: JSON.stringify({ strategy_id: strategyId, code }),
}),
// ===== Sync =====
syncStatus: (): Promise<SyncStatusResponse> =>
request('/api/data/sync/status'),
syncConfig: (body: SyncConfigReq): Promise<{ ok: boolean }> =>
request('/api/data/sync/config', { method: 'POST', body: JSON.stringify(body) }),
syncPush: (parts: string[]): Promise<SyncPushResponse> =>
request('/api/data/sync/push', { method: 'POST', body: JSON.stringify({ parts }) }),
}
// ===== Pipeline =====
@@ -1931,3 +1939,39 @@ export interface AnalysisMenu {
updated_at?: string | null
builtin?: boolean
}
// ===== Sync =====
export interface SyncPartInfo {
file_count: number
size_bytes: number
last_modified: string | null
}
export interface SyncStatusResponse {
parts: Record<string, SyncPartInfo>
config: {
serve_url: string
sync_key: string
enable_auto: boolean
interval_minutes: number
}
last_sync?: string | null
}
export interface SyncPushResponse {
ok: boolean
parts_sent: string[]
size_bytes: number
serve_response: {
ok: boolean
parts: string[]
file_count: number
}
}
export interface SyncConfigReq {
serve_url: string
sync_key: string
enable_auto?: boolean
interval_minutes?: number
}
+311
View File
@@ -0,0 +1,311 @@
import { useCallback, useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
CheckCircle2,
Cloud,
CloudOff,
Database,
HardDrive,
Loader2,
RefreshCw,
Save,
Settings,
Upload,
} from 'lucide-react'
import { toast } from '@/components/Toast'
import { PageHeader } from '@/components/PageHeader'
import { api, type SyncStatusResponse, type SyncPushResponse } from '@/lib/api'
import { cn } from '@/lib/cn'
type PartEntry = {
key: string
label: string
}
const ALL_PARTS: PartEntry[] = [
{ key: 'kline_daily', label: '日 K 线' },
{ key: 'kline_daily_enriched', label: '日 K 增强' },
{ key: 'kline_index_daily', label: '指数日 K' },
{ key: 'kline_index_enriched', label: '指数日 K 增强' },
{ key: 'kline_etf_daily', label: 'ETF 日 K' },
{ key: 'kline_etf_enriched', label: 'ETF 日 K 增强' },
{ key: 'kline_etf_minute', label: 'ETF 分钟 K' },
{ key: 'kline_minute', label: '分钟 K' },
{ key: 'adj_factor', label: '复权因子' },
{ key: 'adj_factor_etf', label: 'ETF 复权因子' },
{ key: 'instruments', label: '股票列表' },
{ key: 'instruments_index', label: '指数列表' },
{ key: 'instruments_etf', label: 'ETF 列表' },
{ key: 'financials', label: '财务数据' },
{ key: 'pools', label: '板块池' },
{ key: 'ext_data', label: '扩展数据(概念/行业)' },
]
function fmtSize(bytes: number): string {
if (bytes === 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function Sync() {
const qc = useQueryClient()
const [showSettings, setShowSettings] = useState(false)
const [serveUrl, setServeUrl] = useState('')
const [syncKey, setSyncKey] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set(ALL_PARTS.map(p => p.key)))
// Status
const { data: status, isLoading, refetch } = useQuery<SyncStatusResponse>({
queryKey: ['sync-status'],
queryFn: api.syncStatus,
refetchInterval: 30000,
})
// Init form from server config
useEffect(() => {
if (status?.config) {
setServeUrl(status.config.serve_url)
setSyncKey(status.config.sync_key)
}
}, [status?.config])
// Save config
const saveCfg = useMutation<{ ok: boolean }, Error, void>({
mutationFn: () => api.syncConfig({ serve_url: serveUrl, sync_key: syncKey }),
onSuccess: () => {
toast('同步配置已保存', 'success')
qc.invalidateQueries({ queryKey: ['sync-status'] })
setShowSettings(false)
},
})
// Push sync
const push = useMutation<SyncPushResponse, Error, void>({
mutationFn: () => api.syncPush([...selected]),
onSuccess: (res) => {
if (res.ok) {
toast(
`同步完成: ${res.parts_sent.length} 个目录, ${res.serve_response.file_count} 个文件`,
'success',
)
qc.invalidateQueries({ queryKey: ['sync-status'] })
}
},
})
const togglePart = useCallback((key: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}, [])
const selectAll = () => setSelected(new Set(ALL_PARTS.map(p => p.key)))
const selectNone = () => setSelected(new Set())
return (
<div className="mx-auto max-w-5xl space-y-6 px-4 py-6">
<PageHeader
title="数据同步"
subtitle="将本地数据推送到 serve 服务器"
/>
{/* 连接配置 */}
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{status?.config.serve_url ? (
<Cloud className="h-5 w-5 text-accent" />
) : (
<CloudOff className="h-5 w-5 text-muted" />
)}
<div>
<div className="text-sm font-medium text-foreground">Serve </div>
<div className="text-xs text-muted">
{status?.config.serve_url
? status.config.serve_url
: '未配置 — 点击右侧设置按钮'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{status?.last_sync && (
<span className="text-[10px] text-muted">
: {new Date(status.last_sync).toLocaleString('zh-CN')}
</span>
)}
<button
onClick={() => { setShowSettings(v => !v); refetch() }}
className="rounded-btn p-2 text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="同步设置"
>
<Settings className="h-4 w-4" />
</button>
<button
onClick={() => refetch()}
className="rounded-btn p-2 text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="刷新状态"
>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
</button>
</div>
</div>
{showSettings && (
<div className="mt-4 space-y-3 border-t border-border pt-4">
<div>
<label className="text-xs text-muted">Serve </label>
<input
value={serveUrl}
onChange={e => setServeUrl(e.target.value)}
placeholder="http://your-server:3018"
className="mt-1 w-full rounded border border-border bg-base px-3 py-2 text-sm text-foreground placeholder:text-muted focus:border-accent focus:outline-none"
/>
</div>
<div>
<label className="text-xs text-muted"> Key</label>
<input
value={syncKey}
onChange={e => setSyncKey(e.target.value)}
placeholder="留空则不校验"
type="password"
className="mt-1 w-full rounded border border-border bg-base px-3 py-2 text-sm text-foreground placeholder:text-muted focus:border-accent focus:outline-none"
/>
</div>
<button
onClick={() => saveCfg.mutate()}
disabled={saveCfg.isPending}
className="inline-flex items-center gap-2 rounded-btn bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
>
{saveCfg.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</button>
</div>
)}
</section>
{/* 数据选择 */}
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground"></h3>
<div className="flex items-center gap-2">
<button
onClick={selectAll}
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
</button>
<span className="text-xs text-muted">/</span>
<button
onClick={selectNone}
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
</button>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{ALL_PARTS.map(({ key, label }) => {
const info = status?.parts?.[key]
const hasData = info && info.file_count > 0
return (
<label
key={key}
className={cn(
'flex items-center gap-3 rounded-lg border px-3 py-2.5 cursor-pointer transition-colors',
selected.has(key)
? 'border-accent/40 bg-accent/5'
: 'border-border bg-base hover:border-muted',
)}
>
<input
type="checkbox"
checked={selected.has(key)}
onChange={() => togglePart(key)}
className="h-4 w-4 rounded border-border text-accent focus:ring-accent"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm text-foreground">{label}</span>
{hasData && (
<HardDrive className="h-3 w-3 text-accent shrink-0" />
)}
</div>
{info && (
<div className="mt-0.5 text-[10px] text-muted">
{info.file_count > 0
? `${info.file_count} 文件 · ${fmtSize(info.size_bytes)}`
: '无数据'}
</div>
)}
</div>
</label>
)
})}
</div>
</section>
{/* 操作 */}
<section className="flex items-center justify-between rounded-card border border-border bg-surface p-5">
<div className="flex items-center gap-2 text-sm text-muted">
<Database className="h-4 w-4" />
<span>
{selected.size}
{push.data && (
<span className="ml-2 text-bull">
<CheckCircle2 className="inline h-3.5 w-3.5 mr-0.5" />
</span>
)}
</span>
</div>
<button
onClick={() => push.mutate()}
disabled={push.isPending || selected.size === 0}
className="inline-flex items-center gap-2 rounded-btn bg-accent px-5 py-2.5 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
>
{push.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
<>
<Upload className="h-4 w-4" />
</>
)}
</button>
</section>
{/* 同步日志 */}
{push.data && (
<section className="rounded-card border border-border bg-surface p-4">
<div className="text-xs text-muted font-mono leading-relaxed">
<div> </div>
<div> : {push.data.parts_sent.join(', ')}</div>
<div> : {fmtSize(push.data.size_bytes)}</div>
<div> : {push.data.serve_response.file_count}</div>
</div>
</section>
)}
{push.error && (
<section className="rounded-card border border-danger/30 bg-danger/5 p-4">
<div className="text-xs text-danger font-mono leading-relaxed">
<div> </div>
<div> {String(push.error)}</div>
</div>
</section>
)}
</div>
)
}
+2
View File
@@ -20,6 +20,7 @@ import { Branding } from './pages/Branding'
import { Settings } from './pages/Settings'
import { Indices } from './pages/Indices'
import { Dev } from './pages/Dev'
import { Sync } from './pages/Sync'
import { useSettings } from './lib/useSharedQueries'
import { Logo } from './components/Logo'
@@ -77,6 +78,7 @@ export const router = createBrowserRouter([
{ path: 'data', element: <Data /> },
{ path: 'monitor', element: <Monitor /> },
{ path: 'trading', element: <Trading /> },
{ path: 'sync', element: <Sync /> },
{ path: 'limit-ladder', element: <LimitUpLadder /> },
{ path: 'indices', element: <Indices /> },
{ path: 'branding', element: <Branding /> },
+3
View File
@@ -8,6 +8,9 @@ frontend/dist
dist
build
# Project data
data
# Git
.git
.gitignore
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 tickflow-stock-panel contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+132 -244
View File
@@ -1,94 +1,44 @@
<div align="center">
# 📈 A股智能量化工作台
# 📈 A股智能量化工作台(服务端备份/查看版)
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
**本地完整版的云端伴侣:数据备份 + 基础数据查看**
**面向个人散户与量化爱好者而生**
**面向已部署 `local/` 完整版的用户,提供远程备份与轻量查看能力**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/)
[![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/)
[![Data: TickFlow](https://img.shields.io/badge/Data-TickFlow-00b386.svg)](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
[![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile)
[![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](https://github.com/shy3130/tickflow-stock-panel/stargazers)
</div>
<div align="center">
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[路线图](#-路线图)**
**[快速开始](#-快速开始)** · **[功能范围](#-功能范围)** · **[接收本地同步](#-接收本地同步)** · **[部署说明](#-部署说明)**
</div>
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
- 🇨🇳 **A 股专用** — 盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
觉得有用可以点个 Star,蟹蟹 🌹
---
## 🎯 项目定位
**面向个人散户与量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具。
`serve/` 目录下的版本是 **服务端轻量版**,它不是 `local/` 的替代品,而是其**配套服务端**:
- **数据备份**:接收本地完整版推送的核心数据,防止本地磁盘故障导致历史数据丢失
- **远程查看**:在手机、平板或其他设备上通过浏览器查看基础行情、选股结果、复盘报告等
- **多用户隔离**:支持 admin / viewer 角色,适合家庭或小团队共享查看
**它不是满血版**,不包含实时行情、回测、实时监控、五档盘口、AI 策略生成等需要本地运行或消耗大量计算/Quota 的能力。所有复杂分析和决策功能,请在 `local/` 本地完整版中使用。
---
## 📸 界面预览
<table>
<tr>
<td width="50%" align="center"><b>看板 Dashboard</b></td>
<td width="50%" align="center"><b>策略 Screener</b></td>
</tr>
<tr>
<td width="50%"><img src="./screenshots/dashboard.png" alt="看板页面"></td>
<td width="50%"><img src="./screenshots/screener.png" alt="策略页"></td>
</tr>
<tr>
<td width="50%" align="center"><b>回测 Backtest</b></td>
<td width="50%" align="center"><b>监控中心 Monitor</b></td>
</tr>
<tr>
<td width="50%"><img src="./screenshots/backtest.png" alt="回测页"></td>
<td width="50%"><img src="./screenshots/monitor.png" alt="监控中心"></td>
</tr>
<tr>
<td width="50%" align="center"><b>连板梯队 Limit Ladder</b></td>
<td width="50%" align="center"><b>概念分析 Concept</b></td>
</tr>
<tr>
<td width="50%"><img src="./screenshots/limit-ladder.png" alt="连板梯队页"></td>
<td width="50%"><img src="./screenshots/concept-analysis.png" alt="概念分析"></td>
</tr>
</table>
<div align="center">
### 📸 [查看更多界面截图 »](./screenshots/README.md)
</div>
---
## 🚀 快速开始
## ⚡ 快速开始
### 前置依赖
`local/` 相同:
| 工具 | 版本 | 安装 |
| :--------------------------------- | :----- | :------------------------------------------------- |
| Python | ≥ 3.11 | [python.org](https://www.python.org/) |
@@ -96,19 +46,19 @@
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| `pnpm` | 9 | `npm i -g pnpm` |
### 方式 A:Dev 模式(二次开发推荐)
### 方式 ADev 模式
```bash
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
cp .env.example .env # 配置监听端口、同步密钥等
./dev.sh # Windows: .\dev.ps1
```
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
默认
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
- 自定义端口`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
### 方式 B:Docker(部署最省心)
### 方式 BDocker(推荐)
```bash
cp .env.example .env
@@ -116,213 +66,151 @@ docker compose up --build
# 打开 http://localhost:3018
```
<details>
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
容器名:`stock_panel`。默认端口 `3018`
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`
**手动分别启动:**
```bash
# 后端
cd backend && uv sync --extra backtest # 含回测依赖
uv run uvicorn app.main:app --reload --port 3018
# 前端
cd frontend && pnpm install && pnpm dev # http://localhost:3011
```
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
</details>
### 🔄 更新代码(已部署用户必读)
拉取新版本只需一条命令:
```bash
git pull
```
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
> - `git reset --hard`
> - 直接删除整个项目文件夹重新 `git clone`
>
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
### 🧭 跑起来后的第一次使用
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
2. **设置****立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
> 与 `local/` 不同,`serve/` 的 `data/` 目录**默认被 `.dockerignore` 忽略**,容器重启不会保留容器内的数据。请通过本地 `local/` 主动同步,或将 `data/` 挂载到持久化卷
---
## ✨ 核心功能
## 📦 功能范围
### 🔍 选股引擎(Screener)
| 功能 | serve/ 服务端 | 说明 |
| :--- | :---: | :--- |
| 看板 Dashboard | ✅ | 展示已同步的基础市场概况 |
| 选股结果查看 | ✅ | 查看 local/ 推送的选股结果 |
| 概念 / 行业分析 | ✅ | 查看已同步的概念/行业数据 |
| 复盘报告 | ✅ | 查看 local/ 生成的盘后 AI 复盘 |
| 指数 / 个股基础 K 线 | ✅ | 查看已同步的历史 K 线 |
| 多用户认证(admin/viewer) | ✅ | 支持多用户、角色隔离 |
| 数据同步接收 | ✅ | 接收 local/ 推送的数据 |
| 回测 | ❌ | 请在 local/ 中完成 |
| 实时监控 / SSE 告警 | ❌ | 请在 local/ 中使用 |
| 五档盘口 / 实时行情 | ❌ | 请在 local/ 中使用 |
| 自选实时监控 | ❌ | 请在 local/ 中使用 |
| AI 策略生成 | ❌ | 请在 local/ 中使用 |
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
### 适用场景
| 类型 | 代表策略 |
| :---------- | :------------------------------------------------------- |
| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 |
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
- **异地灾备**:local/ 每天收盘后自动把 enriched 数据、选股结果、复盘报告推送到 serve/
- **移动查看**:出差时用手机浏览器访问 serve/,查看本地已分析好的结果
- **家庭共享**:家人用 viewer 账号查看,admin 账号管理同步密钥
**扩展策略的三种方式:**
---
| 方式 | 说明 |
| :---------------- | :---------------------------------------------------------------------------------------------------- |
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
## 🔄 接收本地同步
### 📊 指标流水线(Indicators)
### 1. 服务端配置
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
`serve/.env` 中设置同步密钥(与 local/ 保持一致):
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**:基于除权因子自动前复权,回测与指标口径一致
```ini
SYNC_KEY=your-shared-secret
```
### 🧪 回测引擎(Backtest)
首次启动时建议通过 `AUTH_PASSWORD` 预置管理员密码:
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
```ini
AUTH_PASSWORD=你的密码
```
### 📡 监控中心(Monitor)
### 2. 本地端配置
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
`local/.env` 中填写服务端地址和密钥:
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
```ini
SYNC_SERVE_URL=https://your-serve.example.com
SYNC_KEY=your-shared-secret
```
### 📈 个股分析(Beta)
### 3. 触发同步
以「行情 + 关键价位」为主体的单标的决策页:
在 local/ 的「设置 → 数据同步」页面手动触发,或等待盘后定时任务自动推送。
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
### 4. 安全建议
### 🧰 数据与扩展
- 务必通过 HTTPS 暴露 serve/
- `SYNC_KEY` 应使用强随机字符串,并定期更换
- 不要对外开放 22 / 数据库等无关端口
- 若暴露在公网,务必先设置 `AUTH_PASSWORD`
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
---
## 🏗️ 部署说明
### 最小资源
| 资源 | 建议值 |
| :--- | :--- |
| CPU | 1 核 |
| 内存 | 512 MB |
| 磁盘 | 根据数据量,建议 ≥ 10 GB |
### 持久化
serve/ 的数据主要来自 local/ 同步。如果你希望容器重启后保留数据,请在 `docker-compose.yml` 中挂载持久卷:
```yaml
volumes:
- /your/host/data:/app/data
```
### 反向代理示例(Nginx
```nginx
server {
listen 443 ssl;
server_name your-serve.example.com;
location / {
proxy_pass http://127.0.0.1:3018;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
---
## ⚙️ 配置
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
### 数据源:TickFlow
```ini
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
# 同步密钥(必须和 local/.env 里的 SYNC_KEY 一致)
SYNC_KEY=your-shared-secret
# 服务端口
HOST=0.0.0.0
PORT=3018
# 首次管理员密码(仅首次启动生效,写入 auth.json 后不再读取)
AUTH_PASSWORD=你的密码
# 日志级别
LOG_LEVEL=INFO
# 数据目录(Docker 中建议挂载到宿主机)
DATA_DIR=./data
```
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
完整配置项参考 `serve/.env.example`
| 档位 | 实时能力 |
| :------- | :--------------------------------------- |
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
| Starter+ | 全市场实时行情 |
| Pro | 分钟 K + 盘口 |
| Expert | WebSocket + 财务数据 |
---
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
## 🗺️ 与 local/ 的关系
### AI(可选)
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
```ini
AI_PROVIDER=openai_compat # openai_compat | ollama
AI_BASE_URL=https://api.deepseek.com/v1
AI_API_KEY= # 留空 = 关闭 AI
AI_MODEL=deepseek-chat
AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
```
┌─────────────────────────────────────┐
│ local/ 本地完整版 │
│ 选股 · 回测 · 实时监控 · AI 分析 │
│ ↓ 定时/手动同步 │
└─────────────────────────────────────┘
▼ HTTPS + SYNC_KEY
┌─────────────────────────────────────┐
│ serve/ 服务端轻量版 │
│ 数据备份 · 基础查看 · 多用户 │
└─────────────────────────────────────┘
```
### 服务与数据
```ini
HOST=0.0.0.0 # 监听地址
PORT=3018 # 服务端口
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
DATA_DIR=./data # Parquet / DuckDB 数据存储目录
```
### 访问密码
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取):
```ini
AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖
```
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)。
---
## 🏗️ 技术栈
| 层 | 选型 |
| :----------- | :------------------------------------------------------------------------------------------------ |
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
| **回测** | vectorbt(全项目唯一 pandas 边界) |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
---
## 🗺️ 路线图
| Phase | 内容 | 状态 |
| :----- | :----------------------------------------------------------------- | :--- |
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
---
## 📚 文档与贡献
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
- [docs/](./docs) —— 策略构建步骤、示例
欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
---
## ⚠️ 免责声明
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
## 📄 License
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
## 社区
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
- **local/**:功能核心,承担所有计算、实时行情、回测、监控
- **serve/**:数据落地与轻量展示,不承担计算,不直接调用 TickFlow 高配额接口
+1 -1
View File
@@ -1,4 +1,4 @@
"""TickFlow Stock Panel backend."""
"""A股工作台 backend."""
import sys
-20
View File
@@ -120,25 +120,5 @@ def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True):
})
alert_store.append_many(_data_dir(request), events)
# 同步推入 SSE 队列, 让所有连着 SSE 的客户端实时收到 (不依赖轮询)
qs = getattr(request.app.state, "quote_service", None)
if qs:
# 转成 SSE 推送格式 (和 _evaluate_monitors 一致)
sse_alerts = [{
"source": ev["source"],
"type": ev["type"],
"rule_id": ev.get("rule_id"),
"symbol": ev["symbol"],
"name": ev["name"],
"message": ev["message"],
"price": ev["price"],
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
} for ev in events]
with qs._lock:
qs._pending_alerts.extend(sse_alerts)
qs._alert_event.set()
return {"ok": True, "generated": len(events)}
+106 -28
View File
@@ -1,11 +1,15 @@
"""访问认证 API。
端点:
GET /api/auth/status — 是否已设密码、当前会话是否有效
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
POST /api/auth/logout — 注销当前会话
POST /api/auth/change-password — 改密码(需已登录)
GET /api/auth/status — 认证状态 + 当前用户信息
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
POST /api/auth/login — 登录(用户名+密码 → 会话 token, 含限流)
POST /api/auth/logout — 注销当前会话
POST /api/auth/change-password — 改密码(需已登录)
GET /api/auth/users — 用户列表(需 admin)
POST /api/auth/users — 创建用户(需 admin)
DELETE /api/auth/users/{username} — 删除用户(需 admin)
POST /api/auth/users/{username}/reset-password — 重置密码(需 admin)
安全:
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
@@ -101,6 +105,25 @@ def _clear_login_fails(ip: str) -> None:
_fail_counter.pop(ip, None)
def _get_current_user(request: Request) -> str:
"""从 cookie 获取当前登录的 username, 未登录抛 401。"""
token = request.cookies.get(COOKIE_NAME)
username = auth.get_session_user(token or "")
if not username:
raise HTTPException(status_code=401, detail="请先登录")
return username
def _require_admin(request: Request) -> str:
"""要求当前用户为 admin, 否则抛 403。"""
username = _get_current_user(request)
token = request.cookies.get(COOKIE_NAME, "")
role = auth.get_session_role(token)
if role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return username
# ================================================================
# 端点
# ================================================================
@@ -110,6 +133,7 @@ class PasswordIn(BaseModel):
class LoginIn(BaseModel):
username: str = Field(default="admin", min_length=1, max_length=64, description="用户名, 默认 admin")
password: str = Field(min_length=1, max_length=128)
@@ -118,13 +142,27 @@ class ChangePasswordIn(BaseModel):
new_password: str = Field(min_length=6, max_length=128)
class CreateUserIn(BaseModel):
username: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
password: str = Field(min_length=6, max_length=128)
role: str = Field(default="viewer", pattern=r"^(admin|viewer)$")
class ResetPasswordIn(BaseModel):
new_password: str = Field(min_length=6, max_length=128)
@router.get("/status")
def auth_status(request: Request) -> dict:
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
token = request.cookies.get(COOKIE_NAME)
"""认证状态: 是否已设密码 + 当前请求是否已登录 + 用户信息"""
token = request.cookies.get(COOKIE_NAME) or ""
username = auth.get_session_user(token)
role = auth.get_session_role(token)
return {
"configured": auth.is_configured(),
"authenticated": bool(token and auth.is_valid_session(token)),
"authenticated": bool(username),
"username": username or None,
"role": role or None,
}
@@ -153,18 +191,19 @@ def setup_password(req: PasswordIn, request: Request) -> dict:
@router.post("/login")
def login(req: LoginIn, request: Request, response: Response) -> dict:
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
"""登录: 用户名+密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
ip = _client_ip(request)
_check_login_rate_limit(ip)
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
token = auth.verify_and_create_session(req.password)
if not token:
result = auth.login(req.username, req.password)
if not result:
_record_login_fail(ip)
raise HTTPException(status_code=401, detail="密码错误")
raise HTTPException(status_code=401, detail="用户名或密码错误")
token, username = result
_clear_login_fails(ip)
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
response.set_cookie(
@@ -176,7 +215,7 @@ def login(req: LoginIn, request: Request, response: Response) -> dict:
path="/",
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
)
return {"ok": True, "authenticated": True}
return {"ok": True, "authenticated": True, "username": username}
@router.post("/logout")
@@ -191,23 +230,62 @@ def logout(request: Request, response: Response) -> dict:
@router.post("/change-password")
def change_password(req: ChangePasswordIn, request: Request) -> dict:
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)"""
"""修改密码: 需验证旧密码。"""
token = request.cookies.get(COOKIE_NAME)
if not (token and auth.is_valid_session(token)):
username = auth.get_session_user(token or "")
if not username:
raise HTTPException(status_code=401, detail="请先登录")
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
# 验证旧密码
new_token = auth.verify_and_create_session(req.old_password)
if not new_token:
ip = _client_ip(request)
_record_login_fail(ip)
if not auth.change_password(username, req.old_password, req.new_password):
raise HTTPException(status_code=401, detail="旧密码错误")
# 临时 token 用完即弃
auth.revoke_session(new_token)
# 改密码(set_password 会清空所有会话)
auth.set_password(req.new_password)
return {"ok": True, "message": "密码已修改, 请重新登录"}
return {"ok": True, "message": "密码已修改"}
# ================================================================
# 用户管理 (admin only)
# ================================================================
@router.get("/users")
def list_users(request: Request) -> dict:
"""用户列表(需 admin)。"""
_require_admin(request)
users = auth.list_users()
return {"users": users}
@router.post("/users")
def create_user(req: CreateUserIn, request: Request) -> dict:
"""创建用户(需 admin)。"""
_require_admin(request)
try:
auth.create_user(req.username, req.password, role=req.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True, "username": req.username}
@router.delete("/users/{username}")
def delete_user(username: str, request: Request) -> dict:
"""删除用户(需 admin, 不能删除 admin 自身)。"""
_require_admin(request)
try:
ok = auth.delete_user(username)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if not ok:
raise HTTPException(status_code=404, detail=f"用户 '{username}' 不存在")
return {"ok": True}
@router.post("/users/{username}/reset-password")
def reset_password(username: str, req: ResetPasswordIn, request: Request) -> dict:
"""重置用户密码(需 admin)。"""
_require_admin(request)
try:
ok = auth.admin_reset_password(username, req.new_password)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if not ok:
raise HTTPException(status_code=404, detail=f"用户 '{username}' 不存在")
return {"ok": True}
-474
View File
@@ -1,474 +0,0 @@
"""回测 API — 信号回测 + 因子回测 + 策略回测。"""
from __future__ import annotations
import asyncio
import json
import queue
import threading
from dataclasses import asdict
from datetime import date, timedelta
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.config import settings
from app.services.backtest import (
BacktestConfig,
BacktestService,
VectorbtUnavailable,
is_available,
)
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
FACTOR_DEFAULT_DAYS = 180
STRATEGY_DEFAULT_DAYS = 365 * 3
BACKTEST_MAX_SERVER_DAYS = 186
FACTOR_MAX_SYMBOLS = 1000
BACKTEST_SERVER_GUARD_MESSAGE = (
"当前服务器内存约 1.8GB,回测区间最多支持 6 个月;"
"更长周期容易触发 OOM,建议在 8GB 以上内存环境或本机运行。"
)
def _get_engine(request: Request):
"""获取或创建 BacktestEngine (单例,PanelCache 跨请求生效)。"""
from app.backtest.engine import BacktestEngine
engine = getattr(request.app.state, "backtest_engine", None)
if engine is None:
engine = BacktestEngine(request.app.state.repo)
request.app.state.backtest_engine = engine
return engine
def _resolve_start(req: BaseModel, end: date, default_days: int) -> date:
"""未传 start 使用默认区间;显式传 null/空值表示全部历史。"""
start = getattr(req, "start")
if start is not None:
return start
if "start" in req.model_fields_set:
return date(1900, 1, 1)
return end - timedelta(days=default_days)
def _guard_server_backtest_range(start: date, end: date):
if not settings.backtest_range_guard:
return
days = (end - start).days + 1
if days > BACKTEST_MAX_SERVER_DAYS:
raise HTTPException(status_code=400, detail=BACKTEST_SERVER_GUARD_MESSAGE)
# ================================================================
# 状态
# ================================================================
@router.get("/status")
def status():
"""前端可用此接口判断回测页是否要灰显。"""
return {"available": True}
# ================================================================
# 信号回测 (现有接口,保持不变)
# ================================================================
class BacktestRequest(BaseModel):
symbols: list[str] = Field(..., min_length=1)
start: date | None = None
end: date | None = None
entries: list[str] = []
exits: list[str] = []
stop_loss_pct: float | None = None
max_hold_days: int | None = None
fees_pct: float = 0.0002
slippage_bps: float = 5
matching: Literal["close_t", "open_t+1"] = "close_t"
@router.post("/run")
def run(req: BacktestRequest, request: Request):
"""信号回测 — 现有接口,向后兼容。"""
repo = request.app.state.repo
svc = BacktestService(repo)
end = req.end or date.today()
start = req.start or (end - timedelta(days=365 * 3))
cfg = BacktestConfig(
symbols=req.symbols,
start=start,
end=end,
entries=req.entries,
exits=req.exits,
stop_loss_pct=req.stop_loss_pct,
max_hold_days=req.max_hold_days,
fees_pct=req.fees_pct,
slippage_bps=req.slippage_bps,
matching=req.matching,
)
try:
result = svc.run(cfg)
except VectorbtUnavailable as e:
raise HTTPException(status_code=503, detail=str(e)) from e
return asdict(result)
# ================================================================
# 因子回测
# ================================================================
class FactorColumnsResponse(BaseModel):
columns: list[dict]
@router.get("/factor/columns")
def factor_columns():
"""返回可用的因子列列表。"""
from app.backtest.factor import FACTOR_COLUMNS
return {"columns": FACTOR_COLUMNS}
class FactorBacktestRequest(BaseModel):
factor_name: str
symbols: list[str] | None = None
start: date | None = None
end: date | None = None
n_groups: int = 5
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
weight: Literal["equal", "factor_weight"] = "equal"
fees_pct: float = 0.0002
slippage_bps: float = 5.0
@router.post("/factor/run")
def factor_run(req: FactorBacktestRequest, request: Request):
"""因子回测 — IC/IR 分析 + 分层回测。"""
from app.backtest.factor import FactorBacktestService, FactorConfig
engine = _get_engine(request)
svc = FactorBacktestService(engine)
end = req.end or date.today()
start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS)
_guard_server_backtest_range(start, end)
symbols = req.symbols if req.symbols else None
if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS:
raise HTTPException(
status_code=400,
detail=f"指定标的最多支持 {FACTOR_MAX_SYMBOLS} 只,请缩小标的范围。",
)
cfg = FactorConfig(
factor_name=req.factor_name,
symbols=symbols,
start=start,
end=end,
n_groups=req.n_groups,
rebalance=req.rebalance,
weight=req.weight,
fees_pct=req.fees_pct,
slippage_bps=req.slippage_bps,
)
result = svc.run(cfg)
return asdict(result)
# ================================================================
# 策略回测
# ================================================================
class StrategyBacktestRequest(BaseModel):
strategy_id: str
symbols: list[str] | None = None
start: date | None = None
end: date | None = None
params: dict | None = None
overrides: dict | None = None
# matching 向后兼容; 显式传 entry_fill/exit_fill 时以二者为准。
matching: Literal["close_t", "open_t+1"] = "open_t+1"
entry_fill: Literal["close_t", "open_t+1"] | None = None
exit_fill: Literal["close_t", "open_t+1"] | None = None
fees_pct: float = 0.0002
slippage_bps: float = 5.0
max_positions: int = 10
max_exposure_pct: float = 1.0
initial_capital: float = 1_000_000.0
position_sizing: Literal["equal", "score_weight"] = "equal"
mode: Literal["position", "full"] = "position"
holding_days: int = 5
@router.post("/strategy/run")
def strategy_run(req: StrategyBacktestRequest, request: Request):
"""策略回测 — 复用 StrategyDef 体系做全周期回测。"""
from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
engine = _get_engine(request)
strategy_engine = request.app.state.strategy_engine
svc = StrategyBacktestService(engine, strategy_engine)
end = req.end or date.today()
start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS)
_guard_server_backtest_range(start, end)
cfg = StrategyBacktestConfig(
strategy_id=req.strategy_id,
symbols=req.symbols if req.symbols else None,
start=start,
end=end,
params=req.params,
overrides=req.overrides,
matching=req.matching,
entry_fill=req.entry_fill,
exit_fill=req.exit_fill,
fees_pct=req.fees_pct,
slippage_bps=req.slippage_bps,
max_positions=req.max_positions,
max_exposure_pct=req.max_exposure_pct,
initial_capital=req.initial_capital,
position_sizing=req.position_sizing,
mode=req.mode,
holding_days=req.holding_days,
)
result = svc.run(cfg)
return asdict(result)
# ── SSE 流式回测 (实时进度 + 可取消 + 支持重连) ───────────────────
import time
import hashlib
class _BacktestJob:
"""单个回测任务的状态, 存模块级供重连使用。"""
__slots__ = ("key", "cancel_event", "progress", "result", "error", "done", "finish_ts")
def __init__(self, key: str):
self.key = key
self.cancel_event = threading.Event()
self.progress: list[dict] = [] # 进度历史 (新连接可回放)
self.result = None # 完成后的结果
self.error: str | None = None
self.done = False
self.finish_ts: float = 0.0
# 模块级任务表: key -> _BacktestJob
_running_jobs: dict[str, _BacktestJob] = {}
_jobs_lock = threading.Lock()
_JOB_TTL = 300 # 完成后保留 5 分钟
def _cleanup_stale_jobs():
"""清理过期任务 (完成超过 TTL 的)。"""
now = time.time()
stale = [k for k, j in _running_jobs.items() if j.done and now - j.finish_ts > _JOB_TTL]
for k in stale:
_running_jobs.pop(k, None)
def _make_job_key(
strategy_id: str, symbols: str | None, start: str | None, end: str | None,
matching: str, entry_fill: str | None, exit_fill: str | None,
fees_pct: float, slippage_bps: float,
max_positions: int, max_exposure_pct: float, initial_capital: float, position_sizing: str,
params: str | None, overrides: str | None,
mode: str = "position", holding_days: int = 5,
) -> str:
raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}"
return hashlib.md5(raw.encode()).hexdigest()[:12]
@router.get("/strategy/stream")
async def strategy_stream(
request: Request,
strategy_id: str,
symbols: str | None = None,
start: str | None = None,
end: str | None = None,
matching: str = "open_t+1",
entry_fill: str | None = None,
exit_fill: str | None = None,
fees_pct: float = 0.0002,
slippage_bps: float = 5.0,
max_positions: int = 10,
max_exposure_pct: float = 1.0,
initial_capital: float = 1_000_000.0,
position_sizing: str = "equal",
params: str | None = None,
overrides: str | None = None,
mode: str = "position",
holding_days: int = 5,
):
"""SSE 流式策略回测: 实时推送进度, 完成后推送结果, 支持重连 (刷新/切页后恢复)。
- 相同参数的任务只启动一次, 多次连接订阅同一个任务
- 断开连接不会取消任务 (除非显式调用 cancel)
- 结果保留 5 分钟供重连
事件类型:
- progress: {day, total, date, equity}
- done: {result} (完整回测结果)
- error: {message}
"""
from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
engine = _get_engine(request)
strategy_engine = request.app.state.strategy_engine
svc = StrategyBacktestService(engine, strategy_engine)
end_date = date.fromisoformat(end) if end else date.today()
if start:
start_date = date.fromisoformat(start)
else:
# 空 start = 全部历史: 用本地最早日K日期, 查不到再回退到默认窗口
earliest = request.app.state.repo.earliest_daily_date()
start_date = earliest or (end_date - timedelta(days=FACTOR_DEFAULT_DAYS))
# 服务端范围保护
guard_violated = False
if settings.backtest_range_guard:
days = (end_date - start_date).days + 1
if days > BACKTEST_MAX_SERVER_DAYS:
guard_violated = True
job_key = _make_job_key(
strategy_id, symbols, start, end,
matching, entry_fill, exit_fill,
fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing,
params, overrides,
mode, holding_days,
)
_cleanup_stale_jobs()
# 获取或创建任务
with _jobs_lock:
job = _running_jobs.get(job_key)
if job is None:
job = _BacktestJob(job_key)
_running_jobs[job_key] = job
is_new = True
else:
is_new = False
async def event_generator():
# 范围保护: 直接报错
if guard_violated:
yield f"event: error\ndata: {json.dumps({'message': BACKTEST_SERVER_GUARD_MESSAGE}, ensure_ascii=False)}\n\n"
return
# 如果是新任务, 启动回测线程
if is_new and not job.done:
cfg = StrategyBacktestConfig(
strategy_id=strategy_id,
symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None,
start=start_date,
end=end_date,
params=json.loads(params) if params else None,
overrides=json.loads(overrides) if overrides else None,
matching=matching,
entry_fill=entry_fill,
exit_fill=exit_fill,
fees_pct=fees_pct,
slippage_bps=slippage_bps,
max_positions=int(max_positions),
max_exposure_pct=float(max_exposure_pct),
initial_capital=float(initial_capital),
position_sizing=position_sizing,
mode=mode,
holding_days=int(holding_days),
)
def _run_backtest():
try:
result = svc.run(cfg, lambda d: job.progress.append(d), job.cancel_event)
job.result = result
job.done = True
job.finish_ts = time.time()
except Exception as e:
job.error = str(e)
job.done = True
job.finish_ts = time.time()
# 启动后台线程 (不阻塞事件循环)
threading.Thread(target=_run_backtest, daemon=True).start()
# 订阅进度: 用读指针读 job.progress 列表 (多连接互不干扰)
cursor = 0
tick = 0
try:
while True:
# 已完成: 推送最终结果/错误并退出
if job.done:
if job.error:
yield f"event: error\ndata: {json.dumps({'message': job.error}, ensure_ascii=False)}\n\n"
elif job.result is not None:
r = job.result
if hasattr(r, "error") and r.error == "cancelled":
yield f"event: error\ndata: {json.dumps({'message': '回测已取消'}, ensure_ascii=False)}\n\n"
elif hasattr(r, "error") and r.error:
yield f"event: error\ndata: {json.dumps({'message': r.error}, ensure_ascii=False)}\n\n"
else:
yield f"event: done\ndata: {json.dumps(asdict(r), ensure_ascii=False, default=str)}\n\n"
return
# 断开检测: 每 4 轮检查一次 (降低 GIL 抢占频率)
tick += 1
if tick % 4 == 0 and await request.is_disconnected():
break
# 推送新进度 (从 cursor 开始读)
prog_list = job.progress
while cursor < len(prog_list):
msg = prog_list[cursor]
cursor += 1
yield f"event: progress\ndata: {json.dumps(msg, ensure_ascii=False, default=str)}\n\n"
await asyncio.sleep(0.5)
except asyncio.CancelledError:
raise
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.post("/strategy/cancel")
async def strategy_cancel(request: Request):
"""取消正在运行的回测任务 (前端传 query string, 后端算 job_key)。"""
body = await request.json()
qs = body.get("qs", "")
# 解析 qs 得到参数
from urllib.parse import parse_qs
p = parse_qs(qs)
def _get(key: str, default: str = "") -> str:
return p.get(key, [default])[0]
job_key = _make_job_key(
_get("strategy_id"),
_get("symbols") or None,
_get("start") or None,
_get("end") or None,
_get("matching", "open_t+1"),
_get("entry_fill") or None,
_get("exit_fill") or None,
float(_get("fees_pct", "0.0002")),
float(_get("slippage_bps", "5")),
int(_get("max_positions", "10")),
float(_get("max_exposure_pct", "1")),
float(_get("initial_capital", "1000000")),
_get("position_sizing", "equal"),
_get("params") or None,
_get("overrides") or None,
_get("mode", "position"),
int(_get("holding_days", "5")),
)
job = _running_jobs.get(job_key)
if job and not job.done:
job.cancel_event.set()
return {"ok": True}
return {"ok": False, "message": "任务不存在或已完成"}
+2 -8
View File
@@ -505,7 +505,7 @@ def _compute_storage(data_dir: Path) -> dict:
stats[f"{key}_size_mb"] = sz
# total: 再加上其他零散文件(pools, financials, capabilities.json 等)
other_dirs = ["pools", "financials", "backtest_results", "screener_results", "ai_cache"]
other_dirs = ["pools", "financials", "screener_results", "ai_cache"]
for name in other_dirs:
d = data_dir / name
if d.exists():
@@ -629,7 +629,7 @@ def clear_data(request: Request):
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
"backtest_results", "screener_results", "ai_cache",
"screener_results", "ai_cache",
):
d = data_dir / sub
if d.exists():
@@ -658,12 +658,6 @@ def clear_data(request: Request):
# - 触发记录 alerts.jsonl
from app.services import alert_store
alert_store.clear(data_dir)
# - 待推送的实时通知队列 (进程内存)
qs = getattr(request.app.state, "quote_service", None)
if qs is not None:
with qs._lock:
qs._pending_alerts.clear()
# 清除 Polars 缓存
# 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return,
# 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因),
+223
View File
@@ -0,0 +1,223 @@
"""数据同步 — 接收 local 端推送的 Parquet 数据包。"""
from __future__ import annotations
import logging
import os
import tarfile
import tempfile
from io import BytesIO
from pathlib import Path
import polars as pl
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/data/sync", tags=["data-sync"])
# 允许同步的子目录列表
SYNCABLE_PARTS = {
"kline_daily", "kline_daily_enriched",
"kline_index_daily", "kline_index_enriched",
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute",
"kline_minute",
"adj_factor", "adj_factor_etf",
"instruments", "instruments_index", "instruments_etf",
"financials",
"pools",
"ext_data",
}
def _resolve_unique_key(columns: list[str]) -> list[str] | None:
"""根据列名推断去重键。
策略:
- 含 symbol + datetime: 按 [symbol, datetime] 去重(分钟 K)
- 含 symbol + date: 按 [symbol, date] 去重(日 K / 复权因子)
- 含 symbol: 按 [symbol] 去重(标的维表)
- 其他: 无法推断,回退到覆盖
"""
cols = set(columns)
if "symbol" not in cols:
return None
if "datetime" in cols:
return ["symbol", "datetime"]
if "date" in cols:
return ["symbol", "date"]
return ["symbol"]
def _merge_parquet(target: Path, new_bytes: bytes) -> tuple[bool, str]:
"""把 new_bytes 代表的 Parquet 与 target 已有文件合并去重。
返回 (是否成功落盘, 操作描述):
- created: 目标不存在,直接新建
- merged: 与已有文件按主键合并去重(保留后写入的记录)
- empty_new: 上传文件为空,保留旧文件
- overwritten_no_key: 无法推断去重键,回退覆盖
- overwritten_corrupt_existing: 已有文件损坏,回退覆盖
- invalid_new: 上传文件不是合法 Parquet,未落盘
"""
if not target.exists():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(new_bytes)
return True, "created"
try:
new_df = pl.read_parquet(BytesIO(new_bytes))
except Exception as exc:
logger.warning("failed to parse uploaded parquet %s: %s", target.name, exc)
return False, "invalid_new"
if new_df.is_empty():
return True, "empty_new"
key = _resolve_unique_key(new_df.columns)
if key is None:
logger.warning(
"no unique key for %s (columns=%s), overwriting",
target.name,
new_df.columns,
)
target.write_bytes(new_bytes)
return True, "overwritten_no_key"
try:
existing_df = pl.read_parquet(target)
except Exception as exc:
logger.warning("existing parquet %s corrupt, overwriting: %s", target, exc)
target.write_bytes(new_bytes)
return True, "overwritten_corrupt_existing"
if existing_df.is_empty():
merged = new_df
action = "created"
else:
merged = pl.concat([existing_df, new_df], how="diagonal_relaxed").unique(
subset=key, keep="last"
)
action = "merged"
# 原子写入,防止写入过程中崩溃导致文件损坏
tmp = target.with_suffix(f".tmp-{os.getpid()}")
try:
merged.write_parquet(tmp)
os.replace(tmp, target)
except Exception:
if tmp.exists():
tmp.unlink(missing_ok=True)
raise
return True, action
def _verify_sync_key(request: Request) -> None:
"""简单的鉴权 — 校验 X-Sync-Key 头。"""
key = request.headers.get("X-Sync-Key", "")
expected = getattr(settings, "sync_key", "")
if expected and key != expected:
raise HTTPException(status_code=403, detail="sync key mismatch")
def _refresh_views(request: Request) -> None:
"""重新注册 DuckDB 视图 + 刷新 Polars 缓存。"""
repo = getattr(request.app.state, "repo", None)
if repo is None:
return
try:
d = repo.store.data_dir.as_posix()
views = {
"kline_daily": f"{d}/kline_daily/**/*.parquet",
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
"kline_minute": f"{d}/kline_minute/**/*.parquet",
"adj_factor": f"{d}/adj_factor/**/*.parquet",
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
"instruments": f"{d}/instruments/**/*.parquet",
"instruments_index": f"{d}/instruments_index/**/*.parquet",
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
}
for name, path in views.items():
try:
repo.store.db.execute(
f"CREATE OR REPLACE VIEW {name} AS "
f"SELECT * FROM read_parquet('{path}', union_by_name=true)"
)
except Exception as exc:
logger.warning("view %s refresh failed: %s", name, exc)
repo.store._register_unified_views()
repo.clear_cache()
repo.refresh_cache()
# 清除 API 数据缓存
from app.api.data import invalidate_data_cache
invalidate_data_cache()
except Exception as exc:
logger.warning("cache refresh failed: %s", exc)
@router.post("/upload")
async def upload_sync(
request: Request,
file: UploadFile = File(...),
parts: str = Form(""),
_auth: None = Depends(_verify_sync_key),
):
"""接收 local 端打包的 tar.gz 数据,解压写入 data/ 目录。"""
data_dir: Path = request.app.state.datastore.data_dir
# 解析要同步的 parts
requested = set(p.strip() for p in parts.split(",") if p.strip())
if not requested:
raise HTTPException(status_code=400, detail="parts 不能为空,逗号分隔子目录名")
invalid = requested - SYNCABLE_PARTS
if invalid:
raise HTTPException(
status_code=400,
detail=f"不支持的同步目录: {', '.join(sorted(invalid))}",
)
# 读取上传的 tar.gz
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="上传文件为空")
extracted = 0
action_counter: dict[str, int] = {}
try:
with tarfile.open(fileobj=BytesIO(raw), mode="r:gz") as tar:
for member in tar.getmembers():
# 安全校验: 防止路径穿越
member_path = Path(member.name)
if member_path.is_absolute() or ".." in member_path.parts:
logger.warning("skipping unsafe path: %s", member.name)
continue
# 确定属于哪个 part
top_dir = member_path.parts[0] if member_path.parts else ""
if top_dir not in requested:
continue
target = data_dir / member.name
if member.isfile():
target.parent.mkdir(parents=True, exist_ok=True)
with tar.extractfile(member) as src:
if src is None:
continue
ok, action = _merge_parquet(target, src.read())
if ok:
extracted += 1
action_counter[action] = action_counter.get(action, 0) + 1
except tarfile.TarError as exc:
raise HTTPException(status_code=400, detail=f"压缩包解析失败: {exc}") from exc
# 刷新缓存
_refresh_views(request)
logger.info("sync uploaded: parts=%s files=%d actions=%s", parts, extracted, action_counter)
return {"ok": True, "parts": sorted(requested), "file_count": extracted, "actions": action_counter}
+6 -114
View File
@@ -26,7 +26,6 @@ from app.services.ext_data import (
write_ext_parquet,
rows_to_parquet,
)
from app.services.ext_pull import fetch_and_ingest, pull_scheduler
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ext-data", tags=["ext-data"])
@@ -330,20 +329,8 @@ 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}
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
@router.post("")
@@ -548,115 +535,20 @@ def ingest_data(request: Request, config_id: str, body: IngestReq):
@router.put("/{config_id}/pull")
def configure_pull(request: Request, config_id: str, body: PullConfigReq):
"""配置(或更新)定时拉取。"""
store = _store(request)
config = store.get(config_id)
if not config:
raise HTTPException(404, f"配置 '{config_id}' 不存在")
# 保留历史状态字段
old_pull = config.pull
config.pull = PullConfig(
url=body.url,
method=body.method,
headers=body.headers,
body=body.body,
response_path=body.response_path,
field_map=body.field_map,
schedule_minutes=body.schedule_minutes,
enabled=body.enabled,
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,
last_rows=old_pull.last_rows if old_pull else None,
)
store.upsert(config)
# 刷新调度器
pull_scheduler.refresh(_data_dir(request))
# 关闭定时拉取时清理残留的 next_run, 避免前端展示一个永不执行的"下次"
if not config.pull.enabled:
cleared = store.get(config_id)
if cleared and cleared.pull and cleared.pull.next_run:
cleared.pull.next_run = None
store.upsert(cleared)
return {"status": "ok", "pull": config.pull.to_dict()}
"""配置(或更新)定时拉取 — serve 端为只读模式,已禁用"""
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
@router.post("/{config_id}/pull/test")
async def test_pull(request: Request, config_id: str):
"""测试拉取:请求外部 API 并返回预览数据,不写入"""
store = _store(request)
config = store.get(config_id)
if not config:
raise HTTPException(404, f"配置 '{config_id}' 不存在")
if not config.pull or not config.pull.url:
raise HTTPException(400, "拉取未配置或 URL 为空")
# 临时构建一个带新配置的 config 用于测试
from app.services.ext_pull import _extract_rows, _apply_field_map
import httpx
pull = config.pull
try:
async with httpx.AsyncClient(timeout=30) as client:
headers = pull.headers or {}
kwargs: dict = {"headers": headers}
if pull.method.upper() == "POST" and pull.body:
kwargs["content"] = pull.body
if "content-type" not in {k.lower() for k in headers}:
kwargs["headers"]["Content-Type"] = "application/json"
resp = await client.request(pull.method.upper(), pull.url, **kwargs)
resp.raise_for_status()
data = resp.json()
rows = _extract_rows(data, pull.response_path)
preview = _apply_field_map(rows[:5], pull.field_map)
return {
"status": "ok",
"total_rows": len(rows),
"preview": preview,
"has_symbol": bool(rows and "symbol" in rows[0]),
}
except Exception as e:
raise HTTPException(400, f"测试失败: {e}") from e
"""测试拉取 — serve 端为只读模式,已禁用"""
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
@router.post("/{config_id}/pull/run")
async def run_pull(request: Request, config_id: str):
"""手动触发一次拉取并写入。"""
store = _store(request)
config = store.get(config_id)
if not config:
raise HTTPException(404, f"配置 '{config_id}' 不存在")
if not config.pull or not config.pull.url:
raise HTTPException(400, "拉取未配置或 URL 为空")
try:
n, d = await fetch_and_ingest(config, _data_dir(request))
_refresh_views(request)
# 写回执行状态, 让前端"上次执行"面板立即反映
updated = store.get(config_id)
if updated and updated.pull:
from datetime import datetime, timezone
updated.pull.last_run = datetime.now(timezone.utc).isoformat()
updated.pull.last_status = "success"
updated.pull.last_message = f"{n} rows @ {d}"
updated.pull.last_rows = n
store.upsert(updated)
return {"status": "ok", "rows": n, "date": d}
except Exception as e:
# 失败也写回状态, 记录错误信息
failed = store.get(config_id)
if failed and failed.pull:
from datetime import datetime, timezone
failed.pull.last_run = datetime.now(timezone.utc).isoformat()
failed.pull.last_status = "error"
failed.pull.last_message = str(e)[:200]
store.upsert(failed)
raise HTTPException(400, f"拉取失败: {e}") from e
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
# ---------------------------------------------------------------------------
+15 -46
View File
@@ -20,11 +20,7 @@ router = APIRouter(prefix="/api/financials", tags=["financials"])
@router.get("/status")
def financial_status(request: Request):
"""返回各财务表的同步状态。无需 FINANCIAL 权限(前端根据 available 决定是否展示)。"""
capset = request.app.state.capabilities
if not capset.has(Cap.FINANCIAL):
return {"available": False, "tables": {}}
"""返回各财务表的同步状态。"""
data_dir = request.app.state.repo.store.data_dir
tables = {}
@@ -42,15 +38,16 @@ def financial_status(request: Request):
else:
tables[table] = {"rows": 0, "symbols": 0}
capset = request.app.state.capabilities
has_cap = capset.has(Cap.FINANCIAL)
fs = getattr(request.app.state, "financial_scheduler", None)
last_sync = fs.last_sync if fs else {}
has_data = any(t.get("rows", 0) > 0 for t in tables.values())
return {
"available": True,
"available": has_cap or has_data,
"tables": tables,
"last_sync": last_sync,
# 服务端是否正在同步(手动触发)——前端据此显示"同步中"并防重复点击,
# 且刷新页面后仍能正确反映服务端状态。
"syncing": bool(fs and fs.is_syncing),
}
@@ -58,9 +55,6 @@ def financial_status(request: Request):
@router.get("/metrics")
def get_metrics(request: Request, symbol: str | None = None):
"""查询核心财务指标。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
df = get_financial_df(request.app.state.repo.store.data_dir, "metrics")
if df.is_empty():
return {"data": []}
@@ -72,9 +66,6 @@ def get_metrics(request: Request, symbol: str | None = None):
@router.get("/income")
def get_income(request: Request, symbol: str | None = None):
"""查询利润表。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
df = get_financial_df(request.app.state.repo.store.data_dir, "income")
if df.is_empty():
return {"data": []}
@@ -86,9 +77,6 @@ def get_income(request: Request, symbol: str | None = None):
@router.get("/balance-sheet")
def get_balance_sheet(request: Request, symbol: str | None = None):
"""查询资产负债表。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
df = get_financial_df(request.app.state.repo.store.data_dir, "balance_sheet")
if df.is_empty():
return {"data": []}
@@ -100,9 +88,6 @@ def get_balance_sheet(request: Request, symbol: str | None = None):
@router.get("/cash-flow")
def get_cash_flow(request: Request, symbol: str | None = None):
"""查询现金流量表。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
df = get_financial_df(request.app.state.repo.store.data_dir, "cash_flow")
if df.is_empty():
return {"data": []}
@@ -113,27 +98,8 @@ def get_cash_flow(request: Request, symbol: str | None = None):
@router.post("/sync/{table}")
def sync_table(request: Request, table: str):
"""手动触发同步(立即返回,后台异步执行)。
table: metrics / income / balance_sheet / cash_flow / all
同步在后台线程执行,全量同步需数分钟。本接口立即返回 started 状态,
前端通过轮询 GET /status 的 syncing 字段观察进度。
"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
valid_tables = {"metrics", "income", "balance_sheet", "cash_flow", "all"}
if table not in valid_tables:
raise HTTPException(400, f"invalid table: {table}, expected one of {valid_tables}")
fs = getattr(request.app.state, "financial_scheduler", None)
if not fs:
return {"status": "error", "message": "FinancialScheduler not available"}
target = None if table == "all" else table
result = fs.trigger(target)
return {"status": "ok", "synced": result}
"""手动触发同步 — serve 端为只读模式,已禁用。"""
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
class AnalyzeRequest(BaseModel):
@@ -159,7 +125,7 @@ async def analyze_financials(request: Request, req: AnalyzeRequest):
data_dir = request.app.state.repo.store.data_dir
async def stream_gen():
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus):
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
@@ -185,11 +151,12 @@ class SaveReportRequest(BaseModel):
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。"""
"""获取当前用户的历史报告(按时间降序,后端已裁剪到上限)。"""
capset = request.app.state.capabilities
if not capset.has(Cap.FINANCIAL):
return {"reports": []}
return {"reports": ai_reports.list_reports()}
username = getattr(request.state, "username", None)
return {"reports": ai_reports.list_reports(username)}
@router.post("/reports")
@@ -197,6 +164,7 @@ def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
username = getattr(request.state, "username", None)
report = ai_reports.save_report({
"symbol": req.symbol,
"name": req.name,
@@ -204,7 +172,7 @@ def save_report(request: Request, req: SaveReportRequest):
"content": req.content,
"periods": req.periods,
"summary": req.summary,
})
}, username)
return {"ok": True, "report": report}
@@ -213,5 +181,6 @@ def delete_report(request: Request, report_id: str):
"""删除一条报告。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
ok = ai_reports.delete_report(report_id)
username = getattr(request.state, "username", None)
ok = ai_reports.delete_report(report_id, username)
return {"ok": ok}
-212
View File
@@ -1,212 +0,0 @@
"""行情状态 / SSE 推送 API。
盘中选股相关端点已迁移至策略页面,此处仅保留全局行情基础设施。
SSE 推送三种事件 (使用标准 SSE event 字段):
- quotes_updated: 行情数据刷新,前端 invalidate 对应 query
- strategy_alert: 策略监控/告警触发,前端弹通知
- depth_updated: 五档盘口修正完成,前端刷新连板梯队/看板封单数据
"""
from __future__ import annotations
import asyncio
import json
import time
from fastapi import APIRouter, Query, Request
from sse_starlette.sse import EventSourceResponse
router = APIRouter(prefix="/api/intraday", tags=["quotes"])
def _get_quote_service(request: Request):
"""获取全局 QuoteService。"""
return getattr(request.app.state, "quote_service", None)
def _fallback_index_quotes_from_daily(request: Request, symbols: list[str] | None = None) -> list[dict]:
"""实时指数缓存为空时,从本地指数日 K 取最近收盘价作为兜底。"""
repo = getattr(request.app.state, "repo", None)
if not repo:
return []
params: list[str] = []
symbol_filter = ""
if symbols:
placeholders = ", ".join("?" for _ in symbols)
symbol_filter = f"WHERE symbol IN ({placeholders})"
params.extend(symbols)
try:
rows = repo.execute_all(
f"""
WITH ranked AS (
SELECT symbol, date, close,
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
FROM kline_index_daily
{symbol_filter}
), latest AS (
SELECT symbol,
max(CASE WHEN rn = 1 THEN date END) AS date,
max(CASE WHEN rn = 1 THEN close END) AS last_price,
max(CASE WHEN rn = 2 THEN close END) AS prev_close
FROM ranked
WHERE rn <= 2
GROUP BY symbol
)
SELECT latest.symbol, latest.date, latest.last_price, latest.prev_close
FROM latest
ORDER BY latest.symbol
""",
params,
)
except Exception: # noqa: BLE001
return []
out: list[dict] = []
for symbol, dt, last_price, prev_close in rows:
change_amount = None
change_pct = None
if last_price is not None and prev_close not in (None, 0):
change_amount = float(last_price) - float(prev_close)
change_pct = change_amount / float(prev_close) * 100
out.append({
"symbol": symbol,
"name": None,
"date": str(dt) if dt else None,
"last_price": float(last_price) if last_price is not None else None,
"close": float(last_price) if last_price is not None else None,
"prev_close": float(prev_close) if prev_close is not None else None,
"change_amount": change_amount,
"change_pct": change_pct,
"source": "index_daily",
})
return out
@router.get("/status")
def status(request: Request):
"""行情状态 (来自全局 QuoteService)。"""
qs = _get_quote_service(request)
if qs:
return qs.status()
return {"enabled": False, "running": False, "symbol_count": 0, "index_symbol_count": 0,
"quote_age_ms": None, "is_trading_hours": False, "last_fetch_ms": None}
@router.get("/indices")
def index_quotes(
request: Request,
symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"),
):
"""返回实时指数行情缓存,不触发 TickFlow 请求。"""
symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None
qs = _get_quote_service(request)
if not qs:
rows = _fallback_index_quotes_from_daily(request, symbol_list)
return {"rows": rows, "count": len(rows), "source": "index_daily"}
df = qs.get_index_quotes(symbol_list)
rows = df.to_dicts() if not df.is_empty() else []
if not rows:
rows = _fallback_index_quotes_from_daily(request, symbol_list)
return {"rows": rows, "count": len(rows), "source": "index_daily"}
return {"rows": rows, "count": len(rows), "source": "realtime"}
@router.get("/stream")
async def quote_stream(request: Request):
"""SSE 端点: 行情更新 + 告警推送 + 五档修正。
使用 sse-starlette EventSourceResponse:
- 标准 SSE event 字段,前端按 event name 监听
- 内置断线检测,客户端断开立即终止 generator
- 内置 ping 心跳,保持连接活跃
"""
qs = _get_quote_service(request)
async def event_generator():
while True:
# 同时等待三类信号: 行情更新 / 告警 / 五档修正
tasks: dict[str, asyncio.Future] = {
"quote": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_update, timeout=5.0) if qs else asyncio.sleep(5)
),
"alert": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_alert, timeout=5.0) if qs else asyncio.sleep(5)
),
"depth": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5)
),
"review": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_review, timeout=5.0) if qs else asyncio.sleep(5)
),
}
done, pending = await asyncio.wait(
list(tasks.values()),
timeout=30.0,
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
# 先推送告警 (如果有)
if qs:
alerts = qs.pop_alerts()
if alerts:
for chunk_start in range(0, len(alerts), 20):
chunk = alerts[chunk_start:chunk_start + 20]
yield {
"event": "strategy_alert",
"data": json.dumps({
"ts": int(time.time() * 1000),
"alerts": chunk,
}, ensure_ascii=False),
}
# 推送复盘进度 (定时复盘流式生成时) — 前端 reviewStore 直接消费
# 事件已是 recap_market_stream 产出的 JSON 字符串, 逐条转发
for evt_json in qs.pop_review_events():
yield {
"event": "review_progress",
"data": evt_json,
}
# 推送行情更新 (行情信号触发)
if tasks["quote"] in done:
try:
update_result = tasks["quote"].result()
except Exception: # noqa: BLE001
update_result = False
if update_result:
yield {
"event": "quotes_updated",
"data": json.dumps({
"ts": int(time.time() * 1000),
"symbol_count": qs._symbol_count if qs else 0,
}),
}
# 推送五档修正完成 (depth 信号触发) — 前端刷新连板梯队封单数据
if tasks["depth"] in done:
try:
depth_result = tasks["depth"].result()
except Exception: # noqa: BLE001
depth_result = False
if depth_result:
yield {
"event": "depth_updated",
"data": json.dumps({
"ts": int(time.time() * 1000),
}),
}
return EventSourceResponse(event_generator())
@router.post("/refresh")
def refresh_quotes(request: Request):
"""手动刷新一次行情数据。"""
qs = _get_quote_service(request)
if qs:
return qs.refresh()
return {"error": "QuoteService not available"}
+2 -78
View File
@@ -137,16 +137,11 @@ def get_daily(
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
enriched = compute_enriched(raw, factors=factors)
rows = enriched.tail(days).to_dicts()
# 即使 live 模式也尝试追加实时蜡烛
rows = _maybe_inject_live_candle(request, symbol, rows)
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
return _attach_ext(resp, repo, symbol, ext_columns)
rows = df.to_dicts()
# 追加/覆盖今日实时蜡烛
rows = _maybe_inject_live_candle(request, symbol, rows)
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
return _attach_ext(resp, repo, symbol, ext_columns)
@@ -155,7 +150,7 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。
JOIN 逻辑;任何 ext 表/字段缺失都静默跳过。
"""
if not ext_columns or not ext_columns.strip():
return resp
@@ -216,77 +211,6 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
return resp
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]:
"""如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return rows
df_today, enriched_date = qs.get_enriched_today()
if df_today.is_empty():
return rows
# 非交易日(周末/假日)缓存的行情日期 != 今天,跳过注入避免产生重复蜡烛
if not enriched_date or enriched_date != date.today():
return rows
# 查找该 symbol 的实时 enriched 行
import polars as pl
try:
q = df_today.filter(pl.col("symbol") == symbol).to_dicts()
if not q:
return rows
q = q[0]
except Exception: # noqa: BLE001
return rows
close_price = q.get("close")
if not close_price or close_price <= 0:
return rows
today_str = str(enriched_date)
# enriched 行已包含 OHLCV + 全套指标, 直接用它
# 修复: API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛
raw_open = q.get("open")
raw_high = q.get("high")
raw_low = q.get("low")
live_row: dict = {
"date": today_str,
"symbol": symbol,
"open": raw_open if raw_open and raw_open > 0 else close_price,
"high": raw_high if raw_high and raw_high > 0 else close_price,
"low": raw_low if raw_low and raw_low > 0 else close_price,
"close": close_price,
"volume": q.get("volume"),
"amount": q.get("amount"),
"change_pct": q.get("change_pct"),
"is_live": True,
}
# 补上 enriched 的技术指标字段
for key in ("ma5", "ma10", "ma20", "ma30", "ma60",
"macd_dif", "macd_dea", "macd_hist",
"kdj_k", "kdj_d", "kdj_j",
"boll_upper", "boll_lower",
"rsi_6", "rsi_14", "rsi_24",
"atr_14", "vol_ratio_5d"):
if key in q and q[key] is not None:
live_row[key] = q[key]
# 如果已有今天的 enriched 行, 覆盖; 否则追加
found = False
for i, r in enumerate(rows):
if str(r.get("date")) == today_str:
r.update(live_row)
found = True
break
if not found:
rows.append(live_row)
return rows
class DailyBatchRequest:
"""批量日K请求。"""
symbols: list[str]
@@ -456,7 +380,7 @@ async def sync_minute(request: Request):
try:
progress("sync_minute", 5, "解析标的池…")
universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A")))
universe = sorted(set(get_pool("CN_Equity_A")))
# 补充 instruments 全量标的,覆盖北交所、新股等
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
if inst_path.exists():
+8 -9
View File
@@ -46,8 +46,6 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
from datetime import date as date_cls
repo = request.app.state.repo
quote_service = getattr(request.app.state, "quote_service", None)
depth_service = getattr(request.app.state, "depth_service", None)
as_of = None
if req.as_of:
@@ -57,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
async def stream_gen():
async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus):
async for chunk in recap_market_stream(repo, as_of, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
@@ -83,13 +81,15 @@ class SaveReportRequest(BaseModel):
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史复盘(按时间降序,后端已裁剪到上限)。"""
return {"reports": market_recap_reports.list_reports()}
"""获取当前用户的历史复盘(按时间降序,后端已裁剪到上限)。"""
username = getattr(request.state, "username", None)
return {"reports": market_recap_reports.list_reports(username)}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条复盘报告。"""
username = getattr(request.state, "username", None)
report = market_recap_reports.save_report({
"as_of": req.as_of,
"focus": req.focus,
@@ -97,9 +97,7 @@ def save_report(request: Request, req: SaveReportRequest):
"summary": req.summary,
"emotion_score": req.emotion_score,
"emotion_label": req.emotion_label,
})
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。
# 内部 try/except 静默降级, 不影响归档返回值。
}, username)
from app.jobs.daily_pipeline import _maybe_push_review
_maybe_push_review(req.content, {
"as_of": req.as_of,
@@ -111,5 +109,6 @@ def save_report(request: Request, req: SaveReportRequest):
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条复盘报告。"""
ok = market_recap_reports.delete_report(report_id)
username = getattr(request.state, "username", None)
ok = market_recap_reports.delete_report(report_id, username)
return {"ok": ok}
-499
View File
@@ -1,499 +0,0 @@
"""监控规则 API 路由 — HTTP 请求 → 调用 monitor_rules 模块 → 同步引擎内存态。
只做胶水: 校验 → 持久化 → 失效引擎内存态。不含评估逻辑。
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app.strategy import monitor_rules
router = APIRouter(prefix="/api/monitor-rules", tags=["monitor-rules"])
def _data_dir(request: Request) -> Path:
return request.app.state.repo.store.data_dir
def _sync_engine(request: Request) -> None:
"""保存/删除后,把最新规则集 reload 到引擎内存态。"""
engine = getattr(request.app.state, "monitor_engine", None)
if engine is not None:
rules = monitor_rules.load_all(_data_dir(request))
engine.set_rules(rules)
# ── Pydantic 模型 ───────────────────────────────────────
class ConditionModel(BaseModel):
field: str
op: str # truth | > >= < <= == !=
value: float | None = None # op 非 truth 时必填
class RuleModel(BaseModel):
id: str
name: str
enabled: bool = True
type: str # strategy | signal | price | market
scope: str = "symbols" # symbols | all | sector
symbols: list[str] = []
sector: str | None = None
strategy_id: str | None = None
direction: str = "entry" # entry | exit | both
conditions: list[ConditionModel] = []
logic: str = "and" # and | or
cooldown_seconds: int = 3600
severity: str = "info" # info | warn | critical
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定)
webhook_enabled: bool = False
message: str = ""
# ladder 专属 (连板梯队封单监控)
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
# ── 字段选项 ─────────────────────────────────────────────
@router.get("/options")
def get_options(request: Request):
"""返回可选字段、信号列、运算符、枚举,供前端表单使用。"""
from app.indicators.pipeline import ENRICHED_COLUMNS
from app.strategy.custom_signals import ALLOWED_FIELDS, load_all as load_csg
# 阈值字段 (带中文标签)
threshold_fields = [
{"key": f, "label": ENRICHED_COLUMNS.get(f, f)}
for f in sorted(ALLOWED_FIELDS)
]
# 内置信号列 (布尔, 用于 op=truth)
builtin_signals = [
{"key": k, "label": v}
for k, v in ENRICHED_COLUMNS.items()
if k.startswith("signal_")
]
# 自定义信号列 (csg_)
custom_sigs = []
try:
for cs in load_csg(_data_dir(request)):
if cs.get("enabled") is not False:
custom_sigs.append({
"key": f"csg_{cs['id']}",
"label": cs.get("name", cs["id"]),
})
except Exception:
pass
return {
"threshold_fields": threshold_fields,
"builtin_signals": builtin_signals,
"custom_signals": custom_sigs,
"operators": [">", ">=", "<", "<=", "==", "!="],
"types": [
{"key": "signal", "label": "个股信号"},
{"key": "price", "label": "价格/涨跌"},
{"key": "market", "label": "市场异动"},
{"key": "strategy", "label": "策略监控"},
],
"scopes": [
{"key": "symbols", "label": "指定股票"},
{"key": "all", "label": "全市场"},
{"key": "sector", "label": "板块"},
],
"logics": [
{"key": "and", "label": "全部满足 (AND)"},
{"key": "or", "label": "任一满足 (OR)"},
],
"severities": [
{"key": "info", "label": "普通"},
{"key": "warn", "label": "警告"},
{"key": "critical", "label": "重要"},
],
"directions": [
{"key": "entry", "label": "买入"},
{"key": "exit", "label": "卖出"},
{"key": "both", "label": "买卖都报"},
],
}
# ── 列表 ───────────────────────────────────────────────
@router.get("")
def list_rules(request: Request):
rules = monitor_rules.load_all(_data_dir(request))
# 按 created_at 倒序
rules.sort(key=lambda r: r.get("created_at", ""), reverse=True)
return {"rules": rules}
# ── 新建 / 更新 ────────────────────────────────────────
@router.post("")
def save_rule(req: RuleModel, request: Request):
rule = monitor_rules.normalize(req.model_dump())
# 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。
# 无能力时拒绝创建, 避免规则存了却永远无法触发。
if rule.get("type") == "ladder":
from app.tickflow.capabilities import Cap
capset = getattr(request.app.state, "capabilities", None)
if capset is None or not capset.has(Cap.DEPTH5_BATCH):
raise HTTPException(
status_code=403,
detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置",
)
# 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动)
existing = monitor_rules.load_one(_data_dir(request), rule["id"])
if existing and existing.get("created_at"):
rule["created_at"] = existing["created_at"]
try:
monitor_rules.validate(rule)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
monitor_rules.save_one(_data_dir(request), rule)
_sync_engine(request)
return {"ok": True, "rule": rule}
# ── 删除 ───────────────────────────────────────────────
@router.delete("/{rule_id}")
def delete_rule(rule_id: str, request: Request):
if not monitor_rules.ID_RE.match(rule_id):
raise HTTPException(status_code=400, detail="规则 id 非法")
deleted = monitor_rules.delete_one(_data_dir(request), rule_id)
if not deleted:
raise HTTPException(status_code=404, detail="规则不存在")
_sync_engine(request)
return {"ok": True}
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
import time as _time
from datetime import datetime, timezone
def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str],
conditions: list[dict], logic: str = "or", cooldown: int = 3600,
severity: str = "info", message: str = "",
strategy_id: str | None = None, direction: str = "entry") -> dict:
rule = monitor_rules.normalize({
"id": rule_id,
"name": name,
"type": rtype,
"scope": scope,
"symbols": symbols,
"conditions": conditions,
"logic": logic,
"cooldown_seconds": cooldown,
"severity": severity,
"message": message,
"enabled": True,
})
if rtype == "strategy":
rule["strategy_id"] = strategy_id
rule["direction"] = direction
return rule
_DEMO_RULES_TEMPLATE = [
("个股信号 · 茅台放量突破", "signal", "symbols", ["600519.SH"],
[{"field": "signal_volume_surge", "op": "truth"},
{"field": "signal_n_day_high", "op": "truth"}], "or", "info"),
("个股信号 · 宁德金叉", "signal", "symbols", ["300750.SZ"],
[{"field": "signal_ma_golden_5_20", "op": "truth"}], "or", "info"),
("价格 · 平安跌幅监控", "price", "symbols", ["000001.SZ"],
[{"field": "change_pct", "op": "<", "value": -0.03}], "or", "warn", "warn"),
("价格 · 比亚迪RSI超卖", "price", "symbols", ["002594.SZ"],
[{"field": "rsi_14", "op": "<", "value": 30}], "and", "warn", "warn"),
("市场异动 · 全市场涨停", "market", "all", [],
[{"field": "signal_limit_up", "op": "truth"}], "or", "critical", "critical"),
("市场异动 · 全市场炸板", "market", "all", [],
[{"field": "signal_broken_limit_up", "op": "truth"}], "or", "warn", "warn"),
("市场异动 · 跌幅超5%", "market", "all", [],
[{"field": "change_pct", "op": "<", "value": -0.05}], "or", "warn", "warn"),
("个股信号 · 茅台跌破MA20", "signal", "symbols", ["600519.SH"],
[{"field": "signal_ma20_breakdown", "op": "truth"}], "or", "info"),
]
# 策略类型单独声明 (格式不同: 含 strategy_id + direction)
_DEMO_STRATEGY_RULES: list[dict] = [
{"name": "策略监控 · 趋势突破", "strategy_id": "trend_breakout", "direction": "entry"},
{"name": "策略监控 · MACD金叉", "strategy_id": "macd_golden", "direction": "both"},
]
@router.post("/seed")
def seed_demo_rules(request: Request):
"""生成演示监控规则 (Dev 页用)。覆盖 signal/price/market/strategy 四类。"""
ts = int(_time.time() * 1000)
created = []
i = 0
for (name, rtype, scope, symbols, conditions, logic, severity, sev) in _DEMO_RULES_TEMPLATE:
rule_id = f"demo_{ts}_{i}"
rule = _demo_rule(rule_id, name, rtype, scope, symbols, conditions, logic, 3600, sev)
monitor_rules.save_one(_data_dir(request), rule)
created.append(rule_id)
i += 1
# 策略类型规则
for sr in _DEMO_STRATEGY_RULES:
rule_id = f"demo_{ts}_{i}"
rule = _demo_rule(
rule_id, sr["name"], "strategy", "all", [], [], "and", 3600, "info",
strategy_id=sr["strategy_id"], direction=sr.get("direction", "entry"),
)
monitor_rules.save_one(_data_dir(request), rule)
created.append(rule_id)
i += 1
_sync_engine(request)
return {"ok": True, "generated": len(created), "ids": created}
# ── 封单监控模拟触发 (Dev 调试用) ─────────────────────
@router.post("/test-ladder")
def test_ladder(request: Request):
"""模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。
用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame,
跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。
"""
import polars as pl
repo = request.app.state.repo
depth_svc = getattr(request.app.state, "depth_service", None)
engine = getattr(request.app.state, "monitor_engine", None)
if not depth_svc:
raise HTTPException(status_code=503, detail="depth 服务未初始化")
if not engine or not engine.has_rule_type("ladder"):
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
# 最新交易日
latest = repo.enriched_latest_date()
if not latest:
raise HTTPException(status_code=400, detail="无 enriched 数据")
# 取涨停+跌停封单 {symbol: vol}
sealed: dict[str, int] = {}
for is_down in (False, True):
m = depth_svc.get_sealed_map(latest, is_down=is_down)
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol
if not sealed:
raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)")
# 取这些 symbol 的 close (算封单额用)
enriched_today, _ = repo.get_enriched_latest()
cols = ["symbol", "close", "change_pct"]
avail = [c for c in cols if c in enriched_today.columns]
mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys())))
# 注入 _sealed_vol
sealed_df = pl.DataFrame({
"symbol": list(sealed.keys()),
"_sealed_vol": list(sealed.values()),
})
mock = mock.join(sealed_df, on="symbol", how="inner")
# 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire)
ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)]
all_events = []
not_triggered = []
for rule in ladder_rules:
syms = rule.get("symbols", [])
sym = syms[0] if syms else None
metric = rule.get("metric", "sealed_vol")
thr = rule.get("threshold", 0)
direction = rule.get("direction", "up")
warn_label = "炸板预警" if direction == "up" else "翘板预警"
# 取该 symbol 的封单数据
cur_vol = sealed.get(sym) if sym else None
row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear()
cur_close = row["close"][0] if len(row) and "close" in row.columns else None
cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None
cur_val = cur_amt if metric == "sealed_amount" else cur_vol
# 条件判断: 封单 > 0 且 比较值 <= 阈值
if cur_val is not None and cur_val > 0 and cur_val <= thr:
if metric == "sealed_amount":
sv_text = f"{cur_val / 1e4:.0f}万元"
th_text = f"{thr / 1e4:.0f}万元"
else:
sv_text = f"{cur_val:,.0f}"
th_text = f"{thr:,.0f}"
all_events.append({
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"symbol": sym,
"name": sym,
"type": warn_label,
"message": f"{warn_label} · 封单 {sv_text}{th_text}",
"severity": rule.get("severity", "warn"),
"sealed_value": cur_val,
"sealed_metric": metric,
"current_sealed_vol": cur_vol,
"current_sealed_amount": cur_amt,
})
else:
reason = "封单数据缺失" if cur_val is None else (
f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0"
)
not_triggered.append({
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"symbol": sym,
"metric": metric,
"threshold": thr,
"current_value": cur_val,
"current_sealed_vol": cur_vol,
"current_sealed_amount": cur_amt,
"reason": reason,
})
return {
"ok": True,
"as_of": str(latest),
"sealed_count": len(sealed),
"triggered": all_events,
"not_triggered": not_triggered,
}
@router.post("/trigger-ladder")
def trigger_ladder(request: Request):
"""真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。
与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE,
让用户看到真实的预警通知。绕过 cooldown 强制触发。
"""
import time
from app.services import alert_store
repo = request.app.state.repo
depth_svc = getattr(request.app.state, "depth_service", None)
engine = getattr(request.app.state, "monitor_engine", None)
quote_svc = getattr(request.app.state, "quote_service", None)
if not depth_svc:
raise HTTPException(status_code=503, detail="depth 服务未初始化")
if not engine or not engine.has_rule_type("ladder"):
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
latest = repo.enriched_latest_date()
if not latest:
raise HTTPException(status_code=400, detail="无 enriched 数据")
# 取封单
sealed: dict[str, int] = {}
for is_down in (False, True):
m = depth_svc.get_sealed_map(latest, is_down=is_down)
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol
if not sealed:
raise HTTPException(status_code=400, detail="无封单数据")
# 构造真实 rule_events (与 _evaluate_ladder 产出格式一致)
import polars as pl
enriched_today, _ = repo.get_enriched_latest()
cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns]
mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys())))
sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())})
mock = mock.join(sealed_df, on="symbol", how="inner")
now = time.time()
rule_events: list[dict] = []
name_map = {}
try:
inst = repo.get_instruments()
if not inst.is_empty() and "name" in inst.columns:
name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")}
except Exception: # noqa: BLE001
pass
for rule in engine.rules.values():
if rule.get("type") != "ladder" or not rule.get("enabled", True):
continue
sym = rule.get("symbols", [""])[0] if rule.get("symbols") else ""
metric = rule.get("metric", "sealed_vol")
thr = rule.get("threshold", 0)
direction = rule.get("direction", "up")
warn_label = "炸板预警" if direction == "up" else "翘板预警"
row = mock.filter(pl.col("symbol") == sym)
if row.is_empty():
continue
cur_vol = row["_sealed_vol"][0]
close_v = row["close"][0] if "close" in row.columns else None
cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol
if not cur_val or cur_val <= 0 or cur_val > thr:
continue # 不满足条件, 跳过
if metric == "sealed_amount":
sv_text = f"{cur_val / 1e4:.0f}万元"
th_text = f"{thr / 1e4:.0f}万元"
else:
sv_text = f"{cur_val:,.0f}"
th_text = f"{thr:,.0f}"
rule_events.append({
"ts": int(now * 1000),
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"source": "ladder",
"type": warn_label,
"symbol": sym,
"name": name_map.get(sym, sym),
"message": f"{warn_label} · 封单 {sv_text}{th_text}",
"price": close_v,
"change_pct": row["change_pct"][0] if "change_pct" in row.columns else None,
"signals": [],
"severity": rule.get("severity", "warn"),
"conditions": [],
"logic": "and",
"sealed_value": cur_val,
"sealed_metric": metric,
})
if not rule_events:
raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)")
# 1. 落盘到 alerts.jsonl
try:
alert_store.append_many(repo.store.data_dir, rule_events)
except Exception as e: # noqa: BLE001
pass # 落盘失败不阻断推送
# 2. SSE 推送 (入 pending_alerts 队列)
if quote_svc:
sse_alerts = [{
"source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"],
"strategy_id": None, "symbol": ev["symbol"], "name": ev["name"],
"message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"],
"signals": ev["signals"], "severity": ev["severity"],
"conditions": ev["conditions"], "logic": ev["logic"],
} for ev in rule_events]
try:
with quote_svc._lock:
quote_svc._pending_alerts.extend(sse_alerts)
quote_svc._alert_event.set()
except Exception: # noqa: BLE001
pass
# 3. 飞书推送
if quote_svc:
try:
quote_svc._maybe_send_webhook(rule_events, engine)
except Exception: # noqa: BLE001
pass
return {
"ok": True,
"triggered": len(rule_events),
"events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events],
}
+45 -61
View File
@@ -222,68 +222,54 @@ def _score(value: float, low: float, high: float) -> int:
return max(0, min(100, round((value - low) / (high - low) * 100)))
def _quote_status(request: Request) -> dict:
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
return qs.status()
def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]:
qs = getattr(request.app.state, "quote_service", None)
rows: list[dict] = []
if qs and as_of is None:
df = qs.get_index_quotes(list(CORE_INDEX_SYMBOLS))
if not df.is_empty():
rows = df.to_dicts()
if not rows:
repo = getattr(request.app.state, "repo", None)
if repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
try:
db_rows = repo.execute_all(
f"""
WITH ranked AS (
SELECT symbol, date, close,
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
FROM kline_index_daily
WHERE symbol IN ({placeholders})
AND (? IS NULL OR date <= ?)
), latest AS (
SELECT symbol,
max(CASE WHEN rn = 1 THEN date END) AS date,
max(CASE WHEN rn = 1 THEN close END) AS last_price,
max(CASE WHEN rn = 2 THEN close END) AS prev_close
FROM ranked
WHERE rn <= 2
GROUP BY symbol
)
SELECT symbol, date, last_price, prev_close
FROM latest
""",
[*CORE_INDEX_SYMBOLS, as_of, as_of],
repo = getattr(request.app.state, "repo", None)
if repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
try:
db_rows = repo.execute_all(
f"""
WITH ranked AS (
SELECT symbol, date, close,
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
FROM kline_index_daily
WHERE symbol IN ({placeholders})
AND (? IS NULL OR date <= ?)
), latest AS (
SELECT symbol,
max(CASE WHEN rn = 1 THEN date END) AS date,
max(CASE WHEN rn = 1 THEN close END) AS last_price,
max(CASE WHEN rn = 2 THEN close END) AS prev_close
FROM ranked
WHERE rn <= 2
GROUP BY symbol
)
except Exception: # noqa: BLE001
db_rows = []
for symbol, dt, last_price, prev_close in db_rows:
change_amount = None
change_pct = None
lp = _finite(last_price)
pc = _finite(prev_close)
if lp is not None and pc not in (None, 0):
change_amount = lp - pc
change_pct = change_amount / pc * 100
rows.append({
"symbol": symbol,
"name": CORE_INDEX_NAMES.get(symbol),
"date": str(dt) if dt else None,
"last_price": lp,
"close": lp,
"prev_close": pc,
"change_amount": change_amount,
"change_pct": change_pct,
})
SELECT symbol, date, last_price, prev_close
FROM latest
""",
[*CORE_INDEX_SYMBOLS, as_of, as_of],
)
except Exception: # noqa: BLE001
db_rows = []
for symbol, dt, last_price, prev_close in db_rows:
change_amount = None
change_pct = None
lp = _finite(last_price)
pc = _finite(prev_close)
if lp is not None and pc not in (None, 0):
change_amount = lp - pc
change_pct = change_amount / pc * 100
rows.append({
"symbol": symbol,
"name": CORE_INDEX_NAMES.get(symbol),
"date": str(dt) if dt else None,
"last_price": lp,
"close": lp,
"prev_close": pc,
"change_amount": change_amount,
"change_pct": change_pct,
})
by_symbol = {r.get("symbol"): r for r in rows}
out = []
@@ -351,8 +337,6 @@ def _build_overview(request: Request, as_of: date | None = None) -> dict:
from app.services.market_overview_builder import build_market_overview
return build_market_overview(
repo=request.app.state.repo,
quote_service=getattr(request.app.state, "quote_service", None),
depth_service=getattr(request.app.state, "depth_service", None),
as_of=as_of,
)
+2 -3
View File
@@ -50,13 +50,12 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
{"type":"done"}
"""
repo = request.app.state.repo
quote_service = getattr(request.app.state, "quote_service", None)
depth_service = getattr(request.app.state, "depth_service", None)
days = max(7, min(30, req.days))
async def stream_gen():
async for chunk in analyze_rotation_stream(
repo, days, req.focus, quote_service, depth_service,
repo, days, req.focus,
username=request.state.username,
):
yield chunk + "\n"
+14 -122
View File
@@ -278,32 +278,16 @@ def get_cached(
request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果
"""读取策略结果缓存。
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对加入监控的策略算出,
不落盘 (避免与 read_cache mtime 校验冲突), 在此直接叠加覆盖盘后结果
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存
- 盘后缓存 (strategy_cache.json): run_all 写入, 页面秒加载用
"""
data_dir = request.app.state.repo.store.data_dir
cached = strategy_cache.read_cache(data_dir)
if cached is None:
cached = {"as_of": None, "results": {}, "updated_at": None}
# 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果
monitor_engine = getattr(request.app.state, "monitor_engine", None)
if monitor_engine is not None:
realtime_results = monitor_engine.latest_strategy_results()
if realtime_results:
results = dict(cached.get("results") or {})
results.update(realtime_results)
cached = dict(cached)
cached["results"] = results
# 有实时数据时, 以最新时间戳为准
import time as _time
cached["updated_at"] = int(_time.time() * 1000)
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
# 无任何数据 → 返回空标记, 前端据此提示
if not cached.get("results") and cached.get("as_of") is None:
return {"as_of": None, "results": {}, "updated_at": None}
@@ -312,13 +296,14 @@ def get_cached(
@router.get("/market-snapshot")
def market_snapshot(request: Request):
"""最新全市场轻量行情快照,供板块/概念聚合分析使用。"""
def market_snapshot(request: Request, as_of: date | None = None):
"""全市场轻量行情快照(可选指定日期),供板块/概念聚合分析使用。"""
import polars as pl
repo = request.app.state.repo
svc = ScreenerService(repo)
as_of = svc.latest_date()
if as_of is None:
as_of = svc.latest_date()
if not as_of:
return {"as_of": None, "rows": []}
@@ -503,34 +488,8 @@ def limit_ladder(
count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0
count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0
# 双方向 sealed 修正: 减去各自的假涨停(假涨停已归炸板, 不计入涨停数)
depth_svc_global = getattr(request.app.state, "depth_service", None)
fake_up = 0
fake_down = 0
sealed_up_ready = False
sealed_down_ready = False
if depth_svc_global:
up_map = depth_svc_global.get_sealed_map(as_of, is_down=False)
down_map = depth_svc_global.get_sealed_map(as_of, is_down=True)
sealed_up_ready = bool(up_map) and depth_svc_global.is_sealed_ready(as_of)
sealed_down_ready = bool(down_map) and depth_svc_global.is_sealed_ready(as_of)
if up_map:
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
if down_map:
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
count_up = count_up_raw - fake_up if sealed_up_ready else count_up_raw
count_down = count_down_raw - fake_down if sealed_down_ready else count_down_raw
# 双方向 sealed 明细(供前端弹窗同时显示涨跌停)
def _count_sealed(m: dict, ready: bool):
if not m or not ready:
return {"real": 0, "fake": 0, "pending": 0}
real = sum(1 for v in m.values() if v.get("sealed") is True)
fake = sum(1 for v in m.values() if v.get("sealed") is False)
pending = sum(1 for v in m.values() if v.get("sealed") is None)
return {"real": real, "fake": fake, "pending": pending}
sealed_counts_up = _count_sealed(up_map, sealed_up_ready)
sealed_counts_down = _count_sealed(down_map, sealed_down_ready)
count_up = count_up_raw
count_down = count_down_raw
# 加载前一日数据获取 prev consecutive_limit_ups/downs
prev_consec: pl.DataFrame = pl.DataFrame()
@@ -569,69 +528,11 @@ def limit_ladder(
df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0))
# ── 五档 sealed 叠加(独立旁路, 不改 signal_limit_up) ──
# 假涨停(收盘价=涨停价但卖一有量)从 limit 降级为 broken(归炸板视图)
# 真涨停保留 + 附封单量; sealed=null(待确认/降级)保持原状
depth_svc = getattr(request.app.state, "depth_service", None)
sealed_ready = False
sealed_age: float | None = None
if depth_svc:
sealed_map = depth_svc.get_sealed_map(as_of, is_down=is_down)
sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of)
sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None
if sealed_map:
# 构建 sealed 列(symbol → sealed bool, vol)
sym_sealed = {s: v.get("sealed") for s, v in sealed_map.items()}
sym_vol = {s: v.get("vol") for s, v in sealed_map.items()}
# JOIN sealed: 对每只 status=main 的票, 看 sealed 值
sealed_rows = pl.DataFrame({
"symbol": list(sym_sealed.keys()),
"_sealed": list(sym_sealed.values()),
"_sealed_vol": list(sym_vol.values()),
}) if sym_sealed else pl.DataFrame()
if not sealed_rows.is_empty():
df = df.join(sealed_rows, on="symbol", how="left")
# 假涨停(main 状态但 sealed=False)→ 降级为 broken
df = df.with_columns(
pl.when(
(pl.col("status") == status_main)
& pl.col("_sealed").is_not_null()
& (pl.col("_sealed") == False) # noqa: E712
).then(pl.lit(status_broken))
.otherwise(pl.col("status")).alias("status"),
# sealed_status: real/fake/pending/null
pl.when(
(pl.col("status") == status_main)
& (pl.col("_sealed") == True) # noqa: E712
).then(pl.lit("real"))
.when(
(pl.col("_sealed") == False) # noqa: E712
).then(pl.lit("fake"))
.when(
(pl.col("status") == status_main)
& pl.col("_sealed").is_null()
).then(pl.lit("pending"))
.otherwise(None).alias("sealed_status"),
pl.col("_sealed_vol").alias("sealed_vol"),
).drop(["_sealed", "_sealed_vol"])
else:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
else:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
else:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
# Add null columns for sealed_status and sealed_vol (no longer used)
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
# 动态 JOIN 扩展数据
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
@@ -700,15 +601,6 @@ def limit_ladder(
"tiers": tier_list,
"counts": {"up": count_up, "down": count_down},
"counts_raw": {"up": count_up_raw, "down": count_down_raw},
"sealed_ready": sealed_ready,
"sealed_age": round(sealed_age, 0) if sealed_age is not None else None,
"sealed_counts": {
"real": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "real"),
"fake": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "fake"),
"pending": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "pending"),
},
"sealed_counts_up": sealed_counts_up,
"sealed_counts_down": sealed_counts_down,
}
+58 -331
View File
@@ -48,15 +48,50 @@ class TickflowKeyIn(BaseModel):
api_key: str
def _get_ai_config(username: str | None, key: str, default: str = "") -> str:
"""读 AI 配置: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro。"""
if username:
val = secrets_store.load_ai_config(username).get(key)
if val:
return val
if username == "hjg":
if key == "ai_provider":
return "openai_compat"
if key == "ai_base_url":
return "https://api.deepseek.com/v1"
if key == "ai_model":
return "deepseek-chat"
if key == "ai_user_agent":
return (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
return secrets_store.get_ai_config(key, default)
def _get_ai_api_key(username: str | None) -> str:
"""读 AI API Key: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro Key。"""
if username:
val = secrets_store.load_ai_config(username).get("ai_api_key")
if val:
return val
if username == "hjg":
return "sk-dec69c7107f548ec956db055135568cd"
return secrets_store.get_ai_key()
@router.get("")
def get_settings() -> dict:
def get_settings(request: Request) -> dict:
"""返回当前配置概况(Key 脱敏)。"""
from app.config import settings
from app.services import preferences
from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command
username = getattr(request.state, "username", None)
key = secrets_store.get_tickflow_key()
ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider)
ai_provider = _get_ai_config(username, "ai_provider", settings.ai_provider)
ai_api_key = _get_ai_api_key(username)
return {
"mode": tf_client.current_mode(),
"tickflow_api_key_masked": secrets_store.mask(key),
@@ -68,15 +103,15 @@ def get_settings() -> dict:
"extras_caps": extras_caps(),
# 首次使用引导
"onboarding_completed": preferences.get_onboarding_completed(),
# AI 配置
# AI 配置 (用户级优先)
"ai_provider": ai_provider,
"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()),
"ai_base_url": _get_ai_config(username, "ai_base_url", settings.ai_base_url),
"ai_api_key_masked": secrets_store.mask(ai_api_key),
"has_ai_key": bool(ai_api_key),
"ai_configured": ai_configured(ai_provider),
"ai_model": current_ai_model(),
"ai_codex_command": current_codex_command(),
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
"ai_user_agent": _get_ai_config(username, "ai_user_agent", settings.ai_user_agent),
}
@@ -238,11 +273,14 @@ class AiSettingsIn(BaseModel):
@router.post("/ai")
def save_ai_settings(req: AiSettingsIn) -> dict:
"""保存 AI 配置(全部持久化到 secrets.json"""
def save_ai_settings(req: AiSettingsIn, request: Request) -> dict:
"""保存 AI 配置(按用户独立存储)。"""
from app.config import settings
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider, current_codex_command, normalize_codex_command
username = getattr(request.state, "username", None)
target = username if username else "global"
updates: dict = {}
if req.provider:
updates["ai_provider"] = req.provider
@@ -255,10 +293,10 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
updates["ai_api_key"] = req.api_key
settings.ai_api_key = req.api_key
else:
secrets_store.clear("ai_api_key")
secrets_store.clear_ai_config(target, "ai_api_key")
settings.ai_api_key = ""
if req.provider == "codex_cli" and not req.model:
secrets_store.clear("ai_model")
secrets_store.clear_ai_config(target, "ai_model")
settings.ai_model = ""
elif req.model:
updates["ai_model"] = req.model
@@ -270,12 +308,12 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updates["ai_codex_command"] = codex_command
settings.ai_codex_command = codex_command
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
# user_agent 允许清空,故无条件持久化
updates["ai_user_agent"] = req.user_agent
settings.ai_user_agent = req.user_agent
if updates:
secrets_store.save(updates)
secrets_store.save_ai_config(target, updates)
provider = current_ai_provider()
return {
@@ -288,15 +326,15 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
@router.delete("/ai")
def clear_ai_settings() -> dict:
"""一键清空 AI 配置(provider / base_url / api_key / model)
保留 ai_user_agent 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置
"""
def clear_ai_settings(request: Request) -> dict:
"""一键清空当前用户的 AI 配置。"""
from app.config import settings
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
# 同步重置运行时内存(provider 回默认值,其余置空)
username = getattr(request.state, "username", None)
target = username if username else "global"
secrets_store.clear_ai_config(target, "ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
# 同步重置运行时内存
settings.ai_provider = "openai_compat"
settings.ai_base_url = ""
settings.ai_api_key = ""
@@ -308,12 +346,6 @@ def clear_ai_settings() -> dict:
# ===== 偏好设置 =====
def _realtime_allowed() -> bool:
"""当前档位是否允许实时行情(none/free 不允许)。"""
from app.services.quote_service import QuoteService
return QuoteService.is_realtime_allowed()
class MinuteSyncPrefs(BaseModel):
minute_sync_enabled: bool
minute_sync_days: int = 5
@@ -324,17 +356,12 @@ def get_preferences() -> dict:
"""返回用户偏好设置。"""
from app.services import preferences
return {
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
"realtime_allowed": _realtime_allowed(),
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
"minute_sync_days": preferences.get_minute_sync_days(),
"daily_data_provider": preferences.get_daily_data_provider(),
"adj_factor_provider": preferences.get_adj_factor_provider(),
"minute_data_provider": preferences.get_minute_data_provider(),
"realtime_data_provider": preferences.get_realtime_data_provider(),
"realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(),
**preferences.get_realtime_quote_scope(),
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
@@ -343,35 +370,16 @@ def get_preferences() -> dict:
"instruments_schedule": preferences.get_instruments_schedule(),
"enriched_batch_size": preferences.get_enriched_batch_size(),
"index_daily_batch_size": preferences.get_index_daily_batch_size(),
"watchlist_columns": preferences.get_watchlist_columns(),
"screener_result_columns": preferences.get_screener_result_columns(),
"sse_refresh_pages": preferences.get_sse_refresh_pages(),
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
"system_notify_enabled": preferences.get_system_notify_enabled(),
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
"nav_order": preferences.get_nav_order(),
"nav_hidden": preferences.get_nav_hidden(),
"screener_auto_run": preferences.get_screener_auto_run(),
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
"depth_polling_interval": preferences.get_depth_polling_interval(),
"depth_finalize_time": preferences.get_depth_finalize_time(),
"review_schedule": preferences.get_review_schedule(),
"review_push_channels": preferences.get_review_push_channels(),
}
@router.get("/preferences/watchlist-columns")
def get_watchlist_columns() -> dict:
"""返回自选列表列配置。"""
from app.services import preferences
cols = preferences.get_watchlist_columns()
return {"columns": cols}
class NavOrderIn(BaseModel):
nav_order: list[str]
@@ -396,15 +404,6 @@ def update_nav_hidden(req: NavHiddenIn) -> dict:
return {"nav_hidden": saved}
@router.put("/preferences/watchlist-columns")
def update_watchlist_columns(req: dict) -> dict:
"""保存自选列表列配置。"""
from app.services import preferences
columns = req.get("columns", [])
saved = preferences.set_watchlist_columns(columns)
return {"columns": saved}
@router.get("/preferences/screener-result-columns")
def get_screener_result_columns() -> dict:
"""返回策略结果列表列配置。"""
@@ -437,69 +436,6 @@ def update_minute_sync(req: MinuteSyncPrefs) -> dict:
}
class RealtimeQuotesPrefs(BaseModel):
realtime_quotes_enabled: bool
class RealtimeQuoteScopePrefs(BaseModel):
realtime_pull_stock: bool | None = None
realtime_pull_etf: bool | None = None
realtime_pull_index: bool | None = None
realtime_index_mode: str | None = None
realtime_index_symbols: list[str] | None = None
@router.put("/preferences/realtime-quotes")
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
"""保存全局实时行情开关。
none 档无实时行情权限free 档开启自选股实时starter+ 开启全市场实时
前端据此把开关置灰 / 回弹
"""
from app.services import preferences
qs = getattr(request.app.state, "quote_service", None)
allowed = qs.is_realtime_allowed() if qs else True
if req.realtime_quotes_enabled and not allowed:
# 当前档位不允许开启实时行情 — 强制关闭
preferences.save({"realtime_quotes_enabled": False})
if qs:
qs.disable()
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols():
preferences.save({"realtime_quotes_enabled": False})
return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"}
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
if qs:
if req.realtime_quotes_enabled:
qs.enable()
else:
qs.disable()
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
@router.put("/preferences/realtime-quote-scope")
def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict:
"""保存盘中实时行情范围;独立于盘后管道范围。"""
from app.services import preferences
cfg = req.model_dump(exclude_none=True)
return preferences.set_realtime_quote_scope(cfg)
class RealtimeWatchlistPrefs(BaseModel):
symbols: list[str] = []
@router.put("/preferences/realtime-watchlist")
def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict:
"""兼容旧入口;Free 实时标的由自选页前 5 个决定。"""
from app.services import preferences
symbols = preferences.set_realtime_watchlist_symbols(req.symbols)
return {"realtime_watchlist_symbols": symbols}
class IndicesNavPinnedPrefs(BaseModel):
indices_nav_pinned: bool
@@ -513,45 +449,6 @@ def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
return {"indices_nav_pinned": req.indices_nav_pinned}
class RealtimeMonitorConfigIn(BaseModel):
sse_refresh_pages: dict[str, bool] | None = None
strategy_monitor_enabled: bool | None = None
strategy_monitor_ids: list[str] | None = None
sidebar_index_symbols: list[str] | None = None
screener_auto_run: bool | None = None
@router.put("/preferences/realtime-monitor")
def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict:
"""更新实时监控配置。策略监控统一迁移为 MonitorRule,由监控引擎评估。"""
from app.services import preferences
cfg = req.model_dump(exclude_none=True)
result = preferences.set_realtime_monitor_config(cfg)
# 策略监控开关/池变化 → 同步迁移为 type=strategy 规则 + reload 引擎
if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None:
monitor_engine = getattr(request.app.state, "monitor_engine", None)
strategy_engine = getattr(request.app.state, "strategy_engine", None)
data_dir = request.app.state.repo.store.data_dir
if monitor_engine is not None and strategy_engine is not None:
from app.strategy import monitor_rules as mr_store
try:
if preferences.get_strategy_monitor_enabled():
ids = preferences.get_strategy_monitor_ids()
names = {s.id: s.name for s in strategy_engine.list_strategies()}
mr_store.migrate_strategy_monitors(data_dir, ids, names)
else:
# 关闭策略监控: 停用所有策略规则
mr_store.migrate_strategy_monitors(data_dir, [], {})
# reload 规则到引擎
monitor_engine.set_rules(mr_store.load_all(data_dir))
except Exception:
pass
return result
class PipelinePullTypesIn(BaseModel):
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
pipeline_pull_a_share: bool | None = None
@@ -580,97 +477,6 @@ def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
return {"pipeline_index_symbols": symbols}
class QuoteIntervalIn(BaseModel):
interval: float
class SystemNotifyPrefsIn(BaseModel):
enabled: bool
@router.put("/preferences/system-notify")
def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。
纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可
quote_service 在每轮告警评估时读此开关决定是否发系统通知
"""
from app.services import preferences
saved = preferences.set_system_notify_enabled(req.enabled)
return {"system_notify_enabled": saved}
class FeishuWebhookPrefsIn(BaseModel):
url: str
secret: str = ""
@router.put("/preferences/feishu-webhook")
def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
"""飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。
- url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址
- secret: 机器人启用了签名校验时填密钥, 留空表示不验签
"""
from app.services import preferences
from app.services import webhook_adapter
url = (req.url or "").strip()
if url and not webhook_adapter.is_valid_feishu_url(url):
raise HTTPException(
status_code=400,
detail="Webhook 地址非法, 需为飞书自定义机器人地址 "
"(https://open.feishu.cn/open-apis/bot/v2/hook/...)",
)
saved_url = preferences.set_feishu_webhook_url(url)
saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip())
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
class WebhookEnabledDefaultIn(BaseModel):
enabled: bool
@router.put("/preferences/webhook-enabled-default")
def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔
单条规则仍可在规则编辑页独立修改此项
"""
from app.services import preferences
saved = preferences.set_webhook_enabled_default(req.enabled)
return {"webhook_enabled_default": saved}
@router.put("/preferences/quote-interval")
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
"""更新行情轮询间隔。按档位自动 clamp。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return {"interval": req.interval, "min_interval": qs.get_min_interval(), "max_interval": 60.0}
clamped = qs.set_interval(req.interval)
return {
"interval": clamped,
"min_interval": qs.get_min_interval(),
"max_interval": qs.MAX_INTERVAL,
}
@router.get("/preferences/quote-interval")
def get_quote_interval(request: Request) -> dict:
"""获取当前行情轮询间隔和档位限制。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0}
return {
"interval": qs._interval,
"min_interval": qs.get_min_interval(),
"max_interval": qs.MAX_INTERVAL,
}
class TestEndpointIn(BaseModel):
url: str
# 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
@@ -941,85 +747,6 @@ def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict:
return {"index_daily_batch_size": size}
# ── 五档盘口 sealed 配置 ──────────────────────────────
class LimitLadderMonitorIn(BaseModel):
enabled: bool
@router.put("/preferences/limit-ladder-monitor")
def update_limit_ladder_monitor(req: LimitLadderMonitorIn, request: Request) -> dict:
"""连板梯队 5 档监控开关。开启→启动 depth 轮询, 关闭→停止。"""
from app.services import preferences
preferences.save({"limit_ladder_monitor_enabled": req.enabled})
# 立即应用: 启停 depth 轮询线程
depth_svc = getattr(request.app.state, "depth_service", None)
if depth_svc:
depth_svc.apply_monitor_toggle(req.enabled)
return {"limit_ladder_monitor_enabled": req.enabled}
@router.post("/preferences/limit-ladder-monitor/run")
def run_limit_ladder_fix(request: Request) -> dict:
"""立即手动修正一次真假板(拉取五档盘口 + 更新缓存)。需 Pro+。"""
from app.tickflow.capabilities import Cap
capset = request.app.state.capabilities
capset.require(Cap.DEPTH5_BATCH) # 无能力抛 CapabilityDenied(403)
depth_svc = getattr(request.app.state, "depth_service", None)
if not depth_svc:
raise HTTPException(status_code=503, detail="depth 服务未初始化")
return depth_svc.run_once()
class DepthPollingIntervalIn(BaseModel):
interval: float
@router.put("/preferences/depth-polling-interval")
def update_depth_polling_interval(req: DepthPollingIntervalIn, request: Request) -> dict:
"""保存五档盘口盘中轮询间隔(秒)。需 Pro+。"""
from app.tickflow.capabilities import Cap
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
from app.services import preferences
interval = preferences.set_depth_polling_interval(req.interval)
return {"depth_polling_interval": interval}
class DepthFinalizeTimeIn(BaseModel):
hour: int
minute: int
@router.put("/preferences/depth-finalize-time")
def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> dict:
"""保存盘后 sealed 定版时间(范围15:01~18:00)并立即 reschedule。需 Pro+。"""
from app.tickflow.capabilities import Cap
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
from app.services import preferences
sched = preferences.set_depth_finalize_time(req.hour, req.minute)
from apscheduler.triggers.cron import CronTrigger
scheduler = getattr(request.app.state, "scheduler", None)
if scheduler:
scheduler.reschedule_job(
"depth_finalize",
trigger=CronTrigger(
day_of_week="mon-fri",
hour=sched["hour"],
minute=sched["minute"],
timezone="Asia/Shanghai",
),
)
logger.info("depth_finalize rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
return sched
class ReviewScheduleIn(BaseModel):
enabled: bool
hour: int
+8 -5
View File
@@ -164,7 +164,7 @@ async def analyze_stock(request: Request, req: AnalyzeRequest):
data_dir = repo.store.data_dir
async def stream_gen():
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
@@ -191,13 +191,15 @@ class SaveReportRequest(BaseModel):
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。"""
return {"reports": stock_reports.list_reports()}
"""获取当前用户的历史报告(按时间降序,后端已裁剪到上限)。"""
username = getattr(request.state, "username", None)
return {"reports": stock_reports.list_reports(username)}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。"""
username = getattr(request.state, "username", None)
report = stock_reports.save_report({
"symbol": req.symbol,
"name": req.name,
@@ -206,12 +208,13 @@ def save_report(request: Request, req: SaveReportRequest):
"summary": req.summary,
"close": req.close,
"levels": req.levels,
})
}, username)
return {"ok": True, "report": report}
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条报告。"""
ok = stock_reports.delete_report(report_id)
username = getattr(request.state, "username", None)
ok = stock_reports.delete_report(report_id, username)
return {"ok": ok}
+3 -12
View File
@@ -17,7 +17,6 @@ from app.strategy import config as strategy_config
from app.strategy.engine import StrategyEngine, StrategyDef
from app.strategy.ai_generator import AIStrategyGenerator
from app.strategy.prompt_builder import build_step1, build_step2
from app.strategy.monitor import StrategyMonitorService, StrategyAlert
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
@@ -31,13 +30,6 @@ def _get_engine(request: Request) -> StrategyEngine:
return engine
def _get_monitor(request: Request) -> StrategyMonitorService:
mon = getattr(request.app.state, "strategy_monitor", None)
if not mon:
raise HTTPException(status_code=503, detail="策略监控未初始化")
return mon
def _data_dir(request: Request) -> Path:
return request.app.state.repo.store.data_dir
@@ -353,7 +345,7 @@ async def build_strategy(req: BuildRequest, request: Request):
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
try:
result = await gen.generate(prompt)
result = await gen.generate(prompt, username=request.state.username)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return result
@@ -364,7 +356,7 @@ async def build_strategy(req: BuildRequest, request: Request):
async def ai_generate(req: AIGenerateRequest, request: Request):
try:
gen = AIStrategyGenerator()
result = await gen.generate(req.prompt)
result = await gen.generate(req.prompt, username=request.state.username)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
@@ -427,8 +419,7 @@ def delete_strategy(strategy_id: str, request: Request):
# ── 监控 ─────────────────────────────────────────────────────────────
# 注: 策略监控已统一迁移到 MonitorRuleEngine (监控通知页), 旧的 start/stop/status
# 路由已移除。StrategyMonitorService 类保留 (其 _check_signals 被 MonitorRuleEngine 复用)。
# 注: 策略监控已移除。
# ── 热重载 ───────────────────────────────────────────────────────────
-222
View File
@@ -1,222 +0,0 @@
"""自选股 API。"""
from __future__ import annotations
import logging
import math
import time
from datetime import date
import polars as pl
from fastapi import APIRouter, Query, Request
from pydantic import BaseModel
from app.services import watchlist
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
class AddRequest(BaseModel):
symbol: str
note: str = ""
class BatchAddRequest(BaseModel):
symbols: list[str]
note: str = ""
def _with_names(rows: list[dict], request: Request) -> list[dict]:
if not rows:
return rows
try:
df_i = request.app.state.repo.get_instruments()
if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns:
return rows
name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows())
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
except Exception as e: # noqa: BLE001
logger.debug("attach watchlist names failed: %s", e)
return rows
@router.get("")
def list_all(request: Request):
return {"symbols": _with_names(watchlist.list_symbols(), request)}
@router.post("")
def add_one(req: AddRequest, request: Request):
rows = watchlist.add(req.symbol, req.note)
return {"symbols": _with_names(rows, request)}
@router.post("/batch")
def add_batch(req: BatchAddRequest, request: Request):
for sym in req.symbols:
watchlist.add(sym, req.note)
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)}
@router.post("/{symbol}/top")
def move_one_to_top(symbol: str, request: Request):
rows = watchlist.move_to_top(symbol)
return {"symbols": _with_names(rows, request)}
@router.delete("/{symbol}")
def remove_one(symbol: str, request: Request):
rows = watchlist.remove(symbol)
return {"symbols": _with_names(rows, request)}
@router.delete("")
def clear_all():
"""清空自选列表。"""
count = watchlist.clear()
return {"removed": count}
# 自选页需要的列
_WATCHLIST_COLS = [
"symbol", "close", "change_pct", "change_amount", "amount",
"turnover_rate",
"amplitude", "annual_vol_20d",
"vol_ratio_5d",
"ma5", "ma10", "ma20", "ma60",
"vol_ma5", "vol_ma10",
"high_60d", "low_60d",
"rsi_6", "rsi_14", "rsi_24",
"macd_dif", "macd_dea", "macd_hist",
"kdj_k", "kdj_d", "kdj_j",
"boll_upper", "boll_lower",
"atr_14",
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
"consecutive_limit_ups", "consecutive_limit_downs",
"signal_limit_up", "signal_limit_down", "signal_volume_surge",
"signal_ma_golden_5_20", "signal_macd_golden", "signal_n_day_high",
"signal_boll_breakout_upper", "signal_ma20_breakout",
"signal_ma_dead_5_20", "signal_macd_dead", "signal_n_day_low",
"signal_boll_breakdown_lower", "signal_ma20_breakdown",
]
@router.get("/enriched")
def watchlist_enriched(
request: Request,
ext_columns: str | None = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
):
"""自选股 enriched 数据 — 直接从 enriched 最新日读取, 无即时计算。
ext_columns 参数示例: "industry_rating.score,fund_flow.net_inflow"
会动态 LEFT JOIN 对应的 ext_{config_id} DuckDB view
"""
t0 = time.perf_counter()
repo = request.app.state.repo
symbols = [r["symbol"] for r in watchlist.list_symbols()]
if not symbols:
return {"rows": [], "as_of": None, "elapsed_ms": 0}
df_e, cache_date = repo.get_enriched_latest()
if df_e.is_empty():
return {"rows": [], "as_of": None, "elapsed_ms": 0}
# 按 symbol 过滤
df = df_e.filter(pl.col("symbol").is_in(symbols))
if df.is_empty():
return {"rows": [], "as_of": str(cache_date) if cache_date else None, "elapsed_ms": 0}
# JOIN instruments 取 name + float_shares
df_i = repo.get_instruments()
if not df_i.is_empty() and "name" in df_i.columns:
inst_cols = [c for c in ["symbol", "name", "float_shares"] if c in df_i.columns]
df = df.join(df_i.select(inst_cols), on="symbol", how="left")
# 选择内置需要的列
keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares"] if c in df.columns]
df = df.select(keep)
# 动态 JOIN 扩展数据表
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
if ext_specs:
db = repo.store.db
data_dir = repo.store.data_dir
from app.services.ext_data import ExtConfigStore
from app.api.ext_data import _read_ext_dataframe
ext_store = ExtConfigStore(data_dir)
configs = {c.id: c for c in ext_store.load_all()}
for config_id, field_name in ext_specs:
view_name = f"ext_{config_id}"
ext_col_name = f"{config_id}__{field_name}"
try:
# 扩展时序数据必须只取最新分区;否则一个 symbol 会按历史分区数被 JOIN 放大。
cfg = configs.get(config_id)
if cfg:
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
else:
ext_df = pl.from_arrow(db.query(
f"SELECT symbol, \"{field_name}\" FROM {view_name}"
).arrow())
if not ext_df.is_empty() and "symbol" in ext_df.columns:
ext_df = (
ext_df
.select(["symbol", field_name])
.unique(subset=["symbol"], keep="last")
.rename({field_name: ext_col_name})
)
df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left")
except Exception:
# view 不存在或字段不存在,尝试直接读 parquet
cfg = configs.get(config_id)
if cfg:
try:
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
ext_df = (
ext_df
.select(["symbol", field_name])
.unique(subset=["symbol"], keep="last")
.rename({field_name: ext_col_name})
)
df = df.join(ext_df, on="symbol", how="left")
except Exception as e2:
logger.debug("ext join fallback failed for %s.%s: %s", config_id, field_name, e2)
# sanitize NaN / Inf
float_cols = [c for c in df.columns if df[c].dtype.is_float()]
if float_cols:
df = df.with_columns([
pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite())
.then(None)
.otherwise(pl.col(c))
.alias(c)
for c in float_cols
])
# 按自选添加顺序(新加的在前)重排行
order_map = {s: i for i, s in enumerate(symbols)}
df = df.with_columns(pl.col("symbol").map_elements(lambda s: order_map.get(s, len(symbols)), return_dtype=pl.Int32).alias("_sort_order"))
df = df.sort("_sort_order").drop("_sort_order")
rows = df.to_dicts()
elapsed = (time.perf_counter() - t0) * 1000
return {"rows": rows, "as_of": str(cache_date) if cache_date else None, "elapsed_ms": elapsed}
def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
"""解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]"""
result = []
for part in ext_columns.split(","):
part = part.strip()
if "." not in part:
continue
config_id, field_name = part.split(".", 1)
config_id = config_id.strip()
field_name = field_name.strip()
if config_id and field_name:
result.append((config_id, field_name))
return result
-4
View File
@@ -1,4 +0,0 @@
"""回测模块 — 因子回测 + 策略回测 + 信号回测。
架构: BacktestEngine (共享) FactorBacktestService / StrategyBacktestService
"""
File diff suppressed because it is too large Load Diff
-480
View File
@@ -1,480 +0,0 @@
"""因子回测服务 — IC/IR 分析 + 分层回测 + 多空组合。
Polars 向量化实现 pandas 依赖
"""
from __future__ import annotations
import logging
import time
import uuid
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Literal
import numpy as np
import polars as pl
from app.backtest.engine import BacktestEngine
logger = logging.getLogger(__name__)
# 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标)
FACTOR_COLUMNS: list[dict] = [
{"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5日涨跌幅,正值表示上涨趋势"},
{"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10日涨跌幅,中短期趋势指标"},
{"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "月度涨跌幅,常用因子"},
{"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30日涨跌幅"},
{"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "季度涨跌幅,中期动量"},
{"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标,敏感度高"},
{"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标,经典周期"},
{"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"},
{"id": "annual_vol_20d","label": "20日波动率", "group": "波动率", "desc": "20日年化波动率"},
{"id": "atr_14", "label": "ATR(14)", "group": "波动率", "desc": "14日平均真实波幅"},
{"id": "vol_ratio_5d", "label": "量比(5日)", "group": "量价", "desc": "当日成交量 / 5日均量"},
{"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "当日换手率"},
{"id": "macd_hist", "label": "MACD柱", "group": "趋势", "desc": "MACD柱状图值"},
{"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"},
{"id": "change_pct", "label": "日涨跌幅", "group": "基础", "desc": "当日涨跌幅"},
{"id": "amplitude", "label": "日振幅", "group": "基础", "desc": "当日振幅 (最高-最低)/昨收"},
]
FACTOR_WARMUP_DAYS = 120
@dataclass
class FactorConfig:
factor_name: str
symbols: list[str] | None
start: date
end: date
n_groups: int = 5
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
weight: Literal["equal", "factor_weight"] = "equal"
fees_pct: float = 0.0002
slippage_bps: float = 5.0
@dataclass
class GroupStats:
group: int
label: str
total_return: float
annual_return: float
max_drawdown: float
sharpe: float
win_rate: float
@dataclass
class FactorResult:
run_id: str
config: dict
# IC 分析
ic_mean: float | None = None
ic_std: float | None = None
ir: float | None = None
ic_win_rate: float | None = None
ic_series: list[dict] = field(default_factory=list)
# 分层
group_stats: list[dict] = field(default_factory=list)
group_nav: list[dict] = field(default_factory=list)
# 多空
long_short_stats: dict = field(default_factory=dict)
long_short_nav: list[dict] = field(default_factory=list)
# 元信息
elapsed_ms: float = 0.0
n_symbols: int = 0
n_dates: int = 0
error: str | None = None
class FactorBacktestService:
def __init__(self, engine: BacktestEngine) -> None:
self.engine = engine
def run(self, config: FactorConfig) -> FactorResult:
t0 = time.perf_counter()
run_id = uuid.uuid4().hex[:10]
def _err(msg: str) -> FactorResult:
return FactorResult(
run_id=run_id,
config=self._config_to_dict(config),
error=msg,
elapsed_ms=(time.perf_counter() - t0) * 1000,
)
# 加载基础面板: 当前 enriched parquet 只持久化基础列, 指标因子可能需要运行时计算。
panel_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "turnover_rate"]
if config.factor_name not in panel_columns:
panel_columns.append(config.factor_name)
load_start = config.start
if config.factor_name not in {"turnover_rate"}:
load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS)
panel = self.engine.load_panel(
config.symbols,
load_start,
config.end,
columns=panel_columns,
)
if panel.is_empty():
return _err("无数据,请检查日期范围或先运行盘后管道")
factor_col = config.factor_name
if factor_col not in panel.columns:
panel = self._compute_missing_factor(panel, factor_col)
if factor_col not in panel.columns:
return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算")
if "close" not in panel.columns:
return _err("enriched 数据缺少收盘价 close")
panel = panel.select(["symbol", "date", "close", factor_col])
panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end))
# 过滤有效行
panel = panel.filter(
pl.col(factor_col).is_not_null()
& pl.col("close").is_not_null()
& (pl.col("close") > 0)
)
if panel.is_empty():
return _err("过滤后无有效数据")
n_symbols = panel["symbol"].n_unique()
n_dates = panel["date"].n_unique()
# 计算下期收益
# 根据调仓频率计算不同周期的 forward return
if config.rebalance == "daily":
panel = panel.with_columns(
(pl.col("close").shift(-1).over("symbol") / pl.col("close") - 1)
.alias("_next_return")
)
else:
# weekly/monthly: 计算到下个调仓日的收益
panel = self._calc_period_return(panel, config.rebalance)
# ── 1. IC 分析 ──
ic_df = self._calc_ic(panel, factor_col)
ic_series = [
{"date": str(row["date"]), "ic": round(float(row["ic"]), 4)}
for row in ic_df.iter_rows(named=True)
if row["ic"] is not None and not np.isnan(float(row["ic"]))
]
ic_values = [r["ic"] for r in ic_series]
ic_mean = float(np.mean(ic_values)) if ic_values else None
ic_std = float(np.std(ic_values)) if ic_values else None
ir = (ic_mean / ic_std) if (ic_mean is not None and ic_std and ic_std > 1e-8) else None
ic_win_rate = (sum(1 for v in ic_values if v > 0) / len(ic_values)) if ic_values else None
# ── 2. 分层回测 ──
panel = self._add_groups(panel, factor_col, config.n_groups)
group_nav = self._calc_group_nav(panel, config)
group_stats = self._calc_group_stats(group_nav, config.start, config.end)
# ── 3. 多空组合 ──
long_short_nav, long_short_stats = self._calc_long_short(group_nav, config)
elapsed = (time.perf_counter() - t0) * 1000
return FactorResult(
run_id=run_id,
config=self._config_to_dict(config),
ic_mean=round(ic_mean, 4) if ic_mean is not None else None,
ic_std=round(ic_std, 4) if ic_std is not None else None,
ir=round(ir, 4) if ir is not None else None,
ic_win_rate=round(ic_win_rate, 4) if ic_win_rate is not None else None,
ic_series=ic_series,
group_stats=group_stats,
group_nav=group_nav,
long_short_stats=long_short_stats,
long_short_nav=long_short_nav,
elapsed_ms=round(elapsed, 1),
n_symbols=n_symbols,
n_dates=n_dates,
)
@staticmethod
def _compute_missing_factor(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame:
required = {"symbol", "date", "open", "high", "low", "close", "volume"}
if not required.issubset(panel.columns):
missing = sorted(required - set(panel.columns))
logger.warning("factor %s cannot be computed, missing columns: %s", factor_col, missing)
return panel
from app.indicators.pipeline import compute_indicators
computed = compute_indicators(panel)
if factor_col not in computed.columns:
return panel
return computed.select(["symbol", "date", "close", factor_col])
# ── IC 计算 ──
@staticmethod
def _calc_ic(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame:
"""计算截面 Rank IC (因子值 rank vs 下期收益 rank 的相关系数)。"""
return (
panel.filter(pl.col("_next_return").is_not_null())
.group_by("date")
.agg(
pl.corr(
pl.col(factor_col).rank(method="random"),
pl.col("_next_return").rank(method="random"),
).alias("ic")
)
.sort("date")
)
# ── 调仓期收益 ──
@staticmethod
def _calc_period_return(panel: pl.DataFrame, rebalance: str) -> pl.DataFrame:
"""计算到下个调仓日的收益。
weekly: 下周一的 open / 今日 close - 1
monthly: 下月首个交易日的 open / 今日 close - 1
只在调仓日标记行有效其他行为 null
"""
import datetime as _dt
all_dates = sorted(panel["date"].unique().to_list())
date_set = set(all_dates)
if rebalance == "weekly":
# 调仓日 = 每周一
rebalance_dates = set()
for d in all_dates:
if hasattr(d, "weekday"):
wd = d.weekday()
else:
wd = _dt.date.fromisoformat(str(d)).weekday()
if wd == 0: # Monday
rebalance_dates.add(d)
else: # monthly
# 调仓日 = 每月首个交易日
seen_months: set[str] = set()
rebalance_dates = set()
for d in sorted(all_dates):
m = str(d)[:7] # "YYYY-MM"
if m not in seen_months:
seen_months.add(m)
rebalance_dates.add(d)
if not rebalance_dates:
panel = panel.with_columns(pl.lit(None).cast(pl.Float64).alias("_next_return"))
return panel
# 对每个调仓日,找到下一个调仓日
sorted_rebalance = sorted(rebalance_dates)
next_rebalance_map: dict = {}
for i, d in enumerate(sorted_rebalance):
if i + 1 < len(sorted_rebalance):
next_rebalance_map[d] = sorted_rebalance[i + 1]
# 最后一个调仓日没有下一个,不计算收益
# 构建 (date, symbol) → next_rebalance_date 的 close 价格映射
# 简化: 用下个调仓日的 close / 当前 close
panel = panel.sort(["symbol", "date"])
dates_col = panel["date"].to_list()
close_col = panel["close"].to_list()
symbol_col = panel["symbol"].to_list()
# 先找下个调仓日的 close
# 建立 (date, symbol) → close 的快速查找
price_map: dict[tuple, float] = {}
for i in range(len(dates_col)):
price_map[(str(dates_col[i]), symbol_col[i])] = close_col[i]
next_returns = [None] * len(panel)
for i in range(len(panel)):
d = dates_col[i]
d_val = d if isinstance(d, _dt.date) else _dt.date.fromisoformat(str(d))
if d not in rebalance_dates:
continue
next_d = next_rebalance_map.get(d)
if next_d is None:
continue
next_d_str = str(next_d)[:10]
d_str = str(d)[:10]
sym = symbol_col[i]
next_close = price_map.get((next_d_str, sym))
cur_close = close_col[i]
if next_close is not None and cur_close and cur_close > 0:
next_returns[i] = (next_close / cur_close - 1.0)
panel = panel.with_columns(
pl.Series("_next_return", next_returns, dtype=pl.Float64)
)
return panel
# ── 分组 ──
@staticmethod
def _add_groups(panel: pl.DataFrame, factor_col: str, n_groups: int) -> pl.DataFrame:
"""截面分位数分组。"""
return panel.with_columns(
pl.col(factor_col)
.qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)])
.over("date")
.alias("_group")
)
# ── 分组净值 ──
@staticmethod
def _calc_group_nav(panel: pl.DataFrame, config: FactorConfig) -> list[dict]:
"""计算分组净值曲线 — 只在调仓日更新净值。"""
# 只保留有下期收益的行 (= 调仓日)
group_ret = (
panel.filter(pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null())
.group_by(["date", "_group"])
.agg(pl.col("_next_return").mean().alias("group_return"))
)
# pivot: date × group
pivot = group_ret.pivot(index="date", columns="_group", values="group_return").sort("date")
if pivot.is_empty():
return []
group_cols = [c for c in pivot.columns if c != "date"]
# 累乘净值曲线
result: list[dict] = []
nav_values: dict[str, float] = {c: 1.0 for c in group_cols}
for row in pivot.iter_rows(named=True):
entry: dict = {"date": str(row["date"])[:10]}
for c in group_cols:
ret = float(row[c]) if row[c] is not None else 0.0
nav_values[c] *= (1 + ret)
entry[c] = round(nav_values[c], 4)
result.append(entry)
return result
# ── 分组统计 ──
@staticmethod
def _calc_group_stats(
group_nav: list[dict], start: date, end: date,
) -> list[dict]:
if not group_nav:
return []
group_cols = [k for k in group_nav[0] if k != "date"]
n_days = max((end - start).days, 1)
years = n_days / 365.25
stats = []
for i, c in enumerate(sorted(group_cols)):
values = [r[c] for r in group_nav if r.get(c) is not None]
if not values:
continue
total_return = values[-1] - 1.0
annual_return = (values[-1]) ** (1 / max(years, 0.01)) - 1 if values[-1] > 0 else 0.0
# 最大回撤
peak = 1.0
max_dd = 0.0
for v in values:
peak = max(peak, v)
dd = (v - peak) / peak
max_dd = min(max_dd, dd)
# 日收益序列
daily_rets = []
for j in range(1, len(values)):
if values[j - 1] > 0:
daily_rets.append(values[j] / values[j - 1] - 1)
# 夏普
if daily_rets:
arr = np.array(daily_rets)
sharpe = float(np.mean(arr) / np.std(arr)) * np.sqrt(252) if np.std(arr) > 0 else 0.0
win_rate = float(np.mean(arr > 0))
else:
sharpe = 0.0
win_rate = 0.0
stats.append({
"group": i + 1,
"label": c,
"total_return": round(total_return, 4),
"annual_return": round(annual_return, 4),
"max_drawdown": round(max_dd, 4),
"sharpe": round(sharpe, 2),
"win_rate": round(win_rate, 4),
})
return stats
# ── 多空组合 ──
@staticmethod
def _calc_long_short(
group_nav: list[dict], config: FactorConfig,
) -> tuple[list[dict], dict]:
"""多空组合: 做多最高组 + 做空最低组。"""
if not group_nav:
return [], {}
group_cols = sorted([k for k in group_nav[0] if k != "date"])
if len(group_cols) < 2:
return [], {}
top_col = group_cols[-1] # Q5 (最高)
bottom_col = group_cols[0] # Q1 (最低)
# 独立计算 top 和 bottom 的日收益,然后合成
ls_value = 1.0
prev_top = 1.0
prev_bot = 1.0
peak = 1.0
max_dd = 0.0
ls_nav: list[dict] = []
for row in group_nav:
top_nav = float(row.get(top_col, 1.0)) if row.get(top_col) is not None else 1.0
bot_nav = float(row.get(bottom_col, 1.0)) if row.get(bottom_col) is not None else 1.0
# top 组收益 (做多)
top_ret = (top_nav / prev_top - 1) if prev_top > 0 else 0.0
# bottom 组收益 (做空 = 取反)
bot_ret = -(bot_nav / prev_bot - 1) if prev_bot > 0 else 0.0
# 多空组合收益
ls_ret = (top_ret + bot_ret) / 2 # 各分配 50% 资金
ls_value *= (1 + ls_ret)
prev_top = top_nav
prev_bot = bot_nav
peak = max(peak, ls_value)
dd = (ls_value - peak) / peak if peak > 0 else 0.0
max_dd = min(max_dd, dd)
ls_nav.append({"date": row["date"], "value": round(ls_value, 4)})
total_ret = ls_value - 1.0
ls_stats = {
"total_return": round(total_ret, 4),
"max_drawdown": round(max_dd, 4),
"top_group": top_col,
"bottom_group": bottom_col,
}
return ls_nav, ls_stats
@staticmethod
def _config_to_dict(c: FactorConfig) -> dict:
return {
"factor_name": c.factor_name,
"symbols": c.symbols,
"start": str(c.start),
"end": str(c.end),
"n_groups": c.n_groups,
"rebalance": c.rebalance,
"weight": c.weight,
"fees_pct": c.fees_pct,
"slippage_bps": c.slippage_bps,
}
-721
View File
@@ -1,721 +0,0 @@
"""策略回测服务 — 复用 StrategyDef 体系做全周期回测。
核心优化: 向量化 filter_fn不逐日调用 StrategyEngine.run()
"""
from __future__ import annotations
import logging
import time
import uuid
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Callable, Literal
import numpy as np
import polars as pl
from app.backtest.engine import BacktestEngine, MatcherConfig, SimResult
from app.strategy.engine import StrategyEngine, StrategyDef
logger = logging.getLogger(__name__)
BENCHMARK_SYMBOL = "000001.SH"
@dataclass
class StrategyBacktestConfig:
strategy_id: str
symbols: list[str] | None
start: date
end: date
params: dict | None = None
overrides: dict | None = None
# matching 为向后兼容入口; 显式传 entry_fill/exit_fill 时以二者为准。
matching: Literal["close_t", "open_t+1"] = "open_t+1"
entry_fill: Literal["close_t", "open_t+1"] | None = None
exit_fill: Literal["close_t", "open_t+1"] | None = None
fees_pct: float = 0.0002
slippage_bps: float = 5.0
max_positions: int = 10
max_exposure_pct: float = 1.0
initial_capital: float = 1_000_000.0
position_sizing: Literal["equal", "score_weight"] = "equal"
mode: Literal["position", "full"] = "position"
holding_days: int = 5
def __post_init__(self) -> None:
if self.entry_fill is None:
self.entry_fill = self.matching
if self.exit_fill is None:
self.exit_fill = self.matching
@dataclass
class StrategyBacktestResult:
run_id: str
config: dict
stats: dict = field(default_factory=dict)
equity_curve: list[dict] = field(default_factory=list)
drawdown_curve: list[dict] = field(default_factory=list)
benchmark_curve: list[dict] = field(default_factory=list)
trades: list[dict] = field(default_factory=list)
per_symbol_stats: list[dict] = field(default_factory=list)
strategy_info: dict = field(default_factory=dict)
elapsed_ms: float = 0.0
error: str | None = None
class StrategyBacktestService:
def __init__(
self,
engine: BacktestEngine,
strategy_engine: StrategyEngine,
) -> None:
self.engine = engine
self.strategy_engine = strategy_engine
def run(
self,
config: StrategyBacktestConfig,
progress_cb: "Callable[[dict], None] | None" = None,
cancel_event: "threading.Event | None" = None,
) -> StrategyBacktestResult:
t0 = time.perf_counter()
run_id = uuid.uuid4().hex[:10]
def _err(msg: str) -> StrategyBacktestResult:
return StrategyBacktestResult(
run_id=run_id,
config=self._config_to_dict(config),
error=msg,
elapsed_ms=(time.perf_counter() - t0) * 1000,
)
# 获取策略定义
try:
s = self.strategy_engine.get(config.strategy_id)
except ValueError as e:
return _err(str(e))
params = self._normalize_params(config.params or {}, s)
overrides = config.overrides or {}
basic_filter = self._effective_basic_filter(s, overrides)
entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss)
take_profit = self._normalize_pct(
self._override_value(overrides, "take_profit", getattr(s, "take_profit", None)),
0.01,
5.0,
)
trailing_stop = self._normalize_pct(
self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)),
0.005,
0.5,
)
trailing_take_profit_activate = self._normalize_pct(
self._override_value(overrides, "trailing_take_profit_activate", getattr(s, "trailing_take_profit_activate", None)),
0.01,
2.0,
)
trailing_take_profit_drawdown = self._normalize_pct(
self._override_value(overrides, "trailing_take_profit_drawdown", getattr(s, "trailing_take_profit_drawdown", None)),
0.005,
0.5,
)
if trailing_take_profit_activate is not None and trailing_take_profit_drawdown is not None:
trailing_take_profit_drawdown = min(trailing_take_profit_drawdown, trailing_take_profit_activate)
max_hold_days = self._override_value(overrides, "max_hold_days", s.max_hold_days)
score_min, score_max = self._normalize_score_range(
overrides.get("score_min"),
overrides.get("score_max"),
)
timing_ms: dict[str, float] = {}
# 加载面板 (含 warmup + 全量指标 + 信号)。warmup 只用于指标/形态计算, 不参与正式交易。
warmup_days = max(120, int(max(s.lookback_days or 1, 1) * 1.5))
load_start = config.start - timedelta(days=warmup_days)
# 全量模式: entries 只在正式区间触发, exits 需要 end 之后的尾部数据继续执行策略卖点。
# 若策略有 max_hold_days, 用它决定尾部窗口;否则 holding_days 只作为兜底观察上限。
full_horizon_days = int(max_hold_days or config.holding_days or 5)
full_horizon_days = max(full_horizon_days, 1)
load_end = config.end
if config.mode == "full":
fwd_buffer = full_horizon_days + 5 # 多取几天, 容错停牌缺口/open_t+1
load_end = config.end + timedelta(days=fwd_buffer * 2) # 日历日放宽, 确保覆盖 N 个交易日
t_load = time.perf_counter()
panel = self.engine.load_panel(config.symbols, load_start, load_end)
timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1)
if panel.is_empty():
return _err("无数据,请检查日期范围或先运行盘后管道")
formal_range = self._date_range_mask(panel, config.start, config.end)
if not formal_range.any():
return _err("正式回测区间内无数据")
t_signal = time.perf_counter()
# basic_filter 只影响买入候选, 不能删除行情 panel, 否则持仓 mark / 卖出 / full forward return 都会失真。
basic_mask = pl.Series("_basic", [True] * len(panel), dtype=pl.Boolean)
if basic_filter and basic_filter.get("enabled", True):
expr = StrategyEngine._basic_filter_expr(panel, basic_filter)
if expr is not None:
try:
basic_mask = panel.select(expr.alias("_basic"))["_basic"].fill_null(False).cast(pl.Boolean)
except Exception as e: # noqa: BLE001
logger.warning("basic_filter mask failed: %s", e)
return _err(f"基础过滤计算失败: {e}")
# 策略候选层用于评分归一化;entry_signals 只是买点层, 不参与 score universe。
candidate_filter_mask = self._build_candidate_filter_mask(panel, s, params)
candidate_mask = basic_mask & candidate_filter_mask
panel = self._apply_score(panel, s, overrides, universe_mask=candidate_mask)
entry_mask = self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
entry_mask = entry_mask & formal_range
raw_exit_mask = self._build_signal_mask(panel, exit_signals, "_exit")
exit_mask = raw_exit_mask & (self._date_range_mask(panel, config.start, load_end) if config.mode == "full" else formal_range)
timing_ms["signals_score"] = round((time.perf_counter() - t_signal) * 1000, 1)
if not entry_mask.any():
return _err("在指定区间内未产生买入信号")
# warmup 之后才交给撮合;full mode 保留 end 之后前瞻段用于 shift(-N)。
sim_end = load_end if config.mode == "full" else config.end
sim_range = self._date_range_mask(panel, config.start, sim_end)
sim_panel = panel.filter(sim_range)
sim_entry_mask = entry_mask.filter(sim_range)
sim_exit_mask = exit_mask.filter(sim_range)
if sim_panel.is_empty():
return _err("正式回测区间内无数据")
t_sim = time.perf_counter()
matcher_config = MatcherConfig(
matching=config.matching,
entry_fill=config.entry_fill,
exit_fill=config.exit_fill,
fees_pct=config.fees_pct,
slippage_bps=config.slippage_bps,
stop_loss_pct=stop_loss,
take_profit_pct=take_profit,
trailing_stop_pct=trailing_stop,
trailing_take_profit_activate_pct=trailing_take_profit_activate,
trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown,
max_hold_days=max_hold_days,
max_positions=config.max_positions,
max_exposure_pct=config.max_exposure_pct,
score_min=score_min,
score_max=score_max,
initial_capital=config.initial_capital,
position_sizing=config.position_sizing,
)
# 撮合 — full 为全候选独立执行;position 为账户级仓位模拟。
if config.mode == "full":
result = self.engine.simulate_independent_candidates(
sim_panel,
sim_entry_mask,
sim_exit_mask,
matcher_config,
progress_cb,
cancel_event,
)
else:
result = self.engine.simulate_portfolio(sim_panel, sim_entry_mask, sim_exit_mask, matcher_config, progress_cb, cancel_event)
timing_ms["simulate"] = round((time.perf_counter() - t_sim) * 1000, 1)
# 检查是否被取消
if cancel_event is not None and cancel_event.is_set():
return StrategyBacktestResult(
run_id=run_id,
config=self._config_to_dict(config),
error="cancelled",
elapsed_ms=round((time.perf_counter() - t0) * 1000, 1),
)
if result.stats.get("error"):
return _err(result.stats["error"])
timing_ms["total"] = round((time.perf_counter() - t0) * 1000, 1)
result.stats["timing_ms"] = timing_ms
result.stats["panel_rows"] = int(sim_panel.height)
benchmark_curve = self._build_benchmark_curve(config.start, config.end)
# 构建策略信息
strategy_info = {
"id": s.meta.get("id", config.strategy_id),
"name": s.meta.get("name", config.strategy_id),
"description": s.meta.get("description", ""),
"entry_signals": entry_signals,
"exit_signals": exit_signals,
"stop_loss": stop_loss,
"take_profit": take_profit,
"trailing_stop": trailing_stop,
"trailing_take_profit_activate": trailing_take_profit_activate,
"trailing_take_profit_drawdown": trailing_take_profit_drawdown,
"max_hold_days": max_hold_days,
"full_horizon_days": full_horizon_days,
"score_min": score_min,
"score_max": score_max,
"source": s.source,
}
elapsed = (time.perf_counter() - t0) * 1000
return StrategyBacktestResult(
run_id=run_id,
config=self._config_to_dict(config),
stats=result.stats,
equity_curve=result.equity_curve,
drawdown_curve=result.drawdown_curve,
benchmark_curve=benchmark_curve,
trades=[self._trade_to_dict(t) for t in result.trades],
per_symbol_stats=result.per_symbol_stats,
strategy_info=strategy_info,
elapsed_ms=round(elapsed, 1),
)
# ── 全量模拟 (选股能力统计, 不建组合不算净值) ──
def _run_full_simulation(
self,
panel: pl.DataFrame,
entry_mask: pl.Series,
holding_days: int,
) -> SimResult:
"""对 entry_mask 命中的全部候选, 算持有 N 天后的前瞻收益统计。
不受 max_positions/资金约束, 反映策略选股能力本身
equity_curve 复用为"累计日均超额收益曲线"(基准归零)
"""
n = holding_days if holding_days and holding_days > 0 else 5
df = panel.with_columns([
entry_mask.cast(pl.Boolean).alias("_is_candidate"),
(pl.col("close").shift(-n).over("symbol") / pl.col("close") - 1).alias("_fwd_return"),
]).filter(
pl.col("_is_candidate")
& pl.col("_fwd_return").is_not_null()
& pl.col("_fwd_return").is_not_nan()
)
if df.is_empty():
return self.engine._empty_result()
fwd = df["_fwd_return"].to_numpy()
wins = fwd[fwd > 0]
losses = fwd[fwd <= 0]
avg_win = float(wins.mean()) if wins.size else 0.0
avg_loss = abs(float(losses.mean())) if losses.size else 0.0
# 按日聚合: 当日候选的平均前瞻收益
daily = (
df.group_by("date").agg(
pl.col("_fwd_return").mean().alias("avg_ret"),
pl.col("_fwd_return").count().alias("n_cand"),
).sort("date")
)
# 累计超额曲线: 每日复利平均收益 (基准归零, 故 equity 即累计策略收益)
equity_curve: list[dict] = []
equity = 1.0
peak = 1.0
drawdown_curve: list[dict] = []
for row in daily.iter_rows(named=True):
ret = float(row["avg_ret"] or 0.0)
equity *= (1 + ret)
peak = max(peak, equity)
dd = (equity - peak) / peak if peak > 0 else 0.0
d_str = str(row["date"])[:10]
equity_curve.append({
"date": d_str,
"value": round(equity, 4),
"positions": int(row["n_cand"]),
})
drawdown_curve.append({"date": d_str, "value": round(dd, 4)})
# 同期上证收益 (用 benchmark close 算)
benchmark_curve = self._build_benchmark_curve(
daily["date"].min(), daily["date"].max()
)
benchmark_return = 0.0
if benchmark_curve:
closes = [b["close"] for b in benchmark_curve if b.get("close")]
if len(closes) >= 2 and closes[0] > 0:
benchmark_return = closes[-1] / closes[0] - 1
total_return = equity - 1.0
max_dd = min((d["value"] for d in drawdown_curve), default=0.0)
# 日收益序列算 Sharpe (年化)
daily_rets = daily["avg_ret"].to_numpy()
sharpe = (
float(daily_rets.mean() / daily_rets.std() * np.sqrt(252))
if daily_rets.size > 1 and daily_rets.std() > 0 else 0.0
)
# 收益分布直方图: 按 [-20%, +20%] 分 21 档 (每档 2%), 超出归入首尾档
lo, hi, nbins = -0.20, 0.20, 20
clipped = np.clip(fwd, lo, hi)
counts, edges = np.histogram(clipped, bins=nbins, range=(lo, hi))
dist = [
{
"range": f"{(edges[i]*100):+.0f}~{(edges[i+1]*100):+.0f}%",
"count": int(counts[i]),
"ratio": round(float(counts[i] / fwd.size), 4) if fwd.size else 0.0,
}
for i in range(nbins)
]
stats = {
"mode": "full",
"n_candidates": int(fwd.size),
"n_days": int(daily.height),
"avg_daily_candidates": round(float(daily["n_cand"].mean()), 1),
"avg_return": round(float(fwd.mean()), 4),
"median_return": round(float(np.median(fwd)), 4),
"win_rate": round(float(wins.size / fwd.size), 4) if fwd.size else 0.0,
"profit_factor": round(avg_win / avg_loss, 2) if avg_loss > 0 else None,
"best": round(float(fwd.max()), 4),
"worst": round(float(fwd.min()), 4),
"total_return": round(float(total_return), 4),
"max_drawdown": round(float(max_dd), 4),
"sharpe": round(sharpe, 2),
"benchmark_return": round(float(benchmark_return), 4),
"excess": round(float(total_return - benchmark_return), 4),
"return_distribution": dist,
}
return SimResult(
equity_curve=equity_curve,
drawdown_curve=drawdown_curve,
trades=[],
per_symbol_stats=[],
stats=stats,
)
# ── 向量化信号生成 ──
@staticmethod
def _date_range_mask(panel: pl.DataFrame, start: date, end: date) -> pl.Series:
return panel.select(
((pl.col("date") >= start) & (pl.col("date") <= end)).alias("_range")
)["_range"].fill_null(False).cast(pl.Boolean)
def _build_candidate_filter_mask(
self,
panel: pl.DataFrame,
s: StrategyDef,
params: dict,
) -> pl.Series:
"""生成策略候选层 mask。filter_history/filter 决定候选池, 不包含 entry_signals。"""
false_mask = pl.Series("_candidate_filter", [False] * len(panel), dtype=pl.Boolean)
true_mask = pl.Series("_candidate_filter", [True] * len(panel), dtype=pl.Boolean)
history_failed = False
# 优先: filter_history_fn 策略 (涨停/反包等多日形态, 与选股路径共用同一逻辑)
if s.filter_history_fn:
try:
hit_df = s.filter_history_fn(panel, params)
if hit_df is None or hit_df.is_empty():
return false_mask
# 命中行 (symbol,date) → 转 panel 等长布尔 mask
hits = hit_df.select(["symbol", "date"]).unique()
marked = (
panel.select(["symbol", "date"])
.join(
hits.with_columns(pl.lit(True).alias("_hit")),
on=["symbol", "date"],
how="left",
)
)
return marked["_hit"].fill_null(False).cast(pl.Boolean)
except Exception as e:
history_failed = True
logger.warning("strategy filter_history_fn failed: %s", e)
# 失败则回退到 filter_fn (若存在)
# 策略 filter_fn: 候选层 (filter_history 不可用或失败时)
if s.filter_fn:
try:
expr = s.filter_fn(panel, params)
if expr is not None:
result = panel.select(expr.alias("_candidate_filter"))
if not result.is_empty():
return result["_candidate_filter"].fill_null(False).cast(pl.Boolean)
except Exception as e:
logger.warning("strategy filter_fn failed: %s", e)
return false_mask
if history_failed:
return false_mask
# 没有策略候选层时, 由 entry_signals 直接决定买点。
return true_mask
def _build_entry_mask_from_candidate(
self,
panel: pl.DataFrame,
candidate_mask: pl.Series,
s: StrategyDef,
entry_signals: list[str],
) -> pl.Series:
"""向量化生成买入掩码:候选层 AND 买点层;无买点时只用策略候选层。"""
signal_mask = self._build_signal_mask(panel, entry_signals, "_entry_signal")
if entry_signals:
return candidate_mask & signal_mask
if s.filter_history_fn or s.filter_fn:
return candidate_mask
return pl.Series("_entry", [False] * len(panel), dtype=pl.Boolean)
def _build_entry_mask(
self,
panel: pl.DataFrame,
s: StrategyDef,
params: dict,
entry_signals: list[str],
) -> pl.Series:
"""兼容旧调用: 候选层 AND 买点层。"""
candidate_mask = self._build_candidate_filter_mask(panel, s, params)
return self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
@staticmethod
def _build_signal_mask(panel: pl.DataFrame, signals: list[str], name: str) -> pl.Series:
"""向量化合并信号列,多个信号 OR。支持内置 signal_ 与自定义 csg_ 前缀。"""
masks: list[pl.Series] = []
for sig in signals:
# csg_ (自定义信号) 直接用;否则按 signal_ 解析
col = sig if (sig.startswith("signal_") or sig.startswith("csg_")) else f"signal_{sig}"
if col in panel.columns:
masks.append(panel[col].fill_null(False).cast(pl.Boolean))
if not masks:
return pl.Series(name, [False] * len(panel), dtype=pl.Boolean)
combined = masks[0]
for m in masks[1:]:
combined = combined | m
return combined
def _build_benchmark_curve(self, start: date, end: date) -> list[dict]:
try:
df = self.engine.repo.get_index_daily(BENCHMARK_SYMBOL, start, end, columns=["date", "close"])
except Exception as e:
logger.warning("load benchmark %s failed: %s", BENCHMARK_SYMBOL, e)
return []
if df.is_empty() or "close" not in df.columns:
return []
df = df.filter(pl.col("close").is_not_null() & (pl.col("close") > 0)).sort("date")
if df.is_empty():
return []
return [
{
"date": str(row["date"])[:10],
"value": round(float(row["close"]), 4),
"close": round(float(row["close"]), 4),
"name": "上证指数",
"symbol": BENCHMARK_SYMBOL,
}
for row in df.iter_rows(named=True)
if row["close"] is not None
]
# ── 工具 ──
@staticmethod
def _effective_basic_filter(s: StrategyDef, overrides: dict) -> dict:
basic_filter = dict(s.basic_filter or {})
override_filter = overrides.get("basic_filter")
if isinstance(override_filter, dict):
basic_filter.update(override_filter)
return basic_filter
@staticmethod
def _effective_signals(overrides: dict, key: str, default: list[str]) -> list[str]:
value = overrides.get(key)
if isinstance(value, list):
return [str(v) for v in value if v]
return list(default or [])
@staticmethod
def _override_value(overrides: dict, key: str, default):
if key in overrides:
return overrides.get(key)
return default
@staticmethod
def _normalize_pct(value, min_value: float, max_value: float) -> float | None:
if value is None or value == "":
return None
try:
pct = abs(float(value))
except (TypeError, ValueError):
return None
return min(max(pct, min_value), max_value)
@staticmethod
def _normalize_score_range(min_value, max_value) -> tuple[float | None, float | None]:
def _bound(value) -> float | None:
if value is None or value == "":
return None
try:
score = float(value)
except (TypeError, ValueError):
return None
if not np.isfinite(score):
return None
return min(max(score, 0.0), 100.0)
score_min = _bound(min_value)
score_max = _bound(max_value)
if score_min is not None and score_max is not None and score_min > score_max:
score_min, score_max = score_max, score_min
return score_min, score_max
@staticmethod
def _normalize_params(params: dict, s: StrategyDef) -> dict:
normalized = dict(params)
for param in s.meta.get("params", []):
pid = param.get("id")
if not pid:
continue
value = normalized.get(pid, param.get("default"))
p_type = param.get("type")
if p_type in {"float", "int"}:
try:
num = float(value)
except (TypeError, ValueError):
num = float(param.get("default", 0) or 0)
if param.get("min") is not None:
num = max(num, float(param["min"]))
if param.get("max") is not None:
num = min(num, float(param["max"]))
normalized[pid] = int(num) if p_type == "int" else num
elif p_type == "select" and param.get("options"):
normalized[pid] = value if value in param["options"] else param.get("default")
elif p_type == "bool":
if isinstance(value, bool):
normalized[pid] = value
elif isinstance(value, str):
normalized[pid] = value.lower() == "true"
else:
normalized[pid] = bool(param.get("default", False))
else:
normalized[pid] = value
return normalized
@staticmethod
def _trade_to_dict(t) -> dict:
return {
"symbol": t.symbol,
"name": t.name,
"entry_date": str(t.entry_date) if isinstance(t.entry_date, date) else str(t.entry_date),
"exit_date": str(t.exit_date) if isinstance(t.exit_date, date) else str(t.exit_date),
"entry_price": t.entry_price,
"exit_price": t.exit_price,
"pnl_pct": t.pnl_pct,
"duration": t.duration,
"exit_reason": t.exit_reason,
"shares": t.shares,
"lots": t.lots,
"position_pct": t.position_pct,
"entry_value": t.entry_value,
"exit_value": t.exit_value,
"pnl_amount": t.pnl_amount,
"entry_score": getattr(t, "entry_score", None),
"entry_signal_date": str(t.entry_signal_date) if getattr(t, "entry_signal_date", None) is not None else None,
"exit_signal_date": str(t.exit_signal_date) if getattr(t, "exit_signal_date", None) is not None else None,
"blocked_exit_days": getattr(t, "blocked_exit_days", 0),
}
@staticmethod
def _config_to_dict(c: StrategyBacktestConfig) -> dict:
score_min, score_max = StrategyBacktestService._normalize_score_range(
(c.overrides or {}).get("score_min"),
(c.overrides or {}).get("score_max"),
)
return {
"strategy_id": c.strategy_id,
"symbols": c.symbols,
"start": str(c.start),
"end": str(c.end),
"params": c.params,
"overrides": c.overrides,
"score_min": score_min,
"score_max": score_max,
"matching": c.matching,
"entry_fill": c.entry_fill,
"exit_fill": c.exit_fill,
"fees_pct": c.fees_pct,
"slippage_bps": c.slippage_bps,
"max_positions": c.max_positions,
"max_exposure_pct": c.max_exposure_pct,
"initial_capital": c.initial_capital,
"position_sizing": c.position_sizing,
"mode": c.mode,
"holding_days": c.holding_days,
}
@staticmethod
def _apply_score(
panel: pl.DataFrame,
s: StrategyDef,
overrides: dict | None,
universe_mask: pl.Series | None = None,
) -> pl.DataFrame:
scoring = s.meta.get("scoring", {})
scoring_overrides = (overrides or {}).get("scoring")
if scoring_overrides:
scoring = {**scoring, **scoring_overrides}
work = panel
has_universe = universe_mask is not None and len(universe_mask) == len(panel)
if has_universe:
work = work.with_columns(universe_mask.rename("_score_universe"))
def _value_in_universe(col: str) -> pl.Expr:
if has_universe:
return pl.when(pl.col("_score_universe")).then(pl.col(col)).otherwise(None)
return pl.col(col)
def _finish(df: pl.DataFrame) -> pl.DataFrame:
return df.drop("_score_universe") if "_score_universe" in df.columns else df
if scoring:
total_weight = sum(scoring.values())
if total_weight > 0:
score_parts: list[pl.Expr] = []
for col, weight in scoring.items():
if col not in work.columns:
continue
w = weight / total_weight
value = _value_in_universe(col)
col_min = value.min().over("date")
col_max = value.max().over("date")
col_range = col_max - col_min
normalized = pl.when(col_range > 0).then(
(pl.col(col) - col_min) / col_range
).otherwise(pl.lit(0.5))
if has_universe:
normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0)
score_parts.append(normalized * w)
if score_parts:
score_expr = score_parts[0]
for part in score_parts[1:]:
score_expr = score_expr + part
return _finish(work.with_columns((score_expr * 100).fill_null(0).alias("score")))
order_by = s.meta.get("order_by")
if order_by and order_by != "score" and order_by in work.columns:
direction = 1 if s.meta.get("descending", True) else -1
score_expr = pl.col(order_by).fill_null(0) * direction
if has_universe:
score_expr = pl.when(pl.col("_score_universe")).then(score_expr).otherwise(0.0)
return _finish(work.with_columns(score_expr.alias("score")))
return _finish(work.with_columns(pl.lit(0.0).alias("score")))
+3 -2
View File
@@ -93,12 +93,13 @@ class Settings(BaseSettings):
host: str = "0.0.0.0"
port: int = 3018
log_level: str = "INFO"
backtest_range_guard: bool = False
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
auth_password: str = ""
# Sync — 数据同步鉴权 Keylocal→serve 推送时校验 X-Sync-Key 头)
sync_key: str = ""
# Data — frozen: exe 同级 data/ 子目录; 非 frozen: 项目根 data/
# (均可被环境变量 DATA_DIR 覆盖, pydantic-settings 自动注入)
data_dir: Path = _user_data_root()
-8
View File
@@ -21,7 +21,6 @@ class ProviderCapabilities:
daily: bool = False
adj_factor: bool = False
minute: bool = False
realtime: bool = False
financial: bool = False
@@ -59,10 +58,3 @@ class MarketDataProvider(Protocol):
freq: str = "1m",
) -> pl.DataFrame:
"""Return normalized minute K rows. Implementations may return empty."""
def get_realtime(
self,
universes: list[str] | None = None,
symbols: list[str] | None = None,
) -> pl.DataFrame:
"""Return normalized realtime quotes. Implementations may return empty."""
@@ -22,7 +22,6 @@ class TickFlowProvider:
daily=True,
adj_factor=True,
minute=True,
realtime=True,
financial=True,
)
@@ -103,18 +102,3 @@ class TickFlowProvider:
# Existing minute sync remains in app.services.kline_sync for now.
return pl.DataFrame()
def get_realtime(
self,
universes: list[str] | None = None,
symbols: list[str] | None = None,
) -> pl.DataFrame:
tf = get_client()
if universes and symbols:
raise ValueError("TickFlow realtime accepts either universes or symbols, not both")
if universes:
resp = tf.quotes.get_by_universes(universes=universes)
elif symbols:
resp = tf.quotes.get(symbols=symbols)
else:
return pl.DataFrame()
return pl.DataFrame(resp or [])
+12 -70
View File
@@ -44,7 +44,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
"""解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
batch 能力 直接拉 CN_Equity_A universe
其他用户 instruments parquet + watchlist 兜底
其他用户 instruments parquet 兜底
"""
if capset.has(Cap.KLINE_DAILY_BATCH):
try:
@@ -54,9 +54,8 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
except Exception as e: # noqa: BLE001
logger.warning("CN_Equity_A pool unavailable, fallback: %s", e)
# Free 用户兜底: instruments parquet + watchlist + demo
# Free 用户兜底: instruments parquet + demo
base: set[str] = set(DEMO_SYMBOLS)
base.update(get_pool("watchlist"))
d = Path(settings.data_dir)
inst_path = d / "instruments" / "instruments.parquet"
if inst_path.exists():
@@ -558,15 +557,11 @@ REVIEW_JOB_ID = "scheduled_review"
async def _run_scheduled_review(repo) -> None:
"""定时复盘 job: 流式生成复盘 → 实时推 SSE(开着页面可见) → 落盘归档 → 推飞书。
"""定时复盘 job: 流式生成复盘 → 落盘归档 → 推飞书。
与手动生成复盘体验一致: 流式事件经 quote_service.push_review_event
/api/intraday/stream review_progress 事件 前端 reviewStore, 用户开着复盘页
即可看到报告边生成边显示, 切走再回来也能看到生成中/已生成
LLM 偶发断流(peer closed connection)时自动重试最多 2
与手动生成复盘体验一致LLM 偶发断流(peer closed connection)时自动重试最多 2
任何异常都吞掉只记日志, 绝不影响调度器主循环
"""
import json
try:
from app.services import market_recap_reports
@@ -577,18 +572,9 @@ async def _run_scheduled_review(repo) -> None:
logger.info("scheduled review skipped: AI key not configured")
return
app_state = _get_app_state()
quote_service = getattr(app_state, "quote_service", None) if app_state else None
depth_service = getattr(app_state, "depth_service", None) if app_state else None
content, meta = await _stream_review_with_retry(repo, quote_service, depth_service)
content, meta = await _stream_review_with_retry(repo)
if not content:
logger.warning("scheduled review produced no content (meta=%s)", meta)
# 通知前端进入 error 态(若有页面在听)
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "error", "message": "复盘生成失败,请稍后手动重试"},
ensure_ascii=False))
return
# 落盘: 与手动生成完全相同的归档格式
@@ -602,34 +588,17 @@ async def _run_scheduled_review(repo) -> None:
})
logger.info("scheduled review saved: as_of=%s", meta.get("as_of"))
# 通知前端: 生成完成且已归档(archived=true 让前端只刷新列表, 不重复归档)
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "done", "archived": True}, ensure_ascii=False))
# 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。
# 失败静默降级, 不影响已归档的报告。
_maybe_push_review(content, meta)
except Exception as e: # noqa: BLE001
logger.exception("scheduled review failed: %s", e)
# 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming
try:
app_state = _get_app_state()
qs = getattr(app_state, "quote_service", None) if app_state else None
if qs:
import json as _json
qs.push_review_event(_json.dumps(
{"type": "error", "message": "复盘生成异常,请稍后手动重试"},
ensure_ascii=False))
except Exception: # noqa: BLE001
pass
async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple[str, dict]:
"""流式生成复盘, 每个事件推 SSE + 累积内容。LLM 断流时最多重试 2 次。
async def _stream_review_with_retry(repo) -> tuple[str, dict]:
"""流式生成复盘, 累积内容。LLM 断流时最多重试 2 次。
返回 (content, meta)重试时推一个 retry 事件让前端清空已累积内容重新开始
成功(收到 done/ error)或耗尽重试后返回
返回 (content, meta)成功(收到 done/ error)或耗尽重试后返回
"""
import asyncio
import json
@@ -643,14 +612,10 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
content_parts = [] # 每次重试重新累积
failed = False
try:
async for evt_json in recap_market_stream(repo, quote_service, depth_service):
async for evt_json in recap_market_stream(repo):
evt = json.loads(evt_json)
t = evt.get("type")
# 推给前端(让开着页面的用户实时看到, 与手动一致)
if quote_service:
quote_service.push_review_event(evt_json)
if t == "meta":
last_meta = evt
elif t == "delta" and evt.get("content"):
@@ -675,10 +640,6 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
# 失败: 决定是否重试
if attempt < max_attempts:
logger.info("scheduled review retrying in 3s (attempt %d%d)", attempt, attempt + 1)
# 通知前端: 即将重试, 清空已累积内容重新开始
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "retry", "attempt": attempt + 1}, ensure_ascii=False))
await asyncio.sleep(3)
# 耗尽重试, 返回已累积内容(可能为空)和最后 meta
@@ -790,24 +751,6 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
replace_existing=True,
)
# 盘后: 五档盘口 sealed 定版(时间由偏好决定, 默认15:02, 范围15:01~18:00)
depth_sched = preferences.get_depth_finalize_time()
def _depth_finalize():
depth_svc = getattr(_get_app_state(), "depth_service", None) if _get_app_state() else None
if depth_svc:
depth_svc.finalize()
scheduler.add_job(
_depth_finalize,
trigger=CronTrigger(day_of_week="mon-fri",
hour=depth_sched["hour"], minute=depth_sched["minute"],
timezone="Asia/Shanghai"),
id="depth_finalize",
misfire_grace_time=3600,
replace_existing=True,
)
# 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。
# 默认关闭 —— 仅当用户在复盘页开启时才注册 job。
# 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。
@@ -819,9 +762,8 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
review_sched["hour"], review_sched["minute"])
scheduler.start()
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri",
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"],
depth_sched["hour"], depth_sched["minute"])
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d mon-fri",
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"])
return scheduler
@@ -830,7 +772,7 @@ _app_state_ref = None
def set_app_state(app_state) -> None:
"""lifespan 注册 app.state 引用, 供 scheduled job 访问 depth_service 等单例。"""
"""lifespan 注册 app.state 引用, 供 scheduled job 访问单例。"""
global _app_state_ref
_app_state_ref = app_state
+9 -95
View File
@@ -11,11 +11,10 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
from app.api import analysis, auth as auth_api, data, data_sync, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy
from app.api.routes import router as core_router
from app.config import settings
from app.jobs import daily_pipeline
from app.services.quote_service import QuoteService
from app.tickflow import client as tf_client
from app.tickflow.policy import detect_capabilities
from app.tickflow.repository import DataStore, KlineRepository
@@ -30,7 +29,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(
"TickFlow Stock Panel v%s starting (mode=%s)",
"A股工作台 v%s starting (mode=%s)",
__version__, tf_client.current_mode(),
)
@@ -56,65 +55,23 @@ async def lifespan(app: FastAPI):
app.state.capabilities = capset
logger.info("ready; %d capabilities active", len(capset.all()))
# 全局行情服务
qs = QuoteService()
app.state.quote_service = qs
qs.set_repo(repo)
qs.boot_check()
# QuoteService 需要访问 strategy_monitor 等单例
# 先创建 strategy_monitor,再注入 app.state
from app.strategy.monitor import StrategyMonitorService
strategy_monitor = StrategyMonitorService()
app.state.strategy_monitor = strategy_monitor
qs.set_app_state(app.state)
# 五档盘口 sealed 服务(真假涨停/跌停, 独立旁路线)
from app.services.depth_service import DepthService
depth_service = DepthService()
depth_service.set_repo(repo)
depth_service.set_app_state(app.state)
app.state.depth_service = depth_service
# 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run)
try:
daily_pipeline.set_app_state(app.state) # 供 depth_finalize job 访问 depth_service
scheduler = daily_pipeline.start_scheduler(repo, capset)
app.state.scheduler = scheduler
except Exception as e: # noqa: BLE001
logger.warning("scheduler not started: %s", e)
app.state.scheduler = None
# depth sealed: 启动补跑(当天文件不存在) + 盘中轮询(有能力时)
try:
depth_service.boot_check()
depth_service.start_polling()
except Exception as e: # noqa: BLE001
logger.warning("depth_service init failed: %s", e)
# 扩展数据定时拉取
from app.services.ext_pull import pull_scheduler
pull_scheduler.start(store.data_dir)
pull_scheduler.refresh(store.data_dir)
app.state.pull_scheduler = pull_scheduler
# 内置扩展表 (概念/行业): 只创建 config (含拉取配置), 不自动拉数据
# 数据获取由用户在概念/行业页点「获取数据」手动触发 (POST /api/ext-data/presets/{id}/fetch)
# 内置扩展表 (概念/行业): 只创建 config 供 sync 解压时用, 不拉数据。
try:
from app.services.ext_presets import ensure_builtin_presets
await ensure_builtin_presets(store.data_dir)
except Exception as e: # noqa: BLE001
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
# 财务数据 (需 Expert 套餐): 仅初始化调度器供 /api/financials/sync/* 手动同步,
# 不启动自动调度——用户在「财务分析」页点「同步」手动拉取。
from app.services.financial_sync import financial_scheduler
financial_scheduler.start(store.data_dir, capset)
app.state.financial_scheduler = financial_scheduler
# 策略引擎
from app.strategy.engine import StrategyEngine
from app.strategy.monitor import StrategyMonitorService
from app.services.screener import ScreenerService
_screener_svc = ScreenerService(repo)
@@ -131,57 +88,15 @@ async def lifespan(app: FastAPI):
app.state.strategy_engine = strategy_engine
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
# 通用监控规则引擎: 启动时 reload 规则到内存态 (修复重启后告警失效)
from app.strategy.monitor import MonitorRuleEngine
from app.strategy import monitor_rules as mr_store
from app.services import preferences
monitor_engine = MonitorRuleEngine()
monitor_engine.set_strategy_engine(strategy_engine)
monitor_engine.set_data_dir(store.data_dir)
# 复用 ScreenerService 的历史窗口加载器 (三级缓存, 启动预计算命中 ~0ms),
# 让声明 filter_history 的策略 (如反包) 也能在实时监控里跑选股 → 盘中触发通知。
monitor_engine.set_history_loader(_screener_svc._load_enriched_history)
# 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页)
try:
if preferences.get_strategy_monitor_enabled():
ids = preferences.get_strategy_monitor_ids()
if ids:
names = {s.id: s.name for s in strategy_engine.list_strategies()}
mr_store.migrate_strategy_monitors(store.data_dir, ids, names)
logger.info("strategy monitor migrated: %d strategies", len(ids))
except Exception as e: # noqa: BLE001
logger.warning("strategy monitor migration failed: %s", e)
try:
rules = mr_store.load_all(store.data_dir)
monitor_engine.set_rules(rules)
logger.info("monitor engine loaded: %d rules", monitor_engine.rule_count)
except Exception as e: # noqa: BLE001
logger.warning("monitor engine load failed: %s", e)
app.state.monitor_engine = monitor_engine
yield
if app.state.scheduler:
app.state.scheduler.shutdown(wait=False)
ps = getattr(app.state, "pull_scheduler", None)
if ps:
ps.stop()
fsc = getattr(app.state, "financial_scheduler", None)
if fsc:
fsc.stop()
qs = getattr(app.state, "quote_service", None)
if qs:
qs.stop()
dsvc = getattr(app.state, "depth_service", None)
if dsvc:
dsvc.stop_polling()
logger.info("shutdown")
app = FastAPI(
title="TickFlow Stock Panel",
title="A股工作台",
version=__version__,
description="A 股选股 + 回测面板 — TickFlow 适配",
lifespan=lifespan,
@@ -207,7 +122,7 @@ app.add_middleware(
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
_AUTH_WHITELIST_PREFIX = ("/api/auth/",)
_AUTH_WHITELIST_PREFIX = ("/api/auth/", "/api/data/sync/")
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
@@ -238,7 +153,9 @@ async def auth_middleware(request: Request, call_next):
# 情况 3: 已设密码, 检查会话
token = request.cookies.get(auth_api.COOKIE_NAME)
if token and auth_service.is_valid_session(token):
username = auth_service.get_session_user(token or "")
if username:
request.state.username = username
return await call_next(request)
# 未登录: 401(前端跳登录页)
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
@@ -248,15 +165,13 @@ async def auth_middleware(request: Request, call_next):
app.include_router(core_router)
app.include_router(auth_api.router)
app.include_router(kline.router)
app.include_router(watchlist.router)
app.include_router(screener.router)
app.include_router(backtest.router)
app.include_router(intraday.router)
app.include_router(indices.router)
app.include_router(overview.router)
app.include_router(analysis.router)
app.include_router(pipeline.router)
app.include_router(data.router)
app.include_router(data_sync.router)
app.include_router(ext_data.router)
app.include_router(financials.router)
app.include_router(stock_analysis.router)
@@ -264,7 +179,6 @@ app.include_router(market_recap.router)
app.include_router(settings_api.router)
app.include_router(strategy.router)
app.include_router(signals.router)
app.include_router(monitor_rules.router)
app.include_router(alerts.router)
app.include_router(rps.router)
+50
View File
@@ -94,3 +94,53 @@ def mask(key: str, prefix: int = 4, suffix: int = 4) -> str:
if len(key) <= prefix + suffix:
return "" * len(key)
return f"{key[:prefix]}{'' * 6}{key[-suffix:]}"
# ================================================================
# 按用户 AI 配置 (data/user_data/ai_settings/{username}.json)
# ================================================================
def _ai_config_path(username: str) -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_settings" / f"{username}.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def load_ai_config(username: str) -> dict:
"""加载指定用户的 AI 配置。用户不存在时返回空 dict。"""
p = _ai_config_path(username)
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
logger.warning("ai_settings/%s.json malformed: %s", username, e)
return {}
def save_ai_config(username: str, updates: dict) -> dict:
"""合并写入指定用户的 AI 配置。返回新内容。"""
current = load_ai_config(username)
current.update({k: v for k, v in updates.items() if v is not None})
p = _ai_config_path(username)
p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(p, 0o600)
except OSError:
pass
return current
def clear_ai_config(username: str, *keys: str) -> dict:
"""清掉指定用户的 AI 配置字段。不传 keys 则删除整个文件。"""
p = _ai_config_path(username)
if not p.exists():
return {}
if not keys:
p.unlink()
return {}
current = load_ai_config(username)
for k in keys:
current.pop(k, None)
p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
return current
+81 -17
View File
@@ -25,16 +25,74 @@ Message = dict[str, str]
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
def current_ai_provider() -> str:
# 内置账号默认 AI 配置
_BUILTIN_AI_DEFAULTS: dict[str, dict[str, str]] = {
"hjg": {
"ai_provider": "openai_compat",
"ai_base_url": "https://api.deepseek.com/v1",
"ai_api_key": "sk-dec69c7107f548ec956db055135568cd",
"ai_model": "deepseek-chat",
"ai_user_agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
},
}
def _builtin_ai_config(username: str | None, key: str) -> str | None:
"""读取内置账号的默认 AI 配置。仅当用户未自行配置时作为回退。"""
if not username:
return None
defaults = _BUILTIN_AI_DEFAULTS.get(username)
if not defaults:
return None
return defaults.get(key)
def current_ai_provider(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_provider")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
def current_ai_model() -> str:
if current_ai_provider() == CODEX_CLI_PROVIDER:
def current_ai_model(username: str | None = None) -> str:
if current_ai_provider(username) == CODEX_CLI_PROVIDER:
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
# 用户未配置时使用内置默认值(不读 config.py 的默认模型,避免泄露通用配置)
builtin = _builtin_ai_config(username, "ai_model")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_model", settings.ai_model)
def current_ai_base_url(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_base_url")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_base_url", settings.ai_base_url) or ""
def current_ai_api_key(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_api_key")
if builtin:
return builtin
return secrets_store.get_ai_key() or ""
def current_ai_user_agent(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_user_agent")
if builtin:
return builtin
return (
secrets_store.get_ai_config("ai_user_agent", "")
or settings.ai_user_agent
or ""
)
def current_codex_command() -> str:
return normalize_codex_command(
secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command),
@@ -82,11 +140,11 @@ def codex_cli_available() -> bool:
return False
def ai_configured(provider: str | None = None) -> bool:
provider = provider or current_ai_provider()
def ai_configured(provider: str | None = None, username: str | None = None) -> bool:
provider = provider or current_ai_provider(username)
if is_codex_cli_provider(provider):
return codex_cli_available()
return bool(secrets_store.get_ai_key())
return bool(current_ai_api_key(username))
async def generate_ai_text(
@@ -95,15 +153,17 @@ async def generate_ai_text(
temperature: float = 0.3,
max_tokens: int = 3000,
timeout: float = 180.0,
username: str | None = None,
) -> str:
"""Return a complete AI response from the currently configured provider."""
if is_codex_cli_provider():
if is_codex_cli_provider(current_ai_provider(username)):
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
username=username,
)
@@ -113,13 +173,14 @@ async def stream_ai_text(
temperature: float = 0.5,
max_tokens: int = 4000,
timeout: float = 180.0,
username: str | None = None,
) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits.
"""
if is_codex_cli_provider():
if is_codex_cli_provider(current_ai_provider(username)):
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return
@@ -128,6 +189,7 @@ async def stream_ai_text(
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
username=username,
):
yield chunk
@@ -138,14 +200,15 @@ async def _run_openai_once(
temperature: float,
max_tokens: int,
timeout: float,
username: str | None = None,
) -> str:
ai_key = secrets_store.get_ai_key()
ai_key = current_ai_api_key(username)
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
client = _openai_client(ai_key, timeout, username=username)
resp = await client.chat.completions.create(
model=current_ai_model(),
model=current_ai_model(username),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
@@ -161,14 +224,15 @@ async def _stream_openai(
temperature: float,
max_tokens: int,
timeout: float,
username: str | None = None,
) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key()
ai_key = current_ai_api_key(username)
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
client = _openai_client(ai_key, timeout, username=username)
stream = await client.chat.completions.create(
model=current_ai_model(),
model=current_ai_model(username),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
@@ -181,13 +245,13 @@ async def _stream_openai(
yield delta.content
def _openai_client(api_key: str, timeout: float):
def _openai_client(api_key: str, timeout: float, username: str | None = None):
from openai import AsyncOpenAI
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
user_agent = current_ai_user_agent(username)
return AsyncOpenAI(
api_key=api_key,
base_url=normalize_openai_base_url(secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)),
base_url=normalize_openai_base_url(current_ai_base_url(username)),
timeout=timeout,
max_retries=2,
default_headers={"User-Agent": user_agent},
+26 -40
View File
@@ -1,20 +1,8 @@
"""AI 财务分析报告持久化存储。
"""AI 财务分析报告持久化存储 — 按用户名隔离
存储位置: data/user_data/ai_reports.json (数组, created_at 降序)
保留最近 MAX_REPORTS ;超出自动裁剪最旧的
每条报告结构:
{
"id": "rpt_xxx", # 唯一 id
"symbol": "600519.SH",
"name": "贵州茅台",
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"periods": 4, # 基于几期数据生成
"summary": "metrics: 1期...", # 数据摘要
"created_at": "2026-06-25T10:00:00"
}
存储位置: data/user_data/reports/{username}/ai_reports.json
"""
from __future__ import annotations
import json
@@ -27,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 20
def _path() -> Path:
def _path(username: str | None = None) -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_reports.json"
if username:
p = settings.data_dir / "user_data" / "reports" / username / "ai_reports.json"
else:
p = settings.data_dir / "user_data" / "ai_reports.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_reports() -> list[dict]:
"""返回全部报告(按 created_at 降序)。"""
p = _path()
def list_reports(username: str | None = None) -> list[dict]:
"""返回指定用户的全部报告(按 created_at 降序)。"""
p = _path(username)
if not p.exists():
return []
try:
@@ -48,54 +39,49 @@ def list_reports() -> list[dict]:
return []
def _save_all(reports: list[dict]) -> None:
def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。"""
# 保持降序
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS]
_path().write_text(
_path(username).write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
)
def save_report(report: dict) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。
自动补全 id created_at(若缺),并裁剪到上限
"""
reports = list_reports()
def save_report(report: dict, username: str | None = None) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports(username)
if not report.get("id"):
report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"):
report["created_at"] = _now_iso()
reports.append(report)
_save_all(reports)
logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
_save_all(reports, username)
logger.info("AI report saved by %s: %s (%s), total %d", username or "?", report.get("symbol"), report.get("id"), len(reports))
return report
def delete_report(report_id: str) -> bool:
"""删除指定报告。返回是否删除成功。"""
reports = list_reports()
def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定用户的报告。返回是否删除成功。"""
reports = list_reports(username)
before = len(reports)
reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before:
_save_all(reports)
_save_all(reports, username)
return True
return False
def clear_reports() -> int:
"""清空全部报告。返回删除数量。"""
reports = list_reports()
def clear_reports(username: str | None = None) -> int:
"""清空指定用户的全部报告。返回删除数量。"""
reports = list_reports(username)
n = len(reports)
if n > 0:
_save_all([])
_save_all([], username)
return n
def _now_iso() -> str:
"""当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。"""
from datetime import datetime
return datetime.now().isoformat(timespec="seconds")
+276 -61
View File
@@ -1,15 +1,15 @@
"""访问密码认证 — 单用户, 自托管场景。
"""多用户访问密码认证 — 自托管场景。
设计:
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt
即使 auth.json 泄露, 也无法逆向出明文密码
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式
- 多用户: 支持多个账号, 数据共享不隔离; role=admin 可管理用户
安全要点:
- 设密码接口必须限制本机/内网( auth router), 防黑客抢占域名抢先设密码
- 登录限流: 错5次锁5分钟( auth router 内存计数)
- 单密码, 不做多用户(避免重构全项目数据层)
"""
from __future__ import annotations
@@ -29,13 +29,20 @@ _PBKDF2_ITER = 200_000
_SALT_LEN = 16
_TOKEN_BYTES = 32
# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率)
# 会话有效期: 30 天(自托管, 长一点减少重登频率)
SESSION_TTL = 30 * 24 * 3600
_lock = threading.Lock()
# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。
_sessions: dict[str, float] = {}
# schema 版本
_CURRENT_SCHEMA_VERSION = 2
_lock = threading.Lock()
# 内存中的有效会话: { token: {username, expire} }。进程重启后从磁盘恢复。
_sessions: dict[str, dict] = {}
# ================================================================
# 存储
# ================================================================
def _path() -> Path:
from app.config import settings
@@ -63,6 +70,10 @@ def _save(data: dict) -> None:
pass
# ================================================================
# 密码工具
# ================================================================
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
if salt is None:
@@ -83,36 +94,228 @@ def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
# ================================================================
# 密码管理
# 数据迁移 (v1 → v2)
# ================================================================
def _migrate_from_v1(data: dict) -> dict | None:
"""检测旧版单用户格式并迁移。返回迁移后的新数据, 无需迁移则返回 None。"""
if data.get("schema_version") == _CURRENT_SCHEMA_VERSION:
return None
if not data.get("password_hash"):
return None
logger.info("检测到旧版 auth.json, 正在迁移到多用户格式...")
new_data = {
"schema_version": _CURRENT_SCHEMA_VERSION,
"users": {},
"sessions": {},
"updated_at": int(time.time()),
}
# 将旧密码迁移为 admin 用户
salt = data.get("password_salt", "")
phash = data.get("password_hash", "")
new_data["users"]["admin"] = {
"password_hash": phash,
"password_salt": salt,
"role": "admin",
"created_at": int(time.time()),
}
# 迁移旧 sessions
old_sessions = data.get("sessions") or {}
now = time.time()
for token, expire in old_sessions.items():
if isinstance(expire, (int, float)) and expire > now:
new_data["sessions"][token] = {"username": "admin", "expire": expire}
logger.info("auth.json 迁移完成: admin 用户, %d 个会话恢复", len(new_data["sessions"]))
return new_data
# ================================================================
# 用户管理
# ================================================================
def _ensure_loaded() -> dict:
"""加载数据, 必要时执行迁移。"""
data = _load()
migrated = _migrate_from_v1(data)
if migrated is not None:
data = migrated
_save(data)
return data
def is_configured() -> bool:
"""是否已设置访问密码"""
d = _load()
return bool(d.get("password_hash"))
"""是否已有用户"""
data = _ensure_loaded()
return bool(data.get("users"))
def set_password(password: str) -> None:
"""设置/修改访问密码。清空所有现有会话(强制重新登录)"""
def create_user(username: str, password: str, role: str = "viewer") -> None:
"""创建用户。username 已存在则抛出 ValueError"""
if len(password) < 6:
raise ValueError("密码至少 6 位")
if not username or not username.strip():
raise ValueError("用户名不能为空")
username = username.strip()
if not username.replace("_", "").replace("-", "").isalnum():
raise ValueError("用户名只能包含字母、数字、下划线和连字符")
salt_hex, hash_hex = _hash_password(password)
with _lock:
_sessions.clear() # 改密码 = 旧会话全部失效
_save({
data = _ensure_loaded()
users = data.setdefault("users", {})
if username in users:
raise ValueError(f"用户 '{username}' 已存在")
users[username] = {
"password_hash": hash_hex,
"password_salt": salt_hex,
"updated_at": int(time.time()),
"sessions": {}, # 清空持久化会话
"role": role,
"created_at": int(time.time()),
}
data["updated_at"] = int(time.time())
_save(data)
logger.info("user created: %s (role=%s)", username, role)
def list_users() -> list[dict]:
"""返回用户列表(不含密码哈希)。"""
data = _ensure_loaded()
users = data.get("users") or {}
result = []
for name, info in users.items():
result.append({
"username": name,
"role": info.get("role", "viewer"),
"created_at": info.get("created_at"),
})
logger.info("access password set")
result.sort(key=lambda u: u["created_at"] or 0)
return result
def delete_user(username: str) -> bool:
"""删除用户。admin 用户不可删除。返回是否成功。"""
with _lock:
data = _ensure_loaded()
users = data.get("users") or {}
if username not in users:
return False
if users[username].get("role") == "admin":
raise ValueError("不能删除 admin 用户")
del users[username]
# 清除该用户的所有会话
sessions = data.get("sessions") or {}
data["sessions"] = {t: s for t, s in sessions.items() if s.get("username") != username}
_sessions.clear() # 重新加载时同步
data["updated_at"] = int(time.time())
_save(data)
logger.info("user deleted: %s", username)
return True
def change_password(username: str, old_password: str, new_password: str) -> bool:
"""用户自己改密码。验证旧密码, 返回是否成功。"""
if len(new_password) < 6:
raise ValueError("密码至少 6 位")
with _lock:
data = _ensure_loaded()
users = data.get("users") or {}
user = users.get(username)
if not user:
return False
if not _verify_password(old_password, user.get("password_salt", ""), user.get("password_hash", "")):
return False
salt_hex, hash_hex = _hash_password(new_password)
user["password_hash"] = hash_hex
user["password_salt"] = salt_hex
data["updated_at"] = int(time.time())
_save(data)
# 改密码不清除会话(与单用户模式不同, 多用户下只清自己的)
logger.info("password changed: %s", username)
return True
def admin_reset_password(username: str, new_password: str) -> bool:
"""管理员重置用户密码。清除该用户所有会话。"""
if len(new_password) < 6:
raise ValueError("密码至少 6 位")
with _lock:
data = _ensure_loaded()
users = data.get("users") or {}
if username not in users:
return False
salt_hex, hash_hex = _hash_password(new_password)
users[username]["password_hash"] = hash_hex
users[username]["password_salt"] = salt_hex
# 清除被重置用户的所有会话
sessions = data.get("sessions") or {}
data["sessions"] = {t: s for t, s in sessions.items() if s.get("username") != username}
data["updated_at"] = int(time.time())
_save(data)
logger.info("password reset by admin: %s", username)
return True
# ================================================================
# 会话管理
# ================================================================
def login(username: str, password: str) -> tuple[str, str] | None:
"""验证密码, 成功则创建会话并返回 (token, username), 失败返回 None。"""
data = _ensure_loaded()
users = data.get("users") or {}
user = users.get(username)
if not user:
return None
if not _verify_password(password, user.get("password_salt", ""), user.get("password_hash", "")):
return None
token = _secrets.token_urlsafe(_TOKEN_BYTES)
expire = time.time() + SESSION_TTL
with _lock:
_sessions[token] = {"username": username, "expire": expire}
_persist_sessions_locked()
return token, username
def revoke_session(token: str) -> None:
"""注销会话(登出)。"""
with _lock:
_sessions.pop(token, None)
_persist_sessions_locked()
def get_session_user(token: str) -> str | None:
"""返回 token 对应的 username, 无效/过期则返回 None。过期自动清理。"""
if not token:
return None
with _lock:
session = _sessions.get(token)
if session is None:
return None
if time.time() > session["expire"]:
_sessions.pop(token, None)
_persist_sessions_locked()
return None
return session["username"]
def get_session_role(token: str) -> str | None:
"""返回 token 对应用户的 role, 无效返回 None。"""
username = get_session_user(token)
if not username:
return None
data = _ensure_loaded()
users = data.get("users") or {}
user = users.get(username)
return user.get("role") if user else None
def bootstrap_from_env() -> bool:
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且尚未设过密码, 则用它设密码
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且无用户, 则创建 admin 用户
公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码
明文密码只在内存/配置中, set_password() 哈希后写入 auth.json (chmod 0600)
一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)
Returns:
@@ -123,74 +326,86 @@ def bootstrap_from_env() -> bool:
pwd = (settings.auth_password or "").strip()
if not pwd:
return False
if is_configured():
# 已设过密码, 不覆盖 (避免环境变量反复重置用户在 UI 改的密码)
data = _ensure_loaded()
if data.get("users"):
return False
try:
set_password(pwd)
logger.info("access password bootstrapped from AUTH_PASSWORD env (one-time)")
create_user("admin", pwd, role="admin")
logger.info("admin user bootstrapped from AUTH_PASSWORD env (one-time)")
return True
except ValueError as e:
# 密码不合规 (< 6 位), 记日志但不阻断启动
logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e)
return False
def verify_and_create_session(password: str) -> str | None:
"""验证密码, 成功则创建会话并返回 token, 失败返回 None。"""
d = _load()
if not d.get("password_hash"):
return None
if not _verify_password(password, d.get("password_salt", ""), d["password_hash"]):
return None
token = _secrets.token_urlsafe(_TOKEN_BYTES)
expire = time.time() + SESSION_TTL
with _lock:
_sessions[token] = expire
_persist_sessions_locked()
return token
# ================================================================
# 兼容旧版
# ================================================================
def revoke_session(token: str) -> None:
"""注销会话(登出)"""
# set_password 保留为 create_user 别名(旧 api/auth.py 中可能使用)
def set_password(password: str) -> None:
"""兼容旧版: 创建/更新 admin 用户密码"""
with _lock:
_sessions.pop(token, None)
_persist_sessions_locked()
data = _ensure_loaded()
users = data.get("users") or {}
if "admin" in users:
salt_hex, hash_hex = _hash_password(password)
users["admin"]["password_hash"] = hash_hex
users["admin"]["password_salt"] = salt_hex
data["updated_at"] = int(time.time())
_save(data)
_sessions.clear()
logger.info("admin password updated via set_password")
else:
create_user("admin", password, role="admin")
def is_valid_session(token: str) -> bool:
"""检查会话是否有效(存在且未过期)。过期则清理"""
if not token:
return False
with _lock:
expire = _sessions.get(token)
if expire is None:
return False
if time.time() > expire:
_sessions.pop(token, None)
_persist_sessions_locked()
return False
return True
"""兼容旧版: 检查会话是否有效。"""
return get_session_user(token) is not None
def verify_and_create_session(password: str) -> str | None:
"""兼容旧版: 作为 admin 登录, 返回 token。"""
result = login("admin", password)
if result:
return result[0]
return None
# ================================================================
# 启动恢复
# ================================================================
def _persist_sessions_locked() -> None:
"""把当前内存会话写回 auth.json(需持锁调用)。"""
d = _load()
d["sessions"] = {t: exp for t, exp in _sessions.items()}
d.setdefault("users", {})
d["sessions"] = dict(_sessions)
if "schema_version" not in d:
d["schema_version"] = _CURRENT_SCHEMA_VERSION
_save(d)
def _restore_sessions() -> None:
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
with _lock:
d = _load()
data = _ensure_loaded()
now = time.time()
saved = d.get("sessions") or {}
for token, expire in saved.items():
if isinstance(expire, (int, float)) and expire > now:
_sessions[token] = expire
saved = data.get("sessions") or {}
for token, session in saved.items():
if isinstance(session, dict):
expire = session.get("expire", 0)
username = session.get("username", "")
elif isinstance(session, (int, float)):
# 兼容旧版: 旧格式 {token: expire} 映射到 admin
expire = session
username = "admin"
else:
continue
if expire > now:
_sessions[token] = {"username": username, "expire": expire}
if len(_sessions) != len(saved):
# 有过期会话被清理, 落盘一次
_persist_sessions_locked()
-392
View File
@@ -1,392 +0,0 @@
"""回测服务(§6.7)。
vectorbt 全项目唯一一处出现 pandas
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from datetime import date
from typing import Literal
import numpy as np
import pandas as pd
import polars as pl
from app.config import settings
from app.tickflow.repository import KlineRepository
logger = logging.getLogger(__name__)
# vectorbt 是 optional extras(见 pyproject.toml).未装时只有 backtest 不可用,其他功能正常.
_vbt = None
_vbt_unavailable_reason: str | None = None
class VectorbtUnavailable(RuntimeError):
"""vectorbt 未安装 — 提示用户 `uv sync --extra backtest`."""
def _get_vbt():
global _vbt, _vbt_unavailable_reason
if _vbt is not None:
return _vbt
if _vbt_unavailable_reason is not None:
raise VectorbtUnavailable(_vbt_unavailable_reason)
try:
import vectorbt as vbt
_vbt = vbt
return _vbt
except ImportError as e:
_vbt_unavailable_reason = (
"vectorbt 未安装 — 它是回测的可选依赖.macOS Intel 用户先 `brew install cmake` "
"然后 `uv sync --extra backtest`"
)
logger.warning("vectorbt unavailable: %s", e)
raise VectorbtUnavailable(_vbt_unavailable_reason) from e
def is_available() -> bool:
"""供 API 层快速检测."""
try:
_get_vbt()
return True
except VectorbtUnavailable:
return False
SignalKind = Literal[
"macd_golden", "macd_dead",
"ma_golden_5_20", "ma_dead_5_20",
"ma_golden_20_60",
"ma20_breakout", "ma20_breakdown",
"n_day_high", "n_day_low",
"boll_breakout_upper", "boll_breakdown_lower",
"volume_surge",
"rsi_oversold", "rsi_overbought",
"stop_loss", "trailing_stop", "max_hold",
]
@dataclass
class BacktestConfig:
symbols: list[str]
start: date
end: date
# 买入信号(任一触发即买)
entries: list[str] = field(default_factory=list)
# 卖出信号(任一触发即卖)
exits: list[str] = field(default_factory=list)
# 其他参数
stop_loss_pct: float | None = None # 例 -0.05 = -5%
max_hold_days: int | None = None
fees_pct: float = 0.0002 # 万二佣金
slippage_bps: float = 5 # 5 bps
# 撮合
matching: Literal["close_t", "open_t+1"] = "close_t"
rsi_oversold_threshold: float = 30
rsi_overbought_threshold: float = 70
@dataclass
class BacktestResult:
run_id: str
config: dict
stats: dict
equity_curve: list[dict] # [{date, value}]
trades: list[dict] # [{symbol, entry_date, exit_date, pnl_pct, ...}]
per_symbol_stats: list[dict] # 每只股票的统计
# enriched 表里的信号列名映射
_SIGNAL_COLS: dict[SignalKind, str] = {
"macd_golden": "signal_macd_golden",
"macd_dead": "signal_macd_dead",
"ma_golden_5_20": "signal_ma_golden_5_20",
"ma_dead_5_20": "signal_ma_dead_5_20",
"ma_golden_20_60": "signal_ma_golden_20_60",
"ma20_breakout": "signal_ma20_breakout",
"ma20_breakdown": "signal_ma20_breakdown",
"n_day_high": "signal_n_day_high",
"n_day_low": "signal_n_day_low",
"boll_breakout_upper": "signal_boll_breakout_upper",
"boll_breakdown_lower": "signal_boll_breakdown_lower",
"volume_surge": "signal_volume_surge",
}
class BacktestService:
def __init__(self, repo: KlineRepository) -> None:
self.repo = repo
def _load_panel(
self,
symbols: list[str],
start: date,
end: date,
) -> pd.DataFrame:
"""加载 [date × symbol] 价格面板 — Polars scan_parquet + 即时计算指标。
**全项目唯一从 Polars pandas 的边界**(§7.4 / ADR-19)
"""
try:
enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet")
df = (
pl.scan_parquet(enriched_glob)
.filter(
(pl.col("symbol").is_in(symbols))
& (pl.col("date") >= start)
& (pl.col("date") <= end)
)
.sort(["date", "symbol"])
.collect()
)
except Exception as e: # noqa: BLE001
logger.warning("backtest load failed: %s", e)
return pd.DataFrame()
if df.is_empty():
return pd.DataFrame()
# 即时计算指标 + 信号
from app.indicators.pipeline import compute_all
df = compute_all(df)
# 选择需要的列
needed_cols = [
"date", "symbol", "open", "high", "low", "close", "volume",
"rsi_14", "signal_macd_golden", "signal_macd_dead",
"signal_ma_golden_5_20", "signal_ma_dead_5_20",
"signal_ma_golden_20_60",
"signal_ma20_breakout", "signal_ma20_breakdown",
"signal_n_day_high", "signal_n_day_low",
"signal_boll_breakout_upper", "signal_boll_breakdown_lower",
"signal_volume_surge",
]
existing = [c for c in needed_cols if c in df.columns]
df = df.select(existing)
# to_pandas 边界
return df.to_pandas(use_pyarrow_extension_array=False)
def _build_signal_matrix(
self,
panel: pd.DataFrame,
kinds: list[str],
config: BacktestConfig,
) -> pd.DataFrame:
"""从面板构造 [date × symbol] 的布尔信号矩阵。"""
if not kinds or panel.empty:
return pd.DataFrame()
# pivot 成 [date × symbol] 形式
result = None
for kind in kinds:
mat = None
if kind in _SIGNAL_COLS:
col = _SIGNAL_COLS[kind]
mat = panel.pivot(index="date", columns="symbol", values=col).fillna(False).astype(bool)
elif kind == "rsi_oversold":
mat = (panel.pivot(index="date", columns="symbol", values="rsi_14")
< config.rsi_oversold_threshold)
elif kind == "rsi_overbought":
mat = (panel.pivot(index="date", columns="symbol", values="rsi_14")
> config.rsi_overbought_threshold)
# stop_loss / trailing / max_hold 通过 vectorbt 参数处理,不参与信号矩阵
if mat is not None:
result = mat if result is None else (result | mat)
return result if result is not None else pd.DataFrame()
def run(self, config: BacktestConfig) -> BacktestResult:
vbt = _get_vbt()
run_id = uuid.uuid4().hex[:10]
panel = self._load_panel(config.symbols, config.start, config.end)
if panel.empty:
return BacktestResult(
run_id=run_id,
config=_config_to_dict(config),
stats={"error": "no data"},
equity_curve=[],
trades=[],
per_symbol_stats=[],
)
# 价格面板
close = panel.pivot(index="date", columns="symbol", values="close")
# 信号矩阵
entries = self._build_signal_matrix(panel, config.entries, config)
exits = self._build_signal_matrix(panel, config.exits, config)
# 对齐 index/columns
if not entries.empty:
entries = entries.reindex_like(close).fillna(False).astype(bool)
else:
entries = pd.DataFrame(False, index=close.index, columns=close.columns)
if not exits.empty:
exits = exits.reindex_like(close).fillna(False).astype(bool)
else:
exits = pd.DataFrame(False, index=close.index, columns=close.columns)
if not entries.any().any():
return BacktestResult(
run_id=run_id,
config=_config_to_dict(config),
stats={"error": "no buy signals"},
equity_curve=[],
trades=[],
per_symbol_stats=[],
)
# T+1 适配:vectorbt 默认信号当根 K 撮合
# close_t 撮合:维持默认
# open_t+1 撮合:shift 信号 1 根 + 用 open 作为价
if config.matching == "open_t+1":
entries = entries.shift(1).fillna(False).astype(bool)
exits = exits.shift(1).fillna(False).astype(bool)
price = panel.pivot(index="date", columns="symbol", values="open")
else:
price = close
# 跑回测
try:
pf_kwargs = dict(
close=close,
entries=entries,
exits=exits,
price=price,
fees=config.fees_pct,
slippage=config.slippage_bps / 10000.0,
freq="1D",
)
if config.stop_loss_pct is not None:
pf_kwargs["sl_stop"] = abs(config.stop_loss_pct)
if config.max_hold_days is not None:
# vectorbt 没有内置 max-hold;用时间退出近似:
# 在 max_hold_days 后强制 exit
exits_idx = entries.copy()
for col in entries.columns:
entry_rows = np.where(entries[col].values)[0]
for i in entry_rows:
end_i = min(i + config.max_hold_days, len(entries) - 1)
if end_i > i:
exits_idx.iloc[end_i][col] = True
pf_kwargs["exits"] = (exits | exits_idx).astype(bool)
pf = vbt.Portfolio.from_signals(**pf_kwargs)
except Exception as e: # noqa: BLE001
logger.exception("vectorbt backtest failed")
return BacktestResult(
run_id=run_id,
config=_config_to_dict(config),
stats={"error": str(e)},
equity_curve=[],
trades=[],
per_symbol_stats=[],
)
# 提取结果
try:
stats_series = pf.stats(silence_warnings=True)
if isinstance(stats_series, pd.DataFrame):
# 多列时取 agg
stats_dict = stats_series.mean(numeric_only=True).to_dict()
else:
stats_dict = stats_series.to_dict()
except Exception: # noqa: BLE001
stats_dict = {}
# 净值曲线(组合平均)
equity = pf.value().mean(axis=1) if isinstance(pf.value(), pd.DataFrame) else pf.value()
equity_curve = [
{"date": str(idx.date() if hasattr(idx, "date") else idx), "value": float(v)}
for idx, v in equity.items() if pd.notna(v)
]
# 交易记录
try:
trades_df = pf.trades.records_readable
trades = trades_df.to_dict(orient="records") if not trades_df.empty else []
# 字段名美化
trades = [
{
"symbol": t.get("Column", t.get("Symbol", "")),
"entry_date": str(t.get("Entry Timestamp", t.get("Entry Date", ""))),
"exit_date": str(t.get("Exit Timestamp", t.get("Exit Date", ""))),
"entry_price": float(t.get("Avg Entry Price", t.get("Avg. Entry Price", 0))),
"exit_price": float(t.get("Avg Exit Price", t.get("Avg. Exit Price", 0))),
"pnl_pct": float(t.get("Return", t.get("PnL %", 0))),
"duration": str(t.get("Duration", "")),
}
for t in trades
]
except Exception: # noqa: BLE001
trades = []
# 每标的统计
per_symbol = []
try:
total_ret = pf.total_return()
if isinstance(total_ret, pd.Series):
for sym, ret in total_ret.items():
if pd.notna(ret):
per_symbol.append({"symbol": sym, "total_return": float(ret)})
except Exception: # noqa: BLE001
pass
result = BacktestResult(
run_id=run_id,
config=_config_to_dict(config),
stats={k: _json_safe(v) for k, v in stats_dict.items()},
equity_curve=equity_curve,
trades=trades,
per_symbol_stats=per_symbol,
)
# 落盘
self._persist(result)
return result
def _persist(self, result: BacktestResult) -> None:
out_dir = settings.data_dir / "backtest_results"
out_dir.mkdir(parents=True, exist_ok=True)
# 用 polars 写一份汇总
summary = pl.DataFrame({
"run_id": [result.run_id],
"stats_json": [str(result.stats)],
"n_trades": [len(result.trades)],
})
summary.write_parquet(out_dir / f"run_id={result.run_id}.parquet")
def get_result(self, run_id: str) -> BacktestResult | None:
# Phase 1:只保留近似落盘,完整结果保存在内存的近期 cache 中
# 简化:重新 run 比缓存复杂结果代价小,暂不实现 get_result
return None
def _config_to_dict(c: BacktestConfig) -> dict:
return {
"symbols": c.symbols,
"start": str(c.start),
"end": str(c.end),
"entries": c.entries,
"exits": c.exits,
"stop_loss_pct": c.stop_loss_pct,
"max_hold_days": c.max_hold_days,
"fees_pct": c.fees_pct,
"slippage_bps": c.slippage_bps,
"matching": c.matching,
}
def _json_safe(v):
if isinstance(v, (int, float, str, bool)) or v is None:
return v
if isinstance(v, (np.floating, np.integer)):
return float(v) if not np.isnan(float(v)) else None
if hasattr(v, "isoformat"):
return v.isoformat()
return str(v)
@@ -285,8 +285,8 @@ async def analyze_rotation_stream(
repo,
days: int = 12,
focus: str = "",
quote_service=None,
depth_service=None,
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
@@ -294,7 +294,6 @@ async def analyze_rotation_stream(
repo: KlineRepository (必填)
days: 分析最近 N 个交易日 (7-30)
focus: 用户追加的关注点
quote_service / depth_service: 可选, 大盘背景装配依赖
"""
from app.services.rps_rotation import build_rps_rotation
from app.services.market_overview_builder import build_market_overview
@@ -316,7 +315,7 @@ async def analyze_rotation_stream(
# 3. 大盘背景 (失败不阻断, 降级为空)
try:
overview = build_market_overview(repo, quote_service, depth_service)
overview = build_market_overview(repo)
except Exception as e: # noqa: BLE001
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
overview = {}
@@ -332,7 +331,7 @@ async def analyze_rotation_stream(
try:
from app.services.ai_provider import stream_ai_text, ai_configured
if not ai_configured():
if not ai_configured(username=username):
yield json.dumps({
"type": "error",
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
@@ -347,6 +346,7 @@ async def analyze_rotation_stream(
],
temperature=0.5,
max_tokens=4000,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
-586
View File
@@ -1,586 +0,0 @@
"""五档盘口 sealed(真假涨停/跌停) 服务 — 独立旁路线。
架构(完全解耦):
- 只读 enriched(拿涨跌停名单), 不写回 enriched(14列不动)
- sealed 存独立 parquet(data/depth5/date=xxx/part.parquet)
- limit_ladder API 查询时 LEFT JOIN( ext_columns 机制)
- signal_limit_up 永远是"价格涨停", sealed 是叠加的真假判定层
数据流:
盘中轮询线程(交易时段, 独立 sleep, 不绑行情轮询):
enriched 内存缓存(线程安全) 涨跌停名单 tf.depth.batch
sealed 更新内存缓存(不落盘) sealed_ready=True
盘后定版 job(可配置时间, 默认15:02):
最后拉一次 落盘 depth5 parquet(定版)
三层防护节流("设过大设上限, 设过小设最小值"):
套餐范围 clamp: Pro 10~120s, Expert 3~300s
限速安全 clamp: safe = 60/((rpm*0.8)/batches), 涨跌停多就自动放慢
系统接管通知: 用户设置会超限时, toast 告知已自动调整
"""
from __future__ import annotations
import logging
import math
import threading
import time
from datetime import date, datetime, time as dt_time
from pathlib import Path
import polars as pl
logger = logging.getLogger(__name__)
# 套餐 → (轮询间隔下限s, 上限s)
TIER_INTERVAL_RANGE: dict[str, tuple[float, float]] = {
"pro": (10.0, 120.0),
"expert": (3.0, 300.0),
}
# 兜底: 其他有 DEPTH5_BATCH 的套餐按 pro 范围
DEFAULT_RANGE = (10.0, 120.0)
# 限速余量: 只用 rpm 的 80%, 给系统其他 depth 调用留空间
RPM_MARGIN = 0.8
# 间隔硬下限/上限(任何套餐)
INTERVAL_HARD_MIN = 10.0
INTERVAL_HARD_MAX = 300.0
class DepthService:
"""五档盘口 sealed 服务 — 单例。"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._running = False
self._thread: threading.Thread | None = None
self._repo = None # 延迟注入(KlineRepository)
self._app_state = None # 延迟注入(FastAPI app.state)
# 内存缓存: {symbol: SealedEntry}
# SealedEntry = {sealed_up, sealed_down, ask1_vol, bid1_vol, status, fetched_ts}
self._sealed_cache: dict[str, dict] = {}
self._sealed_ready = False
self._sealed_date: date | None = None # sealed 数据对应的交易日(可能是昨天,如休市)
self._sealed_fetched_ts: float = 0.0 # 上次拉取的 perf_counter
self._sealed_fetched_at: float = 0.0 # 上次拉取的 wall-clock 时间戳
self._persisted_date: date | None = None # 已落盘的日期
# 系统接管状态(防通知刷屏)
self._last_taken_over: bool | None = None
self._last_user_interval: float | None = None
# ================================================================
# 注入
# ================================================================
def set_repo(self, repo) -> None:
self._repo = repo
def set_app_state(self, app_state) -> None:
self._app_state = app_state
# ================================================================
# 生命周期
# ================================================================
def boot_check(self) -> None:
"""启动补跑: 当天 depth5 文件不存在则 finalize 一次; 已存在则恢复内存缓存。"""
if not self._has_capability():
logger.info("depth sealed: 无 DEPTH5_BATCH 能力, 跳过启动补跑")
return
today = date.today()
if self._persisted_for_date(today):
# parquet 已存在: 恢复内存缓存(避免重启后每次查询都读 parquet)
self._restore_from_parquet(today)
return
logger.info("depth sealed: 启动补跑今天定版")
try:
self.finalize()
except Exception as e: # noqa: BLE001
logger.warning("depth sealed 启动补跑失败: %s", e)
def _restore_from_parquet(self, d: date) -> None:
"""从 parquet 恢复内存缓存(服务重启后)。"""
if not self._repo:
return
out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet"
if not out.exists():
return
try:
df = pl.read_parquet(out)
cache: dict[str, dict] = {}
for row in df.to_dicts():
sym = row.get("symbol")
if not sym:
continue
cache[sym] = {
"sealed_up": row.get("sealed_up"),
"sealed_down": row.get("sealed_down"),
"ask1_vol": row.get("ask1_vol"),
"bid1_vol": row.get("bid1_vol"),
"status": row.get("status"),
"fetched_ts": row.get("fetched_at"),
}
with self._lock:
self._sealed_cache = cache
self._sealed_ready = True
self._sealed_date = d
self._persisted_date = d
logger.info("depth sealed: 从 parquet 恢复 %d 只 (日期=%s)", len(cache), d)
except Exception as e: # noqa: BLE001
logger.warning("depth sealed 从 parquet 恢复失败: %s", e)
def start_polling(self) -> None:
"""启动盘中轮询线程(连板梯队监控开启 + 有能力 + 交易时段)。"""
if self._running:
return
if not self._has_capability():
return
from app.services import preferences
if not preferences.get_limit_ladder_monitor_enabled():
return
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
logger.info("depth sealed 盘中轮询已启动")
def stop_polling(self) -> None:
"""停止盘中轮询线程。"""
self._running = False
if self._thread:
self._thread.join(timeout=10)
self._thread = None
logger.info("depth sealed 盘中轮询已停止")
def apply_monitor_toggle(self, enabled: bool) -> None:
"""连板梯队监控开关切换时调用: 开启→启动轮询, 关闭→停止轮询。"""
if enabled:
self.start_polling()
else:
self.stop_polling()
def run_once(self) -> dict:
"""手动触发一次修正(立即拉取 depth + 更新内存缓存)。
不受监控开关限制 用户可随时手动修正一次
返回 {"ok": bool, "count": int, "msg": str}
"""
if not self._has_capability():
return {"ok": False, "count": 0, "msg": "无五档盘口能力(需 Pro+)"}
try:
self._fetch_and_seal(persist=True) # 落盘, 刷新页面不丢
with self._lock:
count = len(self._sealed_cache)
return {"ok": True, "count": count, "msg": f"已修正 {count}"}
except Exception as e: # noqa: BLE001
logger.warning("depth run_once 失败: %s", e)
return {"ok": False, "count": 0, "msg": f"修正失败: {e}"}
# ================================================================
# 核心拉取
# ================================================================
def _fetch_and_seal(self, persist: bool = False) -> None:
"""拉一次 depth.batch, 算 sealed, 更新内存缓存(可选落盘)。
persist=True: 盘后定版, depth5 parquet
persist=False: 盘中轮询, 只更新内存缓存
"""
if not self._repo:
return
# 只读 enriched 内存缓存(线程安全, 避免和 quote_service 写盘竞态)
enriched, enriched_date = self._repo.get_enriched_latest()
if enriched.is_empty():
return
# 筛涨跌停名单(用 fill_null 防止列缺失)
syms_up: list[str] = []
syms_down: list[str] = []
if "signal_limit_up" in enriched.columns:
syms_up = enriched.filter(
pl.col("signal_limit_up").fill_null(False)
)["symbol"].to_list()
if "signal_limit_down" in enriched.columns:
syms_down = enriched.filter(
pl.col("signal_limit_down").fill_null(False)
)["symbol"].to_list()
all_syms = list(dict.fromkeys(syms_up + syms_down)) # 去重保序
if not all_syms:
logger.debug("depth sealed: 当日无涨跌停股, 跳过")
return
# 拉 depth(涨跌停一次拉, 按 capset batch 切片)
depth_data = self._call_depth_batch(all_syms)
if not depth_data:
logger.warning("depth sealed: depth.batch 返回空")
return
up_set = set(syms_up)
down_set = set(syms_down)
now_perf = time.perf_counter()
now_wall = time.time()
new_cache: dict[str, dict] = {}
for sym, d in depth_data.items():
ask_vols = d.get("ask_volumes") or []
bid_vols = d.get("bid_volumes") or []
ask1 = ask_vols[0] if ask_vols else None
bid1 = bid_vols[0] if bid_vols else None
# depth 返回的 timestamp(毫秒 epoch), 回退到当前 wall-clock
depth_ts = d.get("timestamp")
fetched = (depth_ts / 1000.0) if isinstance(depth_ts, (int, float)) and depth_ts else now_wall
entry = {
# 涨停真封: 涨停价上卖一(主动卖压)为 0
"sealed_up": (ask1 == 0) if sym in up_set and ask1 is not None else None,
# 跌停真封: 跌停价上买一为 0
"sealed_down": (bid1 == 0) if sym in down_set and bid1 is not None else None,
"ask1_vol": ask1,
"bid1_vol": bid1,
"status": "limit_down" if sym in down_set and sym not in up_set else "limit_up",
"fetched_ts": fetched,
}
new_cache[sym] = entry
with self._lock:
self._sealed_cache = new_cache
self._sealed_ready = True
self._sealed_date = enriched_date # 记录数据对应的交易日(可能是昨天,如休市)
self._sealed_fetched_ts = now_perf
self._sealed_fetched_at = now_wall
logger.info("depth sealed: 拉取 %d 只 (涨停%d/跌停%d) 日期=%s%s",
len(new_cache), len(syms_up), len(syms_down),
enriched_date, " → 落盘" if persist else "")
# 缓存已更新: 通知 SSE 推 depth_updated, 触发连板梯队刷新封单数据。
self._notify_depth_updated(len(new_cache))
if persist and enriched_date:
self._persist(enriched_date)
def _call_depth_batch(self, symbols: list[str]) -> dict:
"""调 tf.depth.batch, 按 capset 的 batch 切片 + 节流。返回 {symbol: MarketDepth}。"""
from app.tickflow.client import get_client
tf = get_client()
capset = self._get_capset()
lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH)
batch_size = (lim.batch if lim and lim.batch else 100)
rpm = (lim.rpm if lim and lim.rpm else 30)
# 批间隔 = 60/rpm(匀速)
inter_batch = 60.0 / rpm if rpm > 0 else 2.0
result: dict = {}
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
for i, chunk in enumerate(chunks):
if i > 0:
time.sleep(inter_batch)
try:
# SDK 的 batch 内部已按 batch_size 切, 这里再切一层防单请求过大
data = tf.depth.batch(chunk)
if isinstance(data, dict):
result.update(data)
except Exception as e: # noqa: BLE001
logger.warning("depth.batch 第 %d 批失败(%d 只): %s", i + 1, len(chunk), e)
# 单批失败不影响其他批
return result
def finalize(self) -> None:
"""盘后定版: 拉一次 + 落盘。"""
if not self._has_capability():
return
self._fetch_and_seal(persist=True)
# ================================================================
# 落盘
# ================================================================
def _persist(self, today: date) -> None:
"""把内存缓存写 depth5/date=今天/part.parquet。"""
with self._lock:
cache = dict(self._sealed_cache)
if not cache:
return
rows = []
for sym, e in cache.items():
rows.append({
"symbol": sym,
"sealed_up": e.get("sealed_up"),
"sealed_down": e.get("sealed_down"),
"ask1_vol": e.get("ask1_vol"),
"bid1_vol": e.get("bid1_vol"),
"status": e.get("status"),
"fetched_at": e.get("fetched_ts"),
})
# 显式 schema: sealed_up/sealed_down 是 bool 与 None 混合, 不指定 schema
# polars 会按首行推断类型, 后续遇到不一致 (bool vs null) 报
# "could not append value: false of type: bool to the builder"。
df = pl.DataFrame(rows, schema={
"symbol": pl.Utf8,
"sealed_up": pl.Boolean,
"sealed_down": pl.Boolean,
"ask1_vol": pl.Int64,
"bid1_vol": pl.Int64,
"status": pl.Utf8,
"fetched_at": pl.Float64,
})
ds = today.isoformat()
out = self._repo.store.data_dir / "depth5" / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
df.write_parquet(out)
self._persisted_date = today
logger.info("depth sealed 落盘: %d 行 → %s", df.height, out)
def _persisted_for_date(self, d: date) -> bool:
"""检查某日 depth5 文件是否已存在。"""
if not self._repo:
return False
out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet"
return out.exists()
# ================================================================
# 查询(供 limit_ladder API 用)
# ================================================================
def get_sealed_map(self, target_date: date, is_down: bool) -> dict:
"""返回 {symbol: {sealed, vol, ready, age}} 供 JOIN。
优先内存缓存(盘中), 回退 parquet(历史/盘后)
sealed: bool | None (None=待确认或降级)
vol: 封单量(int) | None
ready: sealed 数据是否就绪(False降级标识)
age: 距上次拉取秒数(盘后定版为 None)
"""
# 内存缓存(sealed 数据对应的交易日 = target_date 时才用)
if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_cache:
return self._read_from_memory(is_down)
# parquet(历史或盘后定版)
return self._read_from_parquet(target_date, is_down)
def _read_from_memory(self, is_down: bool) -> dict:
sealed_key = "sealed_down" if is_down else "sealed_up"
# 封单量: 涨停=买一量(涨停价买单堆积), 跌停=卖一量(跌停价卖单堆积)
vol_key = "ask1_vol" if is_down else "bid1_vol"
now = time.perf_counter()
with self._lock:
cache = dict(self._sealed_cache)
fetched_ts = self._sealed_fetched_ts
age = (now - fetched_ts) if fetched_ts else 0.0
result = {}
for sym, e in cache.items():
result[sym] = {
"sealed": e.get(sealed_key),
"vol": e.get(vol_key),
"ready": True,
"age": age,
}
return result
def _read_from_parquet(self, target_date: date, is_down: bool) -> dict:
if not self._repo:
return {}
out = self._repo.store.data_dir / "depth5" / f"date={target_date.isoformat()}" / "part.parquet"
if not out.exists():
return {}
try:
df = pl.read_parquet(out)
except Exception as e: # noqa: BLE001
logger.warning("depth5 parquet 读取失败: %s", e)
return {}
sealed_key = "sealed_down" if is_down else "sealed_up"
# 封单量: 涨停=买一量, 跌停=卖一量
vol_key = "ask1_vol" if is_down else "bid1_vol"
result = {}
for row in df.to_dicts():
sym = row.get("symbol")
if not sym:
continue
result[sym] = {
"sealed": row.get(sealed_key),
"vol": row.get(vol_key),
"ready": True,
"age": None, # 盘后定版, 无 age
}
return result
def is_sealed_ready(self, target_date: date) -> bool:
"""sealed 数据是否就绪(供前端降级判定)。"""
# 内存缓存对应的数据日 == 查询日 → 看内存就绪状态
if self._sealed_date and target_date == self._sealed_date:
return self._sealed_ready
# 其他日期: 有 parquet 就 ready
return self._persisted_for_date(target_date)
def get_sealed_age(self, target_date: date) -> float | None:
"""返回 sealed 数据 age(秒), 盘后定版为 None。"""
if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_fetched_ts:
return time.perf_counter() - self._sealed_fetched_ts
return None
# ================================================================
# 盘中轮询线程
# ================================================================
def _poll_loop(self) -> None:
"""盘中轮询: 按 capset 自适应间隔拉 depth, 更新内存缓存。"""
while self._running:
try:
if self._is_trading_hours():
self._poll_once()
else:
logger.debug("depth sealed: 非交易时段, 跳过")
except Exception as e: # noqa: BLE001
logger.warning("depth sealed 轮询异常: %s", e)
# 等待下一轮(用 _running 检查保证能及时退出)
interval = self._current_sleep_interval()
waited = 0.0
while self._running and waited < interval:
time.sleep(0.5)
waited += 0.5
def _poll_once(self) -> None:
"""单次轮询: 算间隔(三层防护) → 拉取 → 检测系统接管通知。"""
# 数当前涨跌停股
n = self._count_limit_stocks()
if n == 0:
return
interval, taken_over, user_interval = self._compute_interval(n)
# 系统接管通知(状态切换时才推, 防刷屏)
if taken_over and (self._last_taken_over is False or self._last_user_interval != user_interval):
self._notify_takeover(n, user_interval, interval)
self._last_taken_over = taken_over
self._last_user_interval = user_interval
self._fetch_and_seal(persist=False)
def _current_sleep_interval(self) -> float:
"""计算当前 sleep 间隔(供 _poll_loop 等待用)。"""
n = self._count_limit_stocks()
if n == 0:
return 30.0 # 无涨跌停, 慢轮询
interval, _, _ = self._compute_interval(n)
return interval
# ================================================================
# 三层防护节流
# ================================================================
def _compute_interval(self, n_symbols: int) -> tuple[float, bool, float]:
"""三层防护计算实际轮询间隔。
返回 (actual_interval, taken_over, user_interval)
- actual_interval: 实际使用的间隔()
- taken_over: 是否被系统接管(用户设置会超限)
- user_interval: 用户设置(经套餐 clamp )的间隔
"""
from app.services import preferences
from app.tickflow.policy import tier_label
capset = self._get_capset()
lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH)
batch_size = (lim.batch if lim and lim.batch else 100)
rpm = (lim.rpm if lim and lim.rpm else 30)
# ① 套餐范围 clamp
tier = tier_label().split()[0].split("+")[0].strip().lower()
lo, hi = TIER_INTERVAL_RANGE.get(tier, DEFAULT_RANGE)
raw_user = preferences.get_depth_polling_interval()
user_interval = max(lo, min(hi, raw_user))
# ② 限速安全 clamp
batches = max(1, math.ceil(n_symbols / batch_size))
usable_rpm = rpm * RPM_MARGIN
calls_per_min = usable_rpm / batches if batches > 0 else usable_rpm
safe_interval = 60.0 / calls_per_min if calls_per_min > 0 else INTERVAL_HARD_MAX
# 实际: 取用户设置和安全的较大值
actual = max(user_interval, safe_interval)
# 硬上下限
actual = max(INTERVAL_HARD_MIN, min(actual, INTERVAL_HARD_MAX))
taken_over = safe_interval > user_interval
return actual, taken_over, user_interval
def _count_limit_stocks(self) -> int:
"""数当前涨跌停股总数(供节流计算)。"""
if not self._repo:
return 0
enriched, _ = self._repo.get_enriched_latest()
if enriched.is_empty():
return 0
n = 0
if "signal_limit_up" in enriched.columns:
n += enriched.filter(pl.col("signal_limit_up").fill_null(False)).height
if "signal_limit_down" in enriched.columns:
n += enriched.filter(pl.col("signal_limit_down").fill_null(False)).height
return n
# ================================================================
# 通知
# ================================================================
def _notify_takeover(self, n_stocks: int, user_interval: float, actual_interval: float) -> None:
"""系统接管通知: 复用 quote_service 的 _pending_alerts 通道。"""
if not self._app_state:
return
qs = getattr(self._app_state, "quote_service", None)
if not qs:
return
msg = (f"五档轮询: 当前涨跌停 {n_stocks} 只, 您设置的 {user_interval:.0f} 秒间隔会超限, "
f"系统已自动调整为 {actual_interval:.0f}")
alert = {
"source": "depth",
"type": "takeover",
"message": msg,
}
try:
with qs._lock:
qs._pending_alerts.append(alert)
qs._alert_event.set()
except Exception as e: # noqa: BLE001
logger.debug("depth 接管通知推送失败: %s", e)
def _notify_depth_updated(self, count: int) -> None:
"""修正完成通知: set quote_service._depth_update_event, SSE 推 depth_updated 刷新连板梯队。"""
if not self._app_state:
return
qs = getattr(self._app_state, "quote_service", None)
if not qs:
return
try:
qs.notify_depth_updated()
except Exception as e: # noqa: BLE001
logger.debug("depth 更新通知推送失败: %s", e)
# ================================================================
# 工具
# ================================================================
def _has_capability(self) -> bool:
capset = self._get_capset()
from app.tickflow.capabilities import Cap
return capset.has(Cap.DEPTH5_BATCH)
def _get_capset(self):
"""获取当前 capset(优先 app.state, 回退 detect)。"""
if self._app_state:
cs = getattr(self._app_state, "capabilities", None)
if cs:
return cs
from app.tickflow.policy import detect_capabilities
return detect_capabilities()
@staticmethod
def _is_trading_hours() -> bool:
now = datetime.now()
t = now.time()
morning = dt_time(9, 25) <= t <= dt_time(11, 35)
afternoon = dt_time(12, 55) <= t <= dt_time(15, 5)
return now.weekday() < 5 and (morning or afternoon)
+2 -3
View File
@@ -38,7 +38,7 @@ def _invalidate(table: str | None = None) -> None:
def _resolve_universe(capset: CapabilitySet) -> list[str]:
"""解析标的池 — 与 daily_pipeline 独立的副本"""
"""解析标的池。"""
if capset.has(Cap.KLINE_DAILY_BATCH):
try:
from app.tickflow.pools import get_pool
@@ -48,12 +48,11 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
except Exception as e:
logger.warning("CN_Equity_A pool unavailable: %s", e)
from app.tickflow.pools import DEMO_SYMBOLS, get_pool as _get_pool
from app.tickflow.pools import DEMO_SYMBOLS
from app.config import settings
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"
if inst_path.exists():
@@ -141,6 +141,8 @@ async def analyze_financials_stream(
data_dir: Path,
symbol: str,
focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式分析:yield 出每个文本 chunk。
@@ -176,6 +178,7 @@ async def analyze_financials_stream(
],
temperature=0.4,
max_tokens=4000,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -3,11 +3,8 @@
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让大盘复盘
等无 Request 的调用方(定时任务复盘服务)也能复用同一套聚合逻辑
行为与原 `_build_overview` 完全一致,仅把对 `request.app.state.{repo,
quote_service,depth_service}` 的依赖改为显式参数
公共入口:
build_market_overview(repo, quote_service, depth_service, as_of)
build_market_overview(repo, as_of)
"""
from __future__ import annotations
@@ -81,24 +78,12 @@ def _score(value: float, low: float, high: float) -> int:
# ================================================================
# 指数行情(实时 quote_service 优先,回退 kline_index_daily SQL)
# 指数行情( kline_index_daily SQL 读取)
# ================================================================
def _quote_status(quote_service) -> dict:
qs = quote_service
if not qs:
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
return qs.status()
def _index_quotes(repo, quote_service, as_of: date | None = None) -> list[dict]:
def _index_quotes(repo, as_of: date | None = None) -> list[dict]:
rows: list[dict] = []
if quote_service and as_of is None:
df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS))
if not df.is_empty():
rows = df.to_dicts()
if not rows and repo:
if repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
try:
db_rows = repo.execute_all(
@@ -354,27 +339,21 @@ def _pct_band_rows(values: list[float]) -> list[dict]:
def build_market_overview(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
) -> dict:
"""装配市场总览(与原 overview._build_overview 行为一致)。
Args:
repo: KlineRepository(必填)
quote_service: QuoteService(可选;实时指数行情来源)
depth_service: DepthService(可选;五档封板修正)
as_of: 指定日期,None 则取最新有数据日
"""
svc = ScreenerService(repo)
as_of = as_of or svc.latest_date()
status = _quote_status(quote_service)
indices = _index_quotes(repo, quote_service, as_of)
indices = _index_quotes(repo, as_of)
if not as_of:
return {
"as_of": None,
"quote_status": status,
"indices": indices,
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
"amount": {"total": 0, "avg": 0},
@@ -434,22 +413,6 @@ def build_market_overview(
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
sealed_ready = False
fake_up = 0
fake_down = 0
if depth_service:
up_map = depth_service.get_sealed_map(as_of, is_down=False)
down_map = depth_service.get_sealed_map(as_of, is_down=True)
sealed_ready = bool(up_map or down_map) and depth_service.is_sealed_ready(as_of)
if up_map:
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
if down_map:
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
if sealed_ready:
limit_up = max(0, limit_up - fake_up)
limit_down = max(0, limit_down - fake_down)
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
def above_ma_count(ma_key: str) -> int:
@@ -531,7 +494,6 @@ def build_market_overview(
return _json_safe({
"as_of": str(as_of),
"quote_status": status,
"indices": indices,
"breadth": {
"total": total,
@@ -547,7 +509,7 @@ def build_market_overview(
},
"amount": {"total": total_amount, "avg": avg_amount},
"boards": boards,
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down},
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers},
"distribution": _pct_band_rows(pct_values),
"trend": {
"above_ma5": above_ma5,
+5 -7
View File
@@ -252,23 +252,22 @@ def _recap_summary(overview: dict) -> str:
async def recap_market_stream(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式大盘复盘:yield 出每个 NDJSON 事件。
Args:
repo: KlineRepository(必填)
quote_service / depth_service: 可选,数据装配依赖
as_of: 复盘日期,None 取最新有数据日
focus: 用户追加的复盘关注点
news: 预检索的新闻列表(P1 不传, None 走降级说明;P3 news_search 注入)
"""
# 1. 装配市场总览
overview = build_market_overview(repo, quote_service, depth_service, as_of)
overview = build_market_overview(repo, as_of)
as_of_str = overview.get("as_of")
if not as_of_str:
@@ -301,6 +300,7 @@ async def recap_market_stream(
],
temperature=0.5,
max_tokens=4500,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -314,8 +314,6 @@ async def recap_market_stream(
async def recap_market_once(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
@@ -327,7 +325,7 @@ async def recap_market_once(
"""
content_parts: list[str] = []
meta: dict = {"as_of": as_of.isoformat() if as_of else None}
async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news):
async for evt in recap_market_stream(repo, as_of, focus, news):
try:
obj = json.loads(evt)
except Exception: # noqa: BLE001
@@ -1,24 +1,8 @@
"""AI 大盘复盘报告持久化存储。
"""AI 大盘复盘报告持久化存储 — 按用户名隔离
stock_reports.py(个股分析报告)/ ai_reports.py(财务分析报告)完全独立
单独的文件字段上限,互不影响刻意不复用,避免引入 kind 判别字段与分支
(解耦 > 抽象)
存储位置: data/user_data/ai_market_recaps.json (数组, created_at 降序)
保留最近 MAX_REPORTS ;超出自动裁剪最旧的
每条报告结构:
{
"id": "mkr_xxx", # 唯一 id(market-recap-report)
"as_of": "2026-06-27", # 复盘日期
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"summary": "三大指数齐涨...", # 一句话摘要
"emotion_score": 68, # 情绪分(0-100, 复盘生成时的市场情绪雷达均分)
"emotion_label": "偏暖", # 情绪标签(强势/偏暖/震荡/偏冷/冰点)
"created_at": "2026-06-27T15:35:00"
}
存储位置: data/user_data/reports/{username}/ai_market_recaps.json
"""
from __future__ import annotations
import json
@@ -31,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 20
def _path() -> Path:
def _path(username: str | None = None) -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_market_recaps.json"
if username:
p = settings.data_dir / "user_data" / "reports" / username / "ai_market_recaps.json"
else:
p = settings.data_dir / "user_data" / "ai_market_recaps.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_reports() -> list[dict]:
"""返回全部报告(按 created_at 降序)。"""
p = _path()
def list_reports(username: str | None = None) -> list[dict]:
"""返回指定用户的全部报告(按 created_at 降序)。"""
p = _path(username)
if not p.exists():
return []
try:
@@ -52,37 +39,37 @@ def list_reports() -> list[dict]:
return []
def _save_all(reports: list[dict]) -> None:
def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS]
_path().write_text(
_path(username).write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
)
def save_report(report: dict) -> dict:
def save_report(report: dict, username: str | None = None) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports()
reports = list_reports(username)
if not report.get("id"):
report["id"] = f"mkr_{int(time.time() * 1000)}"
if not report.get("created_at"):
report["created_at"] = _now_iso()
reports.append(report)
_save_all(reports)
logger.info("Market recap saved: %s (as_of=%s), total %d",
report.get("id"), report.get("as_of"), len(reports))
_save_all(reports, username)
logger.info("Market recap saved by %s: %s (as_of=%s), total %d",
username or "?", report.get("id"), report.get("as_of"), len(reports))
return report
def delete_report(report_id: str) -> bool:
"""删除指定报告。返回是否删除成功。"""
reports = list_reports()
def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定用户的报告。返回是否删除成功。"""
reports = list_reports(username)
before = len(reports)
reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before:
_save_all(reports)
_save_all(reports, username)
return True
return False
-250
View File
@@ -39,53 +39,12 @@ def save(updates: dict) -> dict:
return current
def get_realtime_quotes_enabled() -> bool:
return load().get("realtime_quotes_enabled", False)
def get_indices_nav_pinned() -> bool:
"""侧栏指数报价卡片是否固定显示。默认 True(常驻)。
关闭后卡片跟随实时行情开关仅实时开时显示"""
return load().get("indices_nav_pinned", True)
def get_realtime_quote_interval() -> float:
return load().get("realtime_quote_interval", 10.0)
def get_realtime_watchlist_symbols() -> list[str]:
"""Free 档自选实时监控标的:直接取自选页前 5 个。"""
try:
from app.services import watchlist
rows = watchlist.list_symbols()
except Exception as e: # noqa: BLE001
logger.warning("load watchlist for realtime failed: %s", e)
return []
out: list[str] = []
for row in rows:
symbol = str((row or {}).get("symbol") or "").strip().upper()
if symbol and symbol not in out:
out.append(symbol)
if len(out) >= 5:
break
return out
def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001
"""兼容旧接口: Free 实时标的现在由自选页前 5 个决定。"""
return get_realtime_watchlist_symbols()
def set_realtime_quote_interval(interval: float) -> float:
"""保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。"""
current = load()
current["realtime_quote_interval"] = interval
_path().write_text(
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
)
return interval
def get_minute_sync_enabled() -> bool:
return load().get("minute_sync_enabled", False)
@@ -116,11 +75,6 @@ def get_minute_data_provider() -> str:
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
def get_realtime_data_provider() -> str:
# 盘中实时现阶段仅支持 TickFlow。
return "tickflow"
# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) =====
def get_pipeline_pull_a_share() -> bool:
@@ -227,44 +181,6 @@ def set_index_daily_batch_size(size: int) -> int:
return size
# ── 五档盘口 sealed(真假涨停) 配置 ──────────────────────
def get_limit_ladder_monitor_enabled() -> bool:
"""连板梯队 5 档监控开关。关闭时 depth 不轮询(连板梯队降级显示)。"""
return load().get("limit_ladder_monitor_enabled", False)
def get_depth_polling_interval() -> float:
"""depth 盘中轮询间隔(秒)。默认 20(Pro/Expert 都适用)。"""
return float(load().get("depth_polling_interval", 20.0))
def set_depth_polling_interval(interval: float) -> float:
"""保存 depth 轮询间隔。套餐范围 clamp 由 depth_service 按档位做。"""
interval = max(1.0, min(600.0, float(interval)))
save({"depth_polling_interval": interval})
return interval
def get_depth_finalize_time() -> dict:
"""盘后 sealed 定版时间 {"hour": 15, "minute": 2}。范围 15:01~18:00。"""
d = load().get("depth_finalize_time", {"hour": 15, "minute": 2})
return {"hour": d.get("hour", 15), "minute": d.get("minute", 2)}
def set_depth_finalize_time(hour: int, minute: int) -> dict:
"""保存盘后 sealed 定版时间,强制范围 15:01~18:00。"""
h = max(0, min(23, hour))
m = max(0, min(59, minute))
# 下限 15:01, 上限 18:00
if h * 60 + m < 15 * 60 + 1:
h, m = 15, 1
if h * 60 + m > 18 * 60:
h, m = 18, 0
save({"depth_finalize_time": {"hour": h, "minute": m}})
return {"hour": h, "minute": m}
# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位)
# 多选: 不推送 = 空数组, 而非 'none'
REVIEW_PUSH_CHANNELS = {"feishu"}
@@ -333,86 +249,9 @@ def set_review_push_channels(channels: list[str]) -> list[str]:
# ===== 实时监控 =====
# 页面 SSE 刷新配置: { "watchlist": true, "monitor": true, ... }
# 可刷新的页面列表及其默认值
SSE_REFRESH_PAGES_DEFAULT = {
"watchlist": True,
"limit-ladder": False,
}
SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"]
# ===== 盘中实时行情范围 (独立于盘后管道范围) =====
def get_realtime_pull_stock() -> bool:
return load().get("realtime_pull_stock", True)
def get_realtime_pull_etf() -> bool:
# 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。
return load().get("realtime_pull_etf", False)
def get_realtime_pull_index() -> bool:
return load().get("realtime_pull_index", True)
def get_realtime_index_mode() -> str:
mode = str(load().get("realtime_index_mode", "core") or "core").lower()
return mode if mode in {"core", "all"} else "core"
def get_realtime_index_symbols() -> list[str]:
stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
if isinstance(stored, str):
import re
stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()]
return [str(s) for s in stored if str(s).strip()]
def set_realtime_quote_scope(cfg: dict) -> dict:
updates = {}
for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"):
if key in cfg and cfg[key] is not None:
updates[key] = bool(cfg[key])
if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}:
updates["realtime_index_mode"] = cfg["realtime_index_mode"]
if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None:
updates["realtime_index_symbols"] = cfg["realtime_index_symbols"]
if updates:
save(updates)
return get_realtime_quote_scope()
def get_realtime_quote_scope() -> dict:
return {
"realtime_pull_stock": get_realtime_pull_stock(),
"realtime_pull_etf": get_realtime_pull_etf(),
"realtime_pull_index": get_realtime_pull_index(),
"realtime_index_mode": get_realtime_index_mode(),
"realtime_index_symbols": get_realtime_index_symbols(),
}
def get_sse_refresh_pages() -> dict[str, bool]:
"""返回每个页面的 SSE 刷新开关。"""
stored = load().get("sse_refresh_pages", {})
# 合并默认值 (新增页面自动出现)
result = dict(SSE_REFRESH_PAGES_DEFAULT)
result.update(stored)
return result
def set_sse_refresh_pages(pages: dict[str, bool]) -> dict[str, bool]:
"""保存页面 SSE 刷新配置。"""
save({"sse_refresh_pages": pages})
return get_sse_refresh_pages()
def get_sidebar_index_symbols() -> list[str]:
"""返回左侧菜单显示的指数代码。"""
stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
@@ -420,22 +259,6 @@ def get_sidebar_index_symbols() -> list[str]:
return [s for s in stored if s in allowed]
def get_strategy_monitor_enabled() -> bool:
"""策略告警评估总开关。"""
return load().get("strategy_monitor_enabled", False)
def get_system_notify_enabled() -> bool:
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。"""
return load().get("system_notify_enabled", False)
def set_system_notify_enabled(enabled: bool) -> bool:
"""保存系统通知开关。"""
save({"system_notify_enabled": bool(enabled)})
return bool(enabled)
def get_feishu_webhook_url() -> str:
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
return load().get("feishu_webhook_url", "")
@@ -446,73 +269,11 @@ def get_feishu_webhook_secret() -> str:
return load().get("feishu_webhook_secret", "")
def set_feishu_webhook_url(url: str) -> str:
"""保存飞书 Webhook 地址。传入空串表示清空配置。"""
save({"feishu_webhook_url": str(url or "").strip()})
return get_feishu_webhook_url()
def set_feishu_webhook_secret(secret: str) -> str:
"""保存飞书签名密钥。传入空串表示不验签。"""
save({"feishu_webhook_secret": str(secret or "").strip()})
return get_feishu_webhook_secret()
def get_webhook_enabled_default() -> bool:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定
此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改
"""
return load().get("webhook_enabled_default", False)
def set_webhook_enabled_default(enabled: bool) -> bool:
"""保存飞书推送默认勾选态。"""
save({"webhook_enabled_default": bool(enabled)})
return get_webhook_enabled_default()
def get_screener_auto_run() -> bool:
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
return load().get("screener_auto_run", True)
def get_strategy_monitor_ids() -> list[str]:
"""返回监控池中的策略 ID。"""
return load().get("strategy_monitor_ids", [])
def set_realtime_monitor_config(cfg: dict) -> dict:
"""批量更新实时监控配置。"""
updates = {}
if "sse_refresh_pages" in cfg:
updates["sse_refresh_pages"] = cfg["sse_refresh_pages"]
if "strategy_monitor_enabled" in cfg:
updates["strategy_monitor_enabled"] = cfg["strategy_monitor_enabled"]
if "strategy_monitor_ids" in cfg:
updates["strategy_monitor_ids"] = cfg["strategy_monitor_ids"]
if "sidebar_index_symbols" in cfg:
allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT)
updates["sidebar_index_symbols"] = [s for s in cfg["sidebar_index_symbols"] if s in allowed]
if "screener_auto_run" in cfg:
updates["screener_auto_run"] = bool(cfg["screener_auto_run"])
if updates:
save(updates)
return get_realtime_monitor_config()
def get_realtime_monitor_config() -> dict:
"""返回完整的实时监控配置。"""
return {
"sse_refresh_pages": get_sse_refresh_pages(),
"strategy_monitor_enabled": get_strategy_monitor_enabled(),
"strategy_monitor_ids": get_strategy_monitor_ids(),
"sidebar_index_symbols": get_sidebar_index_symbols(),
"screener_auto_run": get_screener_auto_run(),
}
def get_nav_order() -> list[str]:
"""返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。"""
return load().get("nav_order", [])
@@ -535,17 +296,6 @@ def set_nav_hidden(hidden: list[str]) -> list[str]:
return get_nav_hidden()
def get_watchlist_columns() -> list[dict] | None:
"""返回自选列表列配置。"""
return load().get("watchlist_columns")
def set_watchlist_columns(columns: list[dict]) -> list[dict]:
"""保存自选列表列配置。"""
save({"watchlist_columns": columns})
return columns
def get_screener_result_columns() -> list[dict] | None:
"""返回策略结果列表列配置。"""
return load().get("screener_result_columns")
-971
View File
@@ -1,971 +0,0 @@
"""全局实时行情服务。
集中管理全市场行情拉取 + enriched 缓存供盘中选股自选股等所有模块复用
架构:
- 后台线程轮询 TickFlow get_by_universes(["CN_Equity_A", "CN_Index"])
- 拉取行情 kline_daily (不复权) + 增量计算 enriched 写盘 + 更新缓存
- _enriched_cache 是唯一的盘中数据源 (OHLCV + 全套技术指标)
- _live_agg_cache 是递推状态 (只加载一次, 盘中不变)
数据流 (每轮 ~15s):
1. API 拉取 raw_records (临时变量)
2. raw_records kline_daily (不复权原始价格)
3. raw_records 更新 _enriched_cache OHLCV
4. 增量计算 enriched 指标 (~50ms)
5. kline_daily_enriched + 替换 _enriched_cache
6. 通知 SSE
生命周期:
- 服务启动时读取 preferences enabled 则自动启动线程
- 运行中可通过 API 切换开关
- 关闭时停止线程
"""
from __future__ import annotations
import logging
import threading
import time
from datetime import date, datetime, time as dt_time
import polars as pl
logger = logging.getLogger(__name__)
class QuoteService:
"""全局实时行情服务 — 单例。"""
CORE_INDEX_SYMBOLS = ("000001.SH", "399001.SZ", "399006.SZ", "000680.SH")
# 档位 → 最小轮询间隔 (秒)
TIER_MIN_INTERVAL = {
"expert": 1.0,
"pro": 2.0,
"starter": 3.0,
"free": 6.0,
}
DEFAULT_INTERVAL = 10.0
MAX_INTERVAL = 60.0
def __init__(self) -> None:
self._lock = threading.Lock()
self._running = False
self._enabled = False # 全局开关 (持久化到 preferences)
self._interval = self.DEFAULT_INTERVAL
self._thread: threading.Thread | None = None
self._repo = None # 延迟注入, 避免循环导入
self._update_event = threading.Event() # SSE 通知: 行情更新后 set
self._alert_event = threading.Event() # SSE 通知: 有告警时 set
self._depth_update_event = threading.Event() # SSE 通知: depth 五档修正后 set (刷新连板梯队)
self._pending_alerts: list[dict] = [] # 待推送的告警
self._max_pending_alerts: int = 1000 # 背压上限: 超出丢弃最旧
# 复盘进度 SSE 通道: 定时复盘流式生成时, 把 meta/delta/done 事件推给开着页面的前端
self._review_event = threading.Event() # SSE 通知: 有复盘进度事件时 set
self._pending_review: list[str] = [] # 待推送的复盘事件(JSON 字符串)
self._max_pending_review: int = 200 # 背压上限: 超出丢弃最旧
self._strategy_monitor = None # 延迟注入
self._app_state = None # 延迟注入 (FastAPI app.state)
# 拉取元信息 (给 SSE / status 用)
self._fetch_time: float = 0.0 # perf_counter (用于计算 quote_age_ms)
self._fetch_ms: float = 0.0 # 拉取耗时 (毫秒)
self._fetched_at: float = 0.0 # 拉取完成的 Unix 时间戳 (毫秒)
self._symbol_count: int = 0
self._index_symbol_count: int = 0
self._etf_symbol_count: int = 0
self._index_quotes_cache: pl.DataFrame | None = None
# ================================================================
# 生命周期
# ================================================================
def start(self, interval: float = 0.0) -> None:
"""启动后台行情轮询线程。"""
if self._running:
return
if interval <= 0:
from app.services import preferences
interval = preferences.get_realtime_quote_interval()
self._interval = self._clamp_interval(interval)
self._running = True
self._enabled = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
self._save_enabled(True)
logger.info("行情服务已启动, 轮询间隔 %.1fs", self._interval)
def stop(self) -> None:
"""停止后台行情轮询线程。"""
self._running = False
self._enabled = False
if self._thread:
self._thread.join(timeout=10)
self._thread = None
self._save_enabled(False)
logger.info("行情服务已停止")
def enable(self) -> bool:
"""开启自动行情 (不立即启动线程,等下一个交易时段)。
none 档无实时行情权限,拒绝开启并返回 False;
free 档开启自选股实时,starter+ 开启全市场实时返回值表示是否真正开启
"""
if not self.is_realtime_allowed():
logger.warning("实时行情开启被拒:当前档位(none)无实时行情权限")
return False
self._enabled = True
self._save_enabled(True)
if not self._running:
from app.services import preferences
self._interval = self._clamp_interval(preferences.get_realtime_quote_interval())
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
logger.info("行情服务已启用, 轮询间隔 %.1fs", self._interval)
def disable(self) -> None:
"""关闭自动行情。"""
self.stop()
logger.info("行情服务已关闭")
def boot_check(self) -> None:
"""启动时检查 preferences,若 enabled 则自动启动。
none 档无实时行情权限:即使 preferences 标记为 enabled,
也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)
"""
from app.services import preferences
if not self.is_realtime_allowed():
if preferences.get_realtime_quotes_enabled():
self._save_enabled(False)
logger.info("实时行情未启动:当前档位(none)无实时行情权限")
return
if preferences.get_realtime_quotes_enabled():
self.start()
def set_repo(self, repo) -> None:
"""注入 KlineRepository, 用于实时落盘。"""
self._repo = repo
def set_app_state(self, app_state) -> None:
"""注入 FastAPI app.state, 用于获取 strategy_monitor 等单例。"""
self._app_state = app_state
def set_interval(self, interval: float) -> float:
"""运行时更新轮询间隔(立即生效)。"""
clamped = self._clamp_interval(interval)
self._interval = clamped
from app.services import preferences
preferences.set_realtime_quote_interval(clamped)
logger.info("轮询间隔已更新为 %.1fs", clamped)
return clamped
def get_min_interval(self) -> float:
"""返回当前档位允许的最小间隔。"""
return self._tier_min_interval()
def wait_for_update(self, timeout: float = 30.0) -> bool:
"""阻塞等待下一次行情更新 (供 SSE 线程使用)。"""
self._update_event.clear()
return self._update_event.wait(timeout=timeout)
def wait_for_alert(self, timeout: float = 30.0) -> bool:
"""阻塞等待告警 (供 SSE 线程使用)。"""
self._alert_event.clear()
return self._alert_event.wait(timeout=timeout)
def notify_depth_updated(self) -> None:
"""五档盘口修正完成后调用: 通知 SSE 推送 depth_updated, 触发连板梯队刷新。
与行情/告警通道独立 只刷新连板梯队, 不连带刷新 watchlist
"""
self._depth_update_event.set()
def wait_for_depth_update(self, timeout: float = 30.0) -> bool:
"""阻塞等待 depth 修正 (供 SSE 线程使用)。"""
self._depth_update_event.clear()
return self._depth_update_event.wait(timeout=timeout)
def pop_alerts(self) -> list[dict]:
"""取走所有待推送的告警 (线程安全)。"""
with self._lock:
alerts = self._pending_alerts
self._pending_alerts = []
return alerts
# ================================================================
# 复盘进度 SSE 通道 — 定时复盘流式生成时, 把事件实时推给前端
# ================================================================
def push_review_event(self, event_json: str) -> None:
"""追加一条复盘进度事件(JSON 字符串), 并唤醒 SSE generator。
事件格式与 recap_market_stream 的产出一致(meta/delta/error/done),
前端 reviewStore 直接消费背压: 超过上限丢弃最旧(复盘流几百条 delta, 200 够用)
"""
with self._lock:
self._pending_review.append(event_json)
if len(self._pending_review) > self._max_pending_review:
overflow = len(self._pending_review) - self._max_pending_review
self._pending_review = self._pending_review[overflow:]
self._review_event.set()
def wait_for_review(self, timeout: float = 30.0) -> bool:
"""阻塞等待复盘进度事件 (供 SSE 线程使用)。"""
self._review_event.clear()
return self._review_event.wait(timeout=timeout)
def pop_review_events(self) -> list[str]:
"""取走所有待推送的复盘事件 (线程安全)。"""
with self._lock:
events = self._pending_review
self._pending_review = []
return events
# ================================================================
# 档位感知间隔限制
# ================================================================
@staticmethod
def _current_tier() -> str:
"""获取当前档位名(小写)。"""
from app.tickflow.policy import tier_label
return tier_label().split()[0].split("+")[0].strip().lower()
@classmethod
def realtime_mode(cls) -> str:
"""当前实时行情模式: none / watchlist / full_market。"""
tier = cls._current_tier()
if tier == "none":
return "none"
if tier == "free":
return "watchlist"
return "full_market"
@classmethod
def is_realtime_allowed(cls) -> bool:
"""当前档位是否允许使用实时行情。"""
return cls.realtime_mode() != "none"
@classmethod
def _tier_min_interval(cls) -> float:
tier = cls._current_tier()
return cls.TIER_MIN_INTERVAL.get(tier, cls.DEFAULT_INTERVAL)
def _clamp_interval(self, interval: float) -> float:
return max(self._tier_min_interval(), min(self.MAX_INTERVAL, interval))
# ================================================================
# 行情数据访问
# ================================================================
def get_enriched_today(self) -> tuple[pl.DataFrame, date | None]:
"""返回今天 enriched 数据 + 日期 (线程安全)。
所有页面统一通过此方法获取实时行情 + 技术指标
"""
if not self._repo:
return pl.DataFrame(), None
return self._repo.get_enriched_latest()
def get_quotes_compat(self) -> pl.DataFrame:
"""兼容接口: 返回行情 DataFrame (用于盘中选股等需要 last_price/prev_close 的场景)。
_enriched_cache today 的数据, 只选行情基础列, 补上 last_price 别名
不返回指标列, 避免 JOIN live_agg 时列名冲突
"""
df, _ = self.get_enriched_today()
if df.is_empty():
return df
# 只取盘中选股需要的行情基础列
keep = [c for c in [
"symbol", "close", "open", "high", "low", "volume", "amount",
"prev_close", "change_pct", "change_amount", "amplitude", "turnover_rate",
] if c in df.columns]
df = df.select(keep)
# enriched 的 close 等价于 last_price
if "close" in df.columns and "last_price" not in df.columns:
df = df.with_columns(pl.col("close").alias("last_price"))
return df
def get_index_quotes(self, symbols: list[str] | None = None) -> pl.DataFrame:
"""返回实时指数行情缓存。不会触发 TickFlow 请求。"""
with self._lock:
df = self._index_quotes_cache.clone() if self._index_quotes_cache is not None else pl.DataFrame()
if df.is_empty():
return df
if symbols:
return df.filter(pl.col("symbol").is_in(symbols))
return df
def status(self) -> dict:
"""返回行情服务状态。"""
from app.services import preferences
age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1
mode = self.realtime_mode()
return {
"enabled": self._enabled,
"running": self._running,
"mode": mode,
"realtime_allowed": mode != "none",
"watchlist_symbol_count": len(preferences.get_realtime_watchlist_symbols()),
"interval_s": self._interval,
"symbol_count": self._symbol_count,
"index_symbol_count": self._index_symbol_count,
"etf_symbol_count": self._etf_symbol_count,
"quote_age_ms": round(age, 0) if age >= 0 else None,
"is_trading_hours": self._is_trading_hours(),
"last_fetch_ms": round(self._fetched_at, 0) if self._fetched_at else None,
}
def refresh(self) -> dict:
"""手动触发一次行情拉取。"""
self._fetch_quotes()
return self.status()
# ================================================================
# 后台轮询
# ================================================================
def _poll_loop(self) -> None:
while self._running and self._enabled:
try:
if self._is_trading_hours():
self._fetch_quotes()
else:
logger.debug("非交易时段, 跳过行情轮询")
except Exception as e: # noqa: BLE001
logger.warning("行情轮询异常: %s", e)
waited = 0.0
while self._running and self._enabled and waited < self._interval:
time.sleep(0.5)
waited += 0.5
def _fetch_quotes(self) -> None:
"""按当前档位拉取行情。"""
if self.realtime_mode() == "watchlist":
self._fetch_watchlist_quotes()
return
self._fetch_full_market_quotes()
def _fetch_full_market_quotes(self) -> None:
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。"""
from app.tickflow.client import get_paid_realtime_client
tf = get_paid_realtime_client()
if tf is None:
logger.warning("实时行情拉取失败:未配置付费服务器 API Key")
return
t0 = time.perf_counter()
now_ts = time.perf_counter()
try:
from app.services import preferences
all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set()
core_index_symbols = set(preferences.get_realtime_index_symbols() or self.CORE_INDEX_SYMBOLS)
all_index_symbols.update(core_index_symbols)
all_etf_symbols = set()
if self._repo:
etf_inst = self._repo.get_etf_instruments()
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
all_etf_symbols = set(etf_inst["symbol"].cast(pl.Utf8).to_list())
universes: list[str] = []
if preferences.get_realtime_pull_stock():
universes.append("CN_Equity_A")
if preferences.get_realtime_pull_etf() and all_etf_symbols:
universes.append("CN_ETF")
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "all":
universes.append("CN_Index")
resp = []
if universes:
resp.extend(tf.quotes.get_by_universes(universes=universes) or [])
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core":
resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or [])
except Exception as e: # noqa: BLE001
logger.warning("行情拉取失败: %s", e)
return
if not resp:
logger.warning("行情数据为空")
return
# ---- 解析 API 响应 (临时变量, 用完丢弃) ----
records = []
for q in resp:
ext = q.get("ext") or {}
last_price = q.get("last_price")
prev_close = q.get("prev_close")
change_amount = ext.get("change_amount")
change_pct = ext.get("change_pct")
if change_amount is None and last_price is not None and prev_close is not None:
change_amount = float(last_price) - float(prev_close)
if change_pct is None and change_amount is not None and prev_close not in (None, 0):
change_pct = float(change_amount) / float(prev_close) * 100
records.append({
"symbol": q.get("symbol"),
"name": q.get("name") or ext.get("name"),
"last_price": last_price,
"prev_close": prev_close,
"open": q.get("open"),
"high": q.get("high"),
"low": q.get("low"),
"volume": q.get("volume"),
"amount": q.get("amount"),
"change_pct": change_pct,
"change_amount": change_amount,
"amplitude": ext.get("amplitude"),
"turnover_rate": ext.get("turnover_rate"),
"timestamp": q.get("timestamp"),
"session": q.get("session"),
})
index_records = [r for r in records if r.get("symbol") in all_index_symbols]
etf_records = [r for r in records if r.get("symbol") in all_etf_symbols]
stock_records = [
r for r in records
if r.get("symbol") not in all_index_symbols and r.get("symbol") not in all_etf_symbols
]
fetch_ms = (time.perf_counter() - t0) * 1000
fetched_at = time.time() * 1000
# ---- 更新元信息 ----
with self._lock:
self._fetch_time = now_ts
self._fetch_ms = fetch_ms
self._fetched_at = fetched_at
self._symbol_count = len(stock_records)
self._index_symbol_count = len(index_records)
self._etf_symbol_count = len(etf_records)
self._index_quotes_cache = self._build_index_quotes(index_records)
logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms)
# ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ----
daily_df = self._build_daily(stock_records)
if not daily_df.is_empty() and self._repo:
try:
self._repo.flush_live_daily(daily_df)
except Exception as e: # noqa: BLE001
logger.warning("日K写盘失败: %s", e)
etf_daily_df = self._build_daily(etf_records)
if not etf_daily_df.is_empty() and self._repo:
try:
self._repo.flush_live_daily_asset("etf", etf_daily_df)
except Exception as e: # noqa: BLE001
logger.warning("ETF 日K写盘失败: %s", e)
# ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ----
quote_extra = self._build_quote_extra(stock_records)
etf_quote_extra = self._build_quote_extra(etf_records)
# ---- 增量计算 enriched + 写盘 + 更新缓存 ----
if not daily_df.is_empty() and self._repo:
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock")
if not etf_daily_df.is_empty() and self._repo:
self._flush_live_enriched(etf_daily_df, etf_quote_extra, asset_type="etf")
# ---- 通知 SSE ----
self._update_event.set()
# ---- 策略监控 + 告警评估 ----
self._evaluate_monitors(daily_df, quote_extra)
def _fetch_watchlist_quotes(self) -> None:
"""Free 档自选股实时: 只拉取最多 5 个 symbols。"""
from app.services import preferences
from app.tickflow.client import get_paid_realtime_client
symbols = preferences.get_realtime_watchlist_symbols()
if not symbols:
logger.info("自选实时未配置标的, 跳过行情拉取")
return
tf = get_paid_realtime_client()
if tf is None:
logger.warning("自选实时拉取失败:未配置付费服务器 API Key")
return
t0 = time.perf_counter()
now_ts = time.perf_counter()
try:
resp = tf.quotes.get(symbols=symbols) or []
except Exception as e: # noqa: BLE001
logger.warning("自选实时拉取失败: %s", e)
return
if not resp:
logger.warning("自选实时行情数据为空")
return
records = []
for q in resp:
ext = q.get("ext") or {}
last_price = q.get("last_price")
prev_close = q.get("prev_close")
change_amount = ext.get("change_amount")
change_pct = ext.get("change_pct")
if change_amount is None and last_price is not None and prev_close is not None:
change_amount = float(last_price) - float(prev_close)
if change_pct is None and change_amount is not None and prev_close not in (None, 0):
change_pct = float(change_amount) / float(prev_close) * 100
records.append({
"symbol": q.get("symbol"),
"name": q.get("name") or ext.get("name"),
"last_price": last_price,
"prev_close": prev_close,
"open": q.get("open"),
"high": q.get("high"),
"low": q.get("low"),
"volume": q.get("volume"),
"amount": q.get("amount"),
"change_pct": change_pct,
"change_amount": change_amount,
"amplitude": ext.get("amplitude"),
"turnover_rate": ext.get("turnover_rate"),
"timestamp": q.get("timestamp"),
"session": q.get("session"),
})
fetch_ms = (time.perf_counter() - t0) * 1000
fetched_at = time.time() * 1000
with self._lock:
self._fetch_time = now_ts
self._fetch_ms = fetch_ms
self._fetched_at = fetched_at
self._symbol_count = len(records)
self._index_symbol_count = 0
self._etf_symbol_count = 0
self._index_quotes_cache = None
logger.info("自选实时刷新: %d 只股票, 耗时 %.0fms", len(records), fetch_ms)
daily_df = self._build_daily(records)
quote_extra = self._build_quote_extra(records)
if not daily_df.is_empty() and self._repo:
try:
self._repo.merge_live_daily_asset("stock", daily_df)
except Exception as e: # noqa: BLE001
logger.warning("自选实时日K写盘失败: %s", e)
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock", merge=True)
self._update_event.set()
self._evaluate_monitors(daily_df, quote_extra)
# ================================================================
# 工具
# ================================================================
@staticmethod
def _build_daily(records: list[dict]) -> pl.DataFrame:
"""将 API records 转为日K格式 DataFrame (只有 OHLCV, 写 kline_daily 用)。"""
if not records:
return pl.DataFrame()
df = pl.DataFrame(records)
cols_map = {
"symbol": "symbol",
"last_price": "close",
"open": "open",
"high": "high",
"low": "low",
"volume": "volume",
"amount": "amount",
}
select_exprs = []
for src, dst in cols_map.items():
if src in df.columns:
select_exprs.append(pl.col(src).alias(dst))
if not select_exprs:
return pl.DataFrame()
result = df.select(select_exprs).with_columns(
pl.lit(date.today()).cast(pl.Date).alias("date"),
)
# 修复: API 在非交易时段可能返回 open/high/low=0 或 null,
# 导致蜡烛从 0 开始。用 close 填充这些异常值。
for col in ("open", "high", "low"):
if col in result.columns:
result = result.with_columns(
pl.when((pl.col(col) == 0) | pl.col(col).is_null())
.then(pl.col("close"))
.otherwise(pl.col(col))
.alias(col)
)
return result
@staticmethod
def _build_quote_extra(records: list[dict]) -> pl.DataFrame:
"""构建 API 直接提供的补充字段 (不写 daily, 只传给 enriched 计算)。
包含: prev_close, change_pct, change_amount, amplitude, turnover_rate
"""
if not records:
return pl.DataFrame()
df = pl.DataFrame(records)
keep = [c for c in [
"symbol", "prev_close", "change_pct", "change_amount",
"amplitude", "turnover_rate",
] if c in df.columns]
if not keep or "symbol" not in keep:
return pl.DataFrame()
return df.select(keep)
@staticmethod
def _build_index_quotes(records: list[dict]) -> pl.DataFrame:
"""构建指数实时行情缓存,不落股票 parquet。
注意: API 返回的 change_pct/amplitude 是小数 (0.0366 = 3.66%),
统一转成百分比输出, _fallback_index_quotes_from_daily 口径一致
(前端指数侧不×100, 直接 toFixed(2)% 展示)
"""
if not records:
return pl.DataFrame()
df = pl.DataFrame(records)
keep = [c for c in [
"symbol", "name", "last_price", "prev_close", "open", "high", "low",
"volume", "amount", "change_pct", "change_amount", "amplitude", "timestamp", "session",
] if c in df.columns]
if not keep or "symbol" not in keep:
return pl.DataFrame()
df = df.select(keep)
# change_pct / amplitude: 小数 → 百分比 (统一指数展示口径)
for col in ("change_pct", "amplitude"):
if col in df.columns:
df = df.with_columns((pl.col(col).cast(pl.Float64) * 100).alias(col))
if "last_price" in df.columns and "close" not in df.columns:
df = df.with_columns(pl.col("last_price").alias("close"))
return df
@staticmethod
def _is_trading_hours() -> bool:
now = datetime.now()
t = now.time()
morning = dt_time(9, 15) <= t <= dt_time(11, 35)
afternoon = dt_time(12, 55) <= t <= dt_time(15, 5)
return now.weekday() < 5 and (morning or afternoon)
@staticmethod
def _save_enabled(enabled: bool) -> None:
from app.services import preferences
preferences.save({"realtime_quotes_enabled": enabled})
# ================================================================
# 策略监控
# ================================================================
def _evaluate_monitors(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame | None) -> None:
"""行情更新后评估统一监控规则引擎,并刷新策略结果缓存。"""
try:
# 获取 enriched 数据 (刚算好的)
enriched_today, enriched_date = self.get_enriched_today()
if enriched_today.is_empty():
return
all_alerts: list[dict] = []
rule_events: list[dict] = []
engine = None
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
if self._app_state:
engine = getattr(self._app_state, "monitor_engine", None)
if engine and engine.rule_count > 0:
# 预构建 symbol → name 映射 (enriched 已 drop name 列, 引擎触发时回填用)
try:
inst_df = self._app_state.repo.get_instruments()
if not inst_df.is_empty() and "symbol" in inst_df.columns and "name" in inst_df.columns:
engine.set_name_map({
row["symbol"]: row["name"]
for row in inst_df.select(["symbol", "name"]).iter_rows(named=True)
if row.get("name")
})
except Exception as e: # noqa: BLE001
logger.debug("name_map 构建失败 (不影响监控): %s", e)
# 连板梯队封单监控: 有 ladder 规则时, 从 depth_service 注入封单量到 enriched
eval_df = enriched_today
if engine.has_rule_type("ladder"):
eval_df = self._inject_sealed_vol(enriched_today, enriched_date)
rule_events = engine.evaluate(eval_df)
if rule_events:
# 落盘到 alerts.jsonl
try:
from app.services import alert_store
alert_store.append_many(
self._app_state.repo.store.data_dir, rule_events,
)
except Exception as e: # noqa: BLE001
logger.warning("告警落盘失败: %s", e)
# 转为 SSE 推送格式 (兼容旧 alert schema)
for ev in rule_events:
all_alerts.append({
"source": ev["source"],
"type": ev["type"],
"rule_id": ev.get("rule_id"),
"strategy_id": ev.get("rule_id") if ev["source"] == "strategy" else None,
"symbol": ev["symbol"],
"name": ev["name"],
"message": ev["message"],
"price": ev["price"],
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
"conditions": ev.get("conditions") or [],
"logic": ev.get("logic") or "and",
})
# 策略页实时回显: 不写文件 (实时行情每轮更新 enriched, 写文件会被 read_cache
# 的 mtime 校验判过期, 反复读不到)。监控引擎本轮已算出的结果存在内存
# (latest_strategy_results), 由 /api/screener/cached 端点直接叠加读取。
# 推入待推送队列 + 通知 SSE (含背压保护)
if all_alerts:
with self._lock:
self._pending_alerts.extend(all_alerts)
# 背压: 超出上限丢弃最旧
if len(self._pending_alerts) > self._max_pending_alerts:
overflow = len(self._pending_alerts) - self._max_pending_alerts
self._pending_alerts = self._pending_alerts[overflow:]
self._alert_event.set()
logger.info("监控评估完成: %d 条通知", len(all_alerts))
# 系统通知 (可选通道, 由 preferences 开关控制)。
# cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。
self._maybe_send_system_notifications(all_alerts)
# Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。
# 紧随系统通知, 同样静默降级不阻断主流程。
if rule_events:
self._maybe_send_webhook(rule_events, engine)
except Exception as e: # noqa: BLE001
logger.warning("监控评估失败: %s", e)
def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame:
"""从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。
涨停封单(买一量) + 跌停封单(卖一量)合并, ladder 规则评估
depth 未就绪时返回原 df (不注入, ladder 规则安全降级不触发)
"""
try:
depth_svc = getattr(self._app_state, "depth_service", None)
if not depth_svc:
return enriched_today
# enriched_date 可能是 date 或字符串, 统一为 date
from datetime import date as date_cls
target_date = enriched_date if isinstance(enriched_date, date_cls) else date_cls.fromisoformat(str(enriched_date))
# 取涨停 + 跌停封单, 合并 {symbol: vol}
up_map = depth_svc.get_sealed_map(target_date, is_down=False)
down_map = depth_svc.get_sealed_map(target_date, is_down=True)
sealed: dict[str, int] = {}
for m in (up_map, down_map):
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol # 后者覆盖前者 (同 symbol 不可能在涨跌停都封单)
if not sealed:
return enriched_today
# 构造 (symbol, _sealed_vol) DataFrame, join 到 enriched 副本
sealed_df = pl.DataFrame({
"symbol": list(sealed.keys()),
"_sealed_vol": list(sealed.values()),
})
# 若已有残留列先移除 (避免重复 join 报错)
df = enriched_today.drop("_sealed_vol") if "_sealed_vol" in enriched_today.columns else enriched_today
return df.join(sealed_df, on="symbol", how="left")
except Exception as e: # noqa: BLE001
logger.debug("封单注入失败 (ladder 规则将不触发): %s", e)
return enriched_today
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。
- 全局飞书 URL 未配置: 直接返回
- 仅推送 webhook_enabled=True 的规则触发的告警
- 失败静默, 不阻断主流程
- 去重: 复用 MonitorRuleEngine cooldown, 此处不重复去重
注意: rule_events ( rule_id) 而非重建后的 all_alerts,
以便反查引擎规则判断是否启用推送
"""
try:
from app.services import preferences
from app.services import webhook_adapter
url = preferences.get_feishu_webhook_url()
if not url:
return
secret = preferences.get_feishu_webhook_secret()
# 反查规则, 过滤出启用推送的事件
source_labels = {
"strategy": "策略", "signal": "信号",
"price": "价格", "market": "异动",
}
rules = engine.rules if engine is not None else {}
pushed = 0
for ev in rule_events:
rule = rules.get(ev.get("rule_id"))
if not rule or not rule.get("webhook_enabled"):
continue
source = ev.get("source", "")
source_label = source_labels.get(source, source or "通知")
symbol = ev.get("symbol") or ""
name = ev.get("name") or ""
message = ev.get("message") or ""
title = f"TickFlow · {source_label}"
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
if webhook_adapter.send_feishu(url, title, body, secret):
pushed += 1
if pushed:
logger.info("飞书 Webhook 推送: %d", pushed)
except Exception as e: # noqa: BLE001
logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e)
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
- 开关关闭: 直接返回
- 开关开启: 逐条发系统通知; 失败静默, 不阻断主流程
- 去重: 复用 MonitorRuleEngine cooldown, 此处不重复去重
- 批量策略事件 (symbol="") 聚合为一条通知, 避免刷屏
"""
try:
from app.services import preferences
from app.services import notify_adapter
if not preferences.get_system_notify_enabled():
return
for ev in all_alerts:
# 通知标题: 用 source 分类 (策略/信号/价格/异动)
source = ev.get("source", "")
source_label = {
"strategy": "策略", "signal": "信号",
"price": "价格", "market": "异动",
}.get(source, source or "通知")
name = ev.get("name") or ""
symbol = ev.get("symbol") or ""
message = ev.get("message") or ""
# 正文: 优先用现成 message, 拼上 symbol/name 让用户一眼定位
if symbol:
body = f"{symbol} {name} {message}".strip()
else:
body = message or name
title = f"TickFlow · {source_label}"
notify_adapter.notify(title, body)
except Exception as e: # noqa: BLE001
logger.debug("系统通知发送异常 (不影响告警主流程): %s", e)
@staticmethod
def _get_strategy_monitor():
"""获取 StrategyMonitorService — 不再使用, 改用 _app_state 注入。"""
return None
# ================================================================
# enriched 增量计算
# ================================================================
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None, asset_type: str = "stock", merge: bool = False) -> None:
"""增量计算今天的 enriched: 用昨天的递推状态 + 今天 OHLCV → 只算今天 5500 行。
quote_extra: API 直接提供的补充字段 (prev_close, change_pct ),
不写 daily, 直接传给 compute_enriched_today 避免重复计算
"""
try:
today = date.today()
t0 = time.perf_counter()
# ---- 尝试增量路径 ----
live_agg = self._repo.get_live_agg() if asset_type == "stock" else pl.DataFrame()
prev_enriched, prev_date = (
self._repo.get_enriched_latest()
if asset_type == "stock"
else self._repo.get_enriched_latest_asset(asset_type)
)
use_incremental = (
asset_type == "stock"
and not live_agg.is_empty()
and not prev_enriched.is_empty()
and prev_date is not None
)
if use_incremental:
from app.indicators.pipeline import compute_enriched_today
instruments = self._repo.get_instruments()
# 将 API 直接提供的补充字段 JOIN 到 daily_df
today_ohlcv = daily_df
if quote_extra is not None and not quote_extra.is_empty():
today_ohlcv = daily_df.join(quote_extra, on="symbol", how="left")
enriched_today = compute_enriched_today(
live_agg=live_agg,
prev_enriched=prev_enriched,
today_ohlcv=today_ohlcv,
instruments=instruments,
)
if enriched_today.is_empty():
logger.warning("增量计算结果为空, 回退到全量计算")
use_incremental = False
# ---- 全量回退路径 ----
if not use_incremental:
from datetime import timedelta
from app.indicators.pipeline import compute_enriched
logger.info("enriched 全量计算 (live_agg=%s, 上次日期=%s)",
"ok" if not live_agg.is_empty() else "", prev_date)
cutoff = today - timedelta(days=90)
table = "kline_etf_daily" if asset_type == "etf" else "kline_daily"
daily_glob = str(self._repo.store.data_dir / table / "**" / "*.parquet")
ohlcv_cols = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
hist_df = (
pl.scan_parquet(daily_glob)
.filter(pl.col("date") >= cutoff)
.sort(["symbol", "date"])
.collect()
)
if hist_df.is_empty():
return
hist_cols = [c for c in ohlcv_cols if c in hist_df.columns]
hist_df = hist_df.select(hist_cols).filter(pl.col("date") != today)
daily_ohlcv = daily_df.select([c for c in ohlcv_cols if c in daily_df.columns])
full_df = pl.concat([hist_df, daily_ohlcv], how="diagonal_relaxed")
full_df = full_df.sort(["symbol", "date"])
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
factor_path = self._repo.store.data_dir / factor_dir / "all.parquet"
factors = pl.DataFrame()
if factor_path.exists():
try:
factors = pl.read_parquet(factor_path)
except Exception:
pass
instruments = self._repo.get_instruments() if asset_type == "stock" else None
enriched_full = compute_enriched(full_df, factors=factors, instruments=instruments)
enriched_today = enriched_full.filter(pl.col("date") == today)
if enriched_today.is_empty():
return
# ---- 写盘 + 更新缓存 ----
if merge:
self._repo.merge_live_enriched_asset(asset_type, enriched_today)
else:
self._repo.flush_live_enriched_asset(asset_type, enriched_today)
elapsed = time.perf_counter() - t0
mode_label = "增量" if use_incremental else "全量"
logger.info("enriched %s: %d 只, %s, 耗时 %.0fms",
mode_label, len(enriched_today), today, elapsed * 1000)
except Exception as e: # noqa: BLE001
logger.warning("enriched 计算失败: %s", e)
@@ -251,6 +251,8 @@ async def analyze_stock_stream(
data_dir: Path,
symbol: str,
focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式个股分析:yield 出每个 NDJSON 事件。
@@ -298,6 +300,7 @@ async def analyze_stock_stream(
],
temperature=0.5,
max_tokens=4500,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
+21 -34
View File
@@ -1,24 +1,8 @@
"""AI 个股分析报告持久化存储。
"""AI 个股分析报告持久化存储 — 按用户名隔离
ai_reports.py(财务分析报告)完全独立 单独的文件字段上限,
互不影响刻意不复用,避免引入 kind 判别字段与分支(解耦 > 抽象)
存储位置: data/user_data/ai_stock_reports.json (数组, created_at 降序)
保留最近 MAX_REPORTS ;超出自动裁剪最旧的
每条报告结构:
{
"id": "sar_xxx", # 唯一 id(stock-analysis-report)
"symbol": "600519.SH",
"name": "贵州茅台",
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"summary": "当前价 12.3 · 压力位...", # 价位/数据摘要
"levels": {...}, # 报告生成时的关键价位(供图表回放)
"close": 12.3, # 报告生成时的收盘价
"created_at": "2026-06-26T10:00:00"
}
存储位置: data/user_data/reports/{username}/ai_stock_reports.json
"""
from __future__ import annotations
import json
@@ -31,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 50
def _path() -> Path:
def _path(username: str | None = None) -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_stock_reports.json"
if username:
p = settings.data_dir / "user_data" / "reports" / username / "ai_stock_reports.json"
else:
p = settings.data_dir / "user_data" / "ai_stock_reports.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_reports() -> list[dict]:
"""返回全部报告(按 created_at 降序)。"""
p = _path()
def list_reports(username: str | None = None) -> list[dict]:
"""返回指定用户的全部报告(按 created_at 降序)。"""
p = _path(username)
if not p.exists():
return []
try:
@@ -52,36 +39,36 @@ def list_reports() -> list[dict]:
return []
def _save_all(reports: list[dict]) -> None:
def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS]
_path().write_text(
_path(username).write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
)
def save_report(report: dict) -> dict:
def save_report(report: dict, username: str | None = None) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports()
reports = list_reports(username)
if not report.get("id"):
report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"):
report["created_at"] = _now_iso()
reports.append(report)
_save_all(reports)
logger.info("Stock report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
_save_all(reports, username)
logger.info("Stock report saved by %s: %s (%s), total %d", username or "?", report.get("symbol"), report.get("id"), len(reports))
return report
def delete_report(report_id: str) -> bool:
"""删除指定报告。返回是否删除成功。"""
reports = list_reports()
def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定用户的报告。返回是否删除成功。"""
reports = list_reports(username)
before = len(reports)
reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before:
_save_all(reports)
_save_all(reports, username)
return True
return False
-144
View File
@@ -1,144 +0,0 @@
"""自选股服务(§6.1)。
存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
import polars as pl
from app.config import settings
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.client import get_client
logger = logging.getLogger(__name__)
def _path() -> Path:
p = settings.data_dir / "user_data" / "watchlist.parquet"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_symbols() -> list[dict]:
p = _path()
if not p.exists():
return []
df = pl.read_parquet(p)
if df.is_empty():
return []
return df.to_dicts()
def add(symbol: str, note: str = "") -> list[dict]:
p = _path()
if p.exists():
df = pl.read_parquet(p)
# 已存在则先移除,后面重新插入到最前面
if symbol in df["symbol"].to_list():
df = df.filter(pl.col("symbol") != symbol)
else:
df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8})
new_row = pl.DataFrame({
"symbol": [symbol],
"added_at": [datetime.utcnow().isoformat(timespec="seconds")],
"note": [note],
})
out = pl.concat([new_row, df], how="diagonal_relaxed")
out.write_parquet(p)
return out.to_dicts()
def remove(symbol: str) -> list[dict]:
p = _path()
if not p.exists():
return []
df = pl.read_parquet(p)
df = df.filter(pl.col("symbol") != symbol)
df.write_parquet(p)
return df.to_dicts()
def move_to_top(symbol: str) -> list[dict]:
p = _path()
if not p.exists():
return []
df = pl.read_parquet(p)
if df.is_empty() or symbol not in df["symbol"].to_list():
return df.to_dicts()
target = df.filter(pl.col("symbol") == symbol)
rest = df.filter(pl.col("symbol") != symbol)
out = pl.concat([target, rest], how="diagonal_relaxed")
out.write_parquet(p)
return out.to_dicts()
def clear() -> int:
"""清空自选列表。返回移除的数量。"""
p = _path()
if not p.exists():
return 0
df = pl.read_parquet(p)
count = df.height
if count > 0:
pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p)
return count
def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
"""拉取实时行情。
优先用 quote.batch;否则降级为 quote.by_symbol 单股请求
timeout_s: 单批次请求超时()防止 API 卡死阻塞整个请求
"""
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
if not symbols:
return []
tf = get_client()
quotes: list[dict] = []
# 走 batch
batch_size = 5
if capset.has(Cap.QUOTE_BATCH):
lim = capset.limits(Cap.QUOTE_BATCH)
batch_size = lim.batch if lim and lim.batch else 50
elif capset.has(Cap.QUOTE_BY_SYMBOL):
lim = capset.limits(Cap.QUOTE_BY_SYMBOL)
batch_size = lim.batch if lim and lim.batch else 5
else:
# 无任何实时行情能力(none/free 档走 free-api 服务器,不提供实时行情)
# 提前返回空,避免发起注定失败的请求
return []
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
# 用线程池为每个批次加超时保护
pool = ThreadPoolExecutor(max_workers=1)
for chunk in chunks:
try:
future = pool.submit(tf.quotes.get, symbols=chunk, as_dataframe=True)
raw = future.result(timeout=timeout_s)
if raw is None or len(raw) == 0:
continue
df = pl.from_pandas(raw)
rename_map = {
"last_price": "price",
"ext.change_pct": "pct",
"ext.name": "name",
}
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
quotes.extend(df.to_dicts())
except FuturesTimeout:
logger.warning("quote fetch timeout (%.1fs) for %d symbols", timeout_s, len(chunk))
break # 超时后不再尝试后续批次
except Exception as e: # noqa: BLE001
logger.warning("quote fetch failed for %d symbols: %s", len(chunk), e)
pool.shutdown(wait=False)
return quotes
+4 -3
View File
@@ -50,7 +50,7 @@ class AIStrategyGenerator:
self._guide_cache = ""
return self._guide_cache
async def generate(self, user_prompt: str) -> dict:
async def generate(self, user_prompt: str, *, username: str | None = None) -> dict:
"""根据用户描述生成策略代码
Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None}
@@ -58,7 +58,7 @@ class AIStrategyGenerator:
guide = self._get_guide()
# 调用 LLM
code = await self._call_llm(user_prompt, guide)
code = await self._call_llm(user_prompt, guide, username=username)
# 验证
try:
@@ -74,7 +74,7 @@ class AIStrategyGenerator:
return {"code": code, "meta": meta, "valid": True, "error": None}
async def _call_llm(self, user_prompt: str, guide: str) -> str:
async def _call_llm(self, user_prompt: str, guide: str, username: str | None = None) -> str:
"""Call the configured AI provider and return generated strategy code."""
from app.services.ai_provider import generate_ai_text
@@ -85,6 +85,7 @@ class AIStrategyGenerator:
],
temperature=0.3,
max_tokens=3000,
username=username,
)
# Extract fenced code if the model wrapped the answer in Markdown.
if "```python" in content:
-857
View File
@@ -1,857 +0,0 @@
"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。
职责: 接收实时行情 DataFrame 检查监控中策略的信号/提醒 推送告警
不知道: 策略加载逻辑AIAPI配置持久化回测
依赖: 外部调用 on_quote_update() 传入实时数据
本模块含两个评估器:
1. StrategyMonitorService 旧的策略监控 (type=strategy),第二步迁移到 MonitorRuleEngine
2. MonitorRuleEngine 通用规则引擎,覆盖 signal/price/market/strategy 四类,
支持 scope (symbols/all/sector) + 多条件 AND/OR + cooldown 去重
"""
from __future__ import annotations
import datetime as _dt
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable
import polars as pl
from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器
from app.strategy import config as _strategy_config
logger = logging.getLogger(__name__)
# 信号 / 字段中文名映射 — 与前端 lib/signals.ts 对齐, 用于告警 message / 推送文案。
# signal_* 为内置原子信号, 其余为技术指标/行情字段。
_SIGNAL_CN: dict[str, str] = {
# 内置信号
"signal_ma_golden_5_20": "MA5上穿MA20", "signal_ma_dead_5_20": "MA5下穿MA20",
"signal_ma_golden_20_60": "MA20上穿MA60", "signal_macd_golden": "MACD金叉",
"signal_macd_dead": "MACD死叉", "signal_ma20_breakout": "突破MA20",
"signal_ma20_breakdown": "跌破MA20", "signal_n_day_high": "60日新高",
"signal_n_day_low": "60日新低", "signal_boll_breakout_upper": "突破布林上轨",
"signal_boll_breakdown_lower": "跌破布林下轨", "signal_volume_surge": "放量",
"signal_limit_up": "涨停", "signal_limit_down": "跌停",
"signal_limit_down_recovery": "跌停翘板", "signal_broken_limit_up": "炸板",
# 行情字段
"close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价",
"change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅",
"turnover_rate": "换手率", "volume": "成交量", "amount": "成交额",
# 均线
"ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60",
"ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20",
# MACD / BOLL / KDJ / RSI
"macd_dif": "MACD-DIF", "macd_dea": "MACD-DEA", "macd_hist": "MACD柱",
"boll_upper": "布林上轨", "boll_lower": "布林下轨",
"kdj_k": "KDJ-K", "kdj_d": "KDJ-D", "kdj_j": "KDJ-J",
"rsi_6": "RSI6", "rsi_14": "RSI14", "rsi_24": "RSI24",
# 量能 / 动量 / 波动
"vol_ratio_5d": "5日量比", "vol_ratio_20d": "20日量比",
"vol_ma5": "5日均量", "vol_ma10": "10日均量",
"high_60d": "60日最高", "low_60d": "60日最低",
"momentum_5d": "5日动量", "momentum_20d": "20日动量", "momentum_60d": "60日动量",
"atr_14": "ATR14", "annual_vol_20d": "20日年化波动",
"consecutive_limit_ups": "连板数", "consecutive_limit_downs": "跌停连板",
}
def _signal_cn_name(name: str) -> str:
"""返回信号/字段的中文名, 找不到原样返回 (与前端 cnSignal 对齐)。"""
return _SIGNAL_CN.get(name, name)
@dataclass
class StrategyAlert:
"""策略告警"""
type: str # "entry" | "exit" | "alert"
strategy_id: str
symbol: str
name: str | None
message: str
price: float | None = None
change_pct: float | None = None
signals: list[str] = field(default_factory=list)
class StrategyMonitorService:
"""策略实时监控服务"""
def __init__(self, alert_handler: Callable[[StrategyAlert], None] | None = None):
"""
Args:
alert_handler: 告警回调 (如推 SSE)
"""
self._alert_handler = alert_handler
# strategy_id → 监控配置
self._watching: dict[str, dict] = {}
def start(self, strategy_id: str, config: dict) -> None:
"""开始监控一个策略
config: {
"entry_signals": ["signal_n_day_high", ...],
"exit_signals": ["signal_ma20_breakdown", ...],
"alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}],
}
"""
self._watching[strategy_id] = config
logger.info("strategy monitor started: %s", strategy_id)
def stop(self, strategy_id: str) -> None:
self._watching.pop(strategy_id, None)
logger.info("strategy monitor stopped: %s", strategy_id)
def stop_all(self) -> None:
self._watching.clear()
@property
def watching(self) -> dict[str, dict]:
return dict(self._watching)
def on_quote_update(self, df: pl.DataFrame) -> list[StrategyAlert]:
"""行情更新后调用。向量化检查所有监控策略。
Args:
df: 实时 enriched 数据 (~5500)
Returns:
触发的告警列表
"""
if not self._watching or df.is_empty():
return []
all_alerts: list[StrategyAlert] = []
for strategy_id, cfg in self._watching.items():
# 买入信号
entry_sigs = cfg.get("entry_signals", [])
if entry_sigs:
for sym, name, price, pct, hit_sigs in self._check_signals(df, entry_sigs):
alert = StrategyAlert(
type="entry",
strategy_id=strategy_id,
symbol=sym,
name=name,
message=f"买入信号触发",
price=price,
change_pct=pct,
signals=hit_sigs,
)
all_alerts.append(alert)
self._emit(alert)
# 卖出信号
exit_sigs = cfg.get("exit_signals", [])
if exit_sigs:
for sym, name, price, pct, hit_sigs in self._check_signals(df, exit_sigs):
alert = StrategyAlert(
type="exit",
strategy_id=strategy_id,
symbol=sym,
name=name,
message=f"卖出信号触发",
price=price,
change_pct=pct,
signals=hit_sigs,
)
all_alerts.append(alert)
self._emit(alert)
# 提醒条件
for alert_cfg in cfg.get("alerts", []):
for sym, name, price, pct in self._check_alert(df, alert_cfg):
alert = StrategyAlert(
type="alert",
strategy_id=strategy_id,
symbol=sym,
name=name,
message=alert_cfg.get("message", "提醒"),
price=price,
change_pct=pct,
)
all_alerts.append(alert)
self._emit(alert)
return all_alerts
def _emit(self, alert: StrategyAlert) -> None:
if self._alert_handler:
try:
self._alert_handler(alert)
except Exception as e:
logger.warning("alert handler failed: %s", e)
@staticmethod
def _check_signals(
df: pl.DataFrame,
signals: list[str],
) -> list[tuple[str, str | None, float | None, float | None, list[str]]]:
"""检查信号列,返回 [(symbol, name, price, change_pct, [hit_signals])]。
支持内置 signal_ 与自定义 csg_ 前缀"""
cols = set(df.columns)
resolved: list[tuple[str, str]] = [] # (原值, 列名)
for s in signals:
col = s if (s.startswith("signal_") or s.startswith("csg_")) else f"signal_{s}"
if col in cols:
resolved.append((s, col))
if not resolved:
return []
mask = pl.any_horizontal(pl.col(c).fill_null(False) for _, c in resolved)
hit_df = df.filter(mask)
results = []
for row in hit_df.iter_rows(named=True):
sym = row.get("symbol", "")
name = row.get("name")
price = row.get("close")
pct = row.get("change_pct")
hit_sigs = [orig for orig, col in resolved if row.get(col)]
results.append((sym, name, price, pct, hit_sigs))
return results
@staticmethod
def _check_alert(
df: pl.DataFrame,
alert: dict,
) -> list[tuple[str, str | None, float | None, float | None]]:
"""检查阈值型提醒条件"""
field = alert.get("field", "")
if field not in df.columns:
return []
if "op" in alert:
# 阈值比较
op = alert["op"]
value = alert["value"]
col = pl.col(field)
ops = {
">": col > value,
">=": col >= value,
"<": col < value,
"<=": col <= value,
}
expr = ops.get(op)
if expr is None:
return []
else:
# 信号列 (布尔)
expr = pl.col(field).fill_null(False)
hit_df = df.filter(expr)
results = []
for row in hit_df.iter_rows(named=True):
results.append((
row.get("symbol", ""),
row.get("name"),
row.get("close"),
row.get("change_pct"),
))
return results
# ================================================================
# 通用监控规则引擎 MonitorRuleEngine
# ================================================================
_SIGNAL_PREFIXES = ("signal_", "csg_")
def _is_signal_field(field: str) -> bool:
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
def _build_condition_mask(df: pl.DataFrame, conditions: list[dict], logic: str) -> pl.DataFrame:
"""根据 conditions + logic 构建过滤后的命中 DataFrame。
conditions: [{"field","op","value"?}] op=truth 为布尔信号, 否则阈值比较
logic: "and" | "or"
返回命中行 ( symbol/name/close/change_pct + 各信号列)
"""
cols = set(df.columns)
parts: list[pl.Expr] = []
for c in conditions:
field = c["field"]
if field not in cols:
return df.head(0) # 字段缺失,无法判定 → 空结果
op = c["op"]
if op == "truth":
parts.append(pl.col(field).fill_null(False))
elif op in _OP_BUILDERS:
parts.append(_OP_BUILDERS[op](pl.col(field), c["value"]))
else:
return df.head(0)
if not parts:
return df.head(0)
if logic == "or":
mask = pl.any_horizontal(parts)
else:
mask = pl.all_horizontal(parts)
return df.filter(mask)
class MonitorRuleEngine:
"""通用监控规则引擎 — 接收实时行情 DataFrame,评估所有规则,返回 AlertEvent。
StrategyMonitorService 的区别:
- 规则来自 monitor_rules 存储 (用户可配), 而非写死的 strategy config
- 支持 scope (symbols/all/sector) 过滤作用域
- 支持 conditions + logic (AND/OR) 任意组合
- cooldown 去重: 同一 (rule_id, symbol) 在冷却期内不重复触发
"""
def __init__(self, alert_handler: Callable[[dict], None] | None = None):
self._alert_handler = alert_handler
self._rules: dict[str, dict] = {} # rule_id → rule
# (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。
self._last_fire: dict[tuple[str, str], float] = {}
self._strategy_engine = None # 延迟注入, type=strategy 规则用它跑选股
# symbol → 股票名 (enriched DataFrame 已 drop name 列, 触发时从此映射回填)
self._name_map: dict[str, str] = {}
# 策略选股池状态: strategy_id → 上期选股符号集合 (用于 diff 变更)
self._strategy_pools: dict[str, set[str]] = {}
# 数据目录 (用于加载策略 overrides)
self._data_dir = None
# 历史窗口加载器: (target_date, lookback_days) → 多日 enriched DataFrame。
# 用于声明 filter_history 的策略 (如反包), 实时监控时拼历史窗口 + 今日行情跑选股。
# 为 None 时, filter_history 策略仍会被跳过 (保持旧行为, 不破坏无历史场景)。
self._history_loader: Callable[[_dt.date, int], "pl.DataFrame"] | None = None
# 本轮 evaluate() 产出的策略选股结果: strategy_id → {rows, total, as_of}
# 供策略页实时回显复用 (/api/screener/cached 端点直接读取此内存结果), 避免重跑
self._latest_strategy_results: dict[str, dict] = {}
def set_strategy_engine(self, engine) -> None:
"""注入 StrategyEngine, type=strategy 规则据此跑选股。"""
self._strategy_engine = engine
def set_data_dir(self, data_dir) -> None:
"""注入数据目录, 用于加载策略的用户覆盖配置。"""
self._data_dir = data_dir
def set_history_loader(self, fn) -> None:
"""注入历史窗口加载器, 用于声明 filter_history 的策略跑实时监控。
loader 签名: (target_date, lookback_days) 多日 enriched DataFrame
复用 ScreenerService._load_enriched_history (三级缓存, 命中 ~0ms)
None filter_history 策略退回到跳过逻辑 (不破坏无历史场景)
"""
self._history_loader = fn
def set_name_map(self, name_map: dict[str, str]) -> None:
"""注入 symbol → 股票名 映射, 用于在告警事件里回填 name 字段。
enriched DataFrame pipeline 计算后不含 name ( indicators/pipeline.py),
触发时从 instruments 表预构建此映射, 保证 AlertEvent.name 有值
"""
self._name_map = name_map or {}
# ── 规则管理 ───────────────────────────────────────
def set_rules(self, rules: list[dict]) -> None:
"""批量设置规则 (覆盖)。用于启动时 reload。"""
self._rules = {}
for r in rules:
if r.get("enabled") is not False:
self._rules[r["id"]] = r
logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules))
def add_rule(self, rule: dict) -> None:
if rule.get("enabled") is not False:
self._rules[rule["id"]] = rule
else:
self._rules.pop(rule["id"], None)
def remove_rule(self, rule_id: str) -> None:
self._rules.pop(rule_id, None)
# 清理对应的 cooldown 记录
self._last_fire = {k: v for k, v in self._last_fire.items() if k[0] != rule_id}
def clear(self) -> None:
self._rules.clear()
self._last_fire.clear()
@property
def rules(self) -> dict[str, dict]:
return dict(self._rules)
@property
def rule_count(self) -> int:
return len(self._rules)
def latest_strategy_results(self) -> dict[str, dict]:
"""返回本轮 evaluate() 产出的策略选股结果 (strategy_id → {rows, total, as_of})。
供策略页实时回显复用: /api/screener/cached 端点直接读取此内存结果,
避免对被监控的策略重跑第二遍 type=strategy 规则时返回空 dict
"""
return self._latest_strategy_results
def has_rule_type(self, rtype: str) -> bool:
"""是否存在指定类型的 (已启用) 规则。供 quote_service 判断是否需要注入特殊数据。"""
if not self._rules:
return False
return any(
r.get("enabled", True) and r.get("type") == rtype
for r in self._rules.values()
)
# ── 评估 ───────────────────────────────────────────
def evaluate(self, df: pl.DataFrame) -> list[dict]:
"""行情更新后评估所有规则。
Args:
df: 实时 enriched 数据 (~5500, signal_/csg_/指标列)
Returns:
触发的 AlertEvent dict 列表 ( ts/rule_id/source/type/symbol/...)
"""
if not self._rules or df.is_empty():
return []
now = time.time()
events: list[dict] = []
# 每轮重置: 只保留本次 evaluate 产出的策略结果
self._latest_strategy_results = {}
for rule_id, rule in self._rules.items():
try:
events.extend(self._evaluate_rule(df, rule, now))
except Exception as e:
logger.warning("规则评估失败 %s: %s", rule_id, e)
return events
def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]:
"""评估单条规则,返回触发的 events。"""
# 1. 按 scope 过滤作用域
scoped = self._apply_scope(df, rule)
if scoped.is_empty():
return []
# 2. 根据 type 构建命中集
# 元组格式: (event_type, symbol, name, price, pct, signals)
hit_rows: list[tuple[str, str, Any, Any, Any, list[str]]] = []
rtype = rule.get("type", "signal")
if rtype == "strategy":
# 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件
hit_rows = self._match_strategy(scoped, rule)
elif rtype == "ladder":
# 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message)
return self._evaluate_ladder(scoped, rule, now)
else:
# signal / price / market: 通用条件匹配
for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule):
hit_rows.append((rtype, sym, name, price, pct, hit_sigs))
if not hit_rows:
return []
# 3. cooldown 去重 + 生成 events
cooldown = rule.get("cooldown_seconds", 3600)
severity = rule.get("severity", "info")
source = rtype
events: list[dict] = []
for ev_type, sym, name, price, pct, hit_sigs in hit_rows:
# cooldown 键: 批量事件用特殊键, 单只事件用 (rule_id, symbol)
is_batch = sym == "_batch"
if is_batch:
key = (rule["id"], f"_{ev_type}_batch")
else:
key = (rule["id"], sym)
last = self._last_fire.get(key)
if last is not None and (now - last) < cooldown:
continue # 冷却期内, 跳过
self._last_fire[key] = now
# 批量事件: name 存放预构建的消息文本
if is_batch:
resolved_name = ""
message = name # name 字段即批量消息
else:
resolved_name = name if name else self._name_map.get(sym)
message = rule.get("message", "") or self._default_message(
rule, ev_type=ev_type, sym=sym, name=resolved_name,
pct=pct, price=price,
conditions=list(rule.get("conditions", [])) if rule.get("type") != "strategy" else None,
)
ev = {
"ts": int(now * 1000),
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"source": source,
"type": ev_type,
"symbol": "" if is_batch else sym,
"name": resolved_name,
"message": message,
"price": price,
"change_pct": pct,
"signals": hit_sigs,
"severity": severity,
# 触发条件快照 (signal/price/market 类型): 用于触发记录展示
# 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。
"conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [],
"logic": rule.get("logic", "and") if rtype != "strategy" else "and",
}
events.append(ev)
if self._alert_handler:
try:
self._alert_handler(ev)
except Exception as e:
logger.warning("alert handler failed: %s", e)
return events
@staticmethod
def _apply_scope(df: pl.DataFrame, rule: dict) -> pl.DataFrame:
"""按 scope 过滤 DataFrame。"""
scope = rule.get("scope", "symbols")
if scope == "all":
return df
if scope == "symbols":
syms = rule.get("symbols", [])
if not syms:
return df.head(0)
return df.filter(pl.col("symbol").is_in(syms))
if scope == "sector":
# sector 过滤: 需 df 含板块列 (后续接入 ext_data JOIN)
# 当前先返回全量, sector 精确过滤第二步完善
return df
return df
def _match_strategy(
self, df: pl.DataFrame, rule: dict,
) -> list[tuple[str, str, Any, Any, Any, list[str]]]:
"""策略类型评估: 跑策略选股 → 对比上期选股池 → 产出变更事件。
返回 [(event_type, symbol, name, price, pct, signals)]
event_type: "new_entry" (新入选) | "dropped" (已移出)
单只变更逐只返回; 同一策略 >5 只合并为一条批量事件 (symbol="_batch")
"""
if self._strategy_engine is None:
return []
sid = rule.get("strategy_id")
if not sid:
return []
try:
s = self._strategy_engine.get(sid)
except Exception:
return []
if s is None:
return []
# 运行策略选股: 复用当前 enriched DataFrame 跳过数据加载
overrides = {}
if self._data_dir:
try:
overrides = _strategy_config.load_override(self._data_dir, sid)
except Exception:
pass
# 声明 filter_history 的策略 (如反包) 需要多日历史窗口才能判定形态。
# 旧实现因"实时监控不支持 history loader"直接跳过 → 反包等策略盘中永不触发。
# 现接入 history_loader, 拼历史窗口 + 今日实时行情, 经 precomputed_history 喂给引擎。
# loader 为 None (未装配) 时退回跳过, 保持旧行为, 不破坏无历史场景。
run_kwargs: dict = {
"as_of": _dt.date.today(),
"overrides": overrides,
}
if s.filter_history_fn:
if self._history_loader is None:
logger.debug("策略 %s 需要历史数据但未注入 history_loader, 跳过实时监控", sid)
return []
try:
today = _dt.date.today()
lookback = max(1, getattr(s, "lookback_days", 30))
hist_df = self._history_loader(today, lookback)
if hist_df is None or hist_df.is_empty():
logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid)
return []
# 历史窗口可能与今日已落盘数据重叠: 排掉 hist_df 中 date==today 的行,
# 今日行情始终以实时 df 为准 (盘中逐轮更新, 最接近收盘真相)。
# 否则 today 行重复会污染 filter_history 的 .over("symbol") 窗口判定。
if "date" in hist_df.columns:
hist_df = hist_df.filter(pl.col("date") != today)
# 拼接历史窗口 + 今日实时行情 (filter_history 用 .over("symbol") 窗口, 多日天然可用)
run_kwargs["precomputed_history"] = pl.concat(
[hist_df, df], how="diagonal_relaxed"
)
except Exception as e:
logger.warning("策略 %s 加载历史窗口失败, 跳过: %s", sid, e)
return []
else:
# 普通策略: 复用当前 enriched DataFrame 跳过数据加载
run_kwargs["precomputed"] = df
try:
result = self._strategy_engine.run(sid, **run_kwargs)
except Exception as e:
logger.warning("策略 %s 选股执行失败: %s", sid, e)
return []
# 记录本轮完整选股结果 (供策略页实时回显: /cached 端点直接读取, 不落盘)。
# 与下面的 diff 事件无关 — 无论是否产生 new_entry/dropped, 结果都该可用于回显。
try:
import math
self._latest_strategy_results[sid] = {
"total": result.total,
"as_of": str(_dt.date.today()),
"rows": [
{k: (None if isinstance(v, float) and not math.isfinite(v) else v)
for k, v in row.items()}
for row in result.rows
],
}
except Exception: # noqa: BLE001
pass
current_pool: set[str] = {r["symbol"] for r in result.rows}
prev_pool = self._strategy_pools.get(sid)
# 首次运行: 仅记录当前选股池, 不产生事件
if prev_pool is None:
self._strategy_pools[sid] = current_pool
return []
new_entries = current_pool - prev_pool
dropped = prev_pool - current_pool
# 无变更
if not new_entries and not dropped:
return []
# 更新存储
self._strategy_pools[sid] = current_pool
sname = s.meta.get("name", "") or s.meta.get("id", sid)
# 构建查找表 (新入选股票可在 result.rows 中找到; 移出股票需从 df 找)
row_map: dict[str, dict] = {r["symbol"]: r for r in result.rows}
dropped_map: dict[str, dict] = {}
if dropped:
try:
_dd = df.filter(pl.col("symbol").is_in(list(dropped)))
for row in _dd.iter_rows(named=True):
dropped_map[row["symbol"]] = row
except Exception:
pass
results: list[tuple[str, str, Any, Any, Any, list[str]]] = []
# ── 新入选 ──
new_list = sorted(new_entries)
if len(new_list) > 5:
names: list[str] = []
for sym in new_list:
row = row_map.get(sym, {})
name = row.get("name") or self._name_map.get(sym, sym)
names.append(str(name))
message = f"策略「{sname}」进入 {len(new_entries)} 只:{''.join(names)}"
results.append(("new_entry", "_batch", message, None, None, []))
else:
for sym in new_list:
row = row_map.get(sym, {})
name = row.get("name") or self._name_map.get(sym, sym)
price = row.get("close")
pct = row.get("change_pct")
results.append(("new_entry", sym, name, price, pct, []))
# ── 已移出 ──
dropped_list = sorted(dropped)
if len(dropped_list) > 5:
names = []
for sym in dropped_list:
row = dropped_map.get(sym, {})
name = row.get("name") or self._name_map.get(sym, sym)
names.append(str(name))
message = f"策略「{sname}」移出 {len(dropped)} 只:{''.join(names)}"
results.append(("dropped", "_batch", message, None, None, []))
else:
for sym in dropped_list:
row = dropped_map.get(sym, {})
name = row.get("name") or self._name_map.get(sym, sym)
price = row.get("close")
pct = row.get("change_pct")
results.append(("dropped", sym, name, price, pct, []))
return results
@staticmethod
def _match_conditions(
df: pl.DataFrame, rule: dict,
) -> list[tuple[str, Any, Any, Any, list[str]]]:
"""按 conditions + logic 匹配,返回命中行 [(symbol,name,price,pct,signals)]。"""
conditions = rule.get("conditions", [])
logic = rule.get("logic", "and")
if not conditions:
return []
hit_df = _build_condition_mask(df, conditions, logic)
results = []
for row in hit_df.iter_rows(named=True):
sym = row.get("symbol", "")
name = row.get("name")
price = row.get("close")
pct = row.get("change_pct")
# 收集命中的信号列名 (仅 op=truth 且为真的)
hit_sigs = [
c["field"] for c in conditions
if c.get("op") == "truth" and row.get(c["field"])
]
results.append((sym, name, price, pct, hit_sigs))
return results
def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]:
"""评估连板梯队封单监控规则。
封单量从注入的临时列 _sealed_vol () 读取 ( quote_service 评估前注入)
命中条件: 封单比较值 <= threshold (且封单 > 0, 排除无 depth 数据的股票)
涨停(direction=up) 炸板预警; 跌停(direction=down) 翘板预警
"""
if "_sealed_vol" not in scoped.columns:
return [] # 无封单数据 (depth 未拉取), 安全降级
metric = rule.get("metric", "sealed_vol")
threshold = rule.get("threshold", 0)
direction = rule.get("direction", "up")
cooldown = rule.get("cooldown_seconds", 600)
severity = rule.get("severity", "warn")
# 比较值: sealed_vol 直接用 (手), sealed_amount = 手 × 100股 × close
if metric == "sealed_amount":
cmp_expr = pl.col("_sealed_vol") * 100 * pl.col("close")
unit = ""
else:
cmp_expr = pl.col("_sealed_vol")
unit = ""
# 命中: 封单 > 0 (有数据) 且 比较值 <= 阈值
hit = scoped.filter(
pl.col("_sealed_vol").is_not_null()
& (pl.col("_sealed_vol") > 0)
& (cmp_expr <= threshold)
)
if hit.is_empty():
return []
warn_label = "炸板预警" if direction == "up" else "翘板预警"
events: list[dict] = []
for row in hit.iter_rows(named=True):
sym = row.get("symbol", "")
key = (rule["id"], sym)
last = self._last_fire.get(key)
if last is not None and (now - last) < cooldown:
continue
self._last_fire[key] = now
name = row.get("name") or self._name_map.get(sym) or sym
price = row.get("close")
pct = row.get("change_pct")
sealed_vol = row.get("_sealed_vol")
# 预警封单值 (展示用)
sealed_value = sealed_vol * 100 * (price or 0) if metric == "sealed_amount" else sealed_vol
# message 体现预警封单量 + 阈值
if metric == "sealed_amount":
sv_text = f"{sealed_value / 1e4:.0f}{unit}"
th_text = f"{threshold / 1e4:.0f}{unit}"
else:
sv_text = f"{sealed_value:,.0f} {unit}"
th_text = f"{threshold:,.0f} {unit}"
message = f"{warn_label} · 封单 {sv_text}{th_text}"
events.append({
"ts": int(now * 1000),
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"source": "ladder",
"type": warn_label,
"symbol": sym,
"name": name,
"message": message,
"price": price,
"change_pct": pct,
"signals": [],
"severity": severity,
"conditions": [],
"logic": "and",
"sealed_value": sealed_value, # 预警封单量/额 (飞书+记录展示)
"sealed_metric": metric,
})
return events
def _default_message(self, rule: dict, ev_type: str = "", sym: str = "",
name: str = "", pct: Any = None, price: Any = None,
conditions: list[dict] | None = None) -> str:
"""生成默认 message。
- strategy: 按变更方向生成 (进入/移出 + 涨跌幅)
- signal/price/market: 条件摘要 + 现价 + 涨跌幅 (避免笼统的信号触发)
"""
rtype = rule.get("type", "signal")
if rtype == "strategy":
# 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分
sname = ""
sid = rule.get("strategy_id")
if sid and self._strategy_engine is not None:
try:
s = self._strategy_engine.get(sid)
sname = s.meta.get("name", "") or s.meta.get("id", "")
except Exception: # noqa: BLE001
sname = ""
if not sname:
rn = rule.get("name", "")
sname = rn.split(" · ", 1)[1] if " · " in rn else (rn or "策略")
if ev_type == "new_entry":
pct_text = ""
if pct is not None:
sign = "+" if pct >= 0 else ""
pct_text = f" {sign}{pct * 100:.1f}%"
return f"策略「{sname}」进入 {name}{pct_text}"
elif ev_type == "dropped":
pct_text = ""
if pct is not None:
sign = "+" if pct >= 0 else ""
pct_text = f" {sign}{pct * 100:.1f}%"
return f"策略「{sname}」移出 {name}{pct_text}"
return f"策略「{sname}」变更"
# signal / price / market: 条件摘要 + 现价 + 涨跌幅
# 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2"
cond_text = self._format_conditions_text(rule, conditions)
price_text = f"现价 {price}" if price is not None else ""
pct_text = ""
if pct is not None:
sign = "+" if pct >= 0 else ""
pct_text = f"{sign}{pct * 100:.1f}%"
tail = " · ".join(s for s in (price_text, pct_text) if s)
if cond_text and tail:
return f"{cond_text} · {tail}"
return cond_text or tail or "监控触发"
@staticmethod
def _format_conditions_text(rule: dict, conditions: list[dict] | None) -> str:
"""把 rule.conditions 拼成可读文本 (用于 message / 推送)。
op=truth: 直接用信号中文名 ( "MA20金叉")
op=比较: 字段中文名 + 操作符 + ( "涨跌幅≥5")
logic: and "", or ""
"""
conds = conditions if conditions is not None else list(rule.get("conditions", []))
if not conds:
return ""
logic_word = "" if rule.get("logic", "and") == "and" else ""
parts: list[str] = []
for c in conds:
field = c.get("field", "")
op = c.get("op", "truth")
value = c.get("value")
label = _signal_cn_name(field) or field
if op == "truth":
parts.append(label)
else:
op_map = {"gte": "", "lte": "", "gt": ">", "lt": "<", "eq": "="}
parts.append(f"{label}{op_map.get(op, op)}{value}")
return f" {logic_word} ".join(parts)
-258
View File
@@ -1,258 +0,0 @@
"""监控规则 — 统一的 MonitorRule 模型,覆盖策略/个股信号/个股价格/市场异动四类。
职责:
- data/user_data/monitor_rules/*.json 加载规则定义
- 校验规则字段合法性
- 提供 CRUD (load_all / save_one / delete_one)
不知道: 行情评估引擎API告警落盘纯函数 + 文件存储
设计 (镜像 custom_signals.py 的写法):
- 一对象一文件 + glob 全扫 + 全量重写
- 字段白名单复用 custom_signals.ALLOWED_FIELDS (阈值条件) + 信号列清单 (布尔条件)
- id 正则与 custom_signals 一致,保证可纳入同一索引体系
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from app.strategy.custom_signals import ALLOWED_FIELDS
logger = logging.getLogger(__name__)
# ── 常量 ────────────────────────────────────────────────
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder"}
SCOPES = {"symbols", "all", "sector"}
LOGICS = {"and", "or"}
DIRECTIONS = {"entry", "exit", "both"}
SEVERITIES = {"info", "warn", "critical"}
OPS = {">", ">=", "<", "<=", "==", "!="}
# ladder 规则: 封单监控的指标 (量=手, 额=元)
LADDER_METRICS = {"sealed_vol", "sealed_amount"}
# ladder 规则: 方向 (up=涨停炸板预警, down=跌停翘板预警)
LADDER_DIRECTIONS = {"up", "down"}
# 布尔信号列前缀 (op=truth 时 field 取这些)
_SIGNAL_PREFIXES = ("signal_", "csg_")
# ── 持久化 (镜像 custom_signals.py) ─────────────────────
def _dir(data_dir: Path) -> Path:
d = data_dir / "user_data" / "monitor_rules"
d.mkdir(parents=True, exist_ok=True)
return d
def _path(data_dir: Path, rule_id: str) -> Path:
return _dir(data_dir) / f"{rule_id}.json"
def load_all(data_dir: Path) -> list[dict]:
"""读取全部监控规则。损坏的文件被跳过。"""
d = _dir(data_dir)
out: list[dict] = []
for f in sorted(d.glob("*.json")):
try:
out.append(json.loads(f.read_text(encoding="utf-8")))
except Exception as e:
logger.warning("monitor rule load failed %s: %s", f.name, e)
return out
def load_one(data_dir: Path, rule_id: str) -> dict | None:
p = _path(data_dir, rule_id)
if not p.exists():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("monitor rule load failed %s: %s", rule_id, e)
return None
def save_one(data_dir: Path, rule: dict) -> None:
p = _path(data_dir, rule["id"])
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(rule, ensure_ascii=False, indent=2), encoding="utf-8")
def delete_one(data_dir: Path, rule_id: str) -> bool:
p = _path(data_dir, rule_id)
if p.exists():
p.unlink()
return True
return False
# ── 校验 ────────────────────────────────────────────────
def _is_signal_field(field: str) -> bool:
"""判断 field 是否为布尔信号列 (signal_ / csg_ 前缀)。"""
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
def validate(rule: dict) -> None:
"""校验一条监控规则,非法则抛 ValueError (含中文信息)。"""
rid = rule.get("id", "")
if not isinstance(rid, str) or not ID_RE.match(rid):
raise ValueError(f"规则 id 非法 (仅小写字母数字下划线, 1-40字符): {rid!r}")
if not isinstance(rule.get("name"), str) or not rule["name"].strip():
raise ValueError("规则 name 不能为空")
if rule.get("type") not in RULE_TYPES:
raise ValueError(f"type 必须是 {RULE_TYPES} 之一")
# 策略类型: 需要 strategy_id + direction,conditions 可空
if rule.get("type") == "strategy":
if not rule.get("strategy_id"):
raise ValueError("策略类型规则必须指定 strategy_id")
if rule.get("direction", "entry") not in DIRECTIONS:
raise ValueError(f"direction 必须是 {DIRECTIONS} 之一")
elif rule.get("type") == "ladder":
# 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions
if rule.get("metric", "sealed_vol") not in LADDER_METRICS:
raise ValueError(f"metric 必须是 {LADDER_METRICS} 之一")
if rule.get("direction", "up") not in LADDER_DIRECTIONS:
raise ValueError(f"direction 必须是 {LADDER_DIRECTIONS} 之一 (up=涨停炸板, down=跌停翘板)")
thr = rule.get("threshold")
if not isinstance(thr, (int, float)) or thr < 0:
raise ValueError("threshold 必须是非负数字 (封单 ≤ 此值时报警)")
else:
# 信号/价格/市场类型: 需要 conditions
conds = rule.get("conditions")
if not isinstance(conds, list) or len(conds) == 0:
raise ValueError("conditions 不能为空")
if len(conds) > 8:
raise ValueError("conditions 最多 8 条")
if rule.get("logic", "and") not in LOGICS:
raise ValueError(f"logic 必须是 {LOGICS} 之一")
for i, c in enumerate(conds):
if not isinstance(c, dict):
raise ValueError(f"{i+1} 个条件格式错误")
field = c.get("field", "")
op = c.get("op", "")
if op == "truth":
# 布尔信号: field 必须是 signal_/csg_ 前缀
if not _is_signal_field(field):
raise ValueError(f"{i+1} 个条件: op=truth 时 field 必须是信号列 (signal_/csg_ 前缀): {field!r}")
elif op in OPS:
# 阈值比较: field 必须在白名单, 需要 value
if field not in ALLOWED_FIELDS:
raise ValueError(f"{i+1} 个条件: 阈值字段 {field!r} 不在白名单")
if not isinstance(c.get("value"), (int, float)):
raise ValueError(f"{i+1} 个条件: value 必须是数字")
else:
raise ValueError(f"{i+1} 个条件: op {op!r} 非法 (应为 truth 或 {OPS})")
# scope 校验
if rule.get("scope", "symbols") not in SCOPES:
raise ValueError(f"scope 必须是 {SCOPES} 之一")
if rule.get("scope") == "symbols":
syms = rule.get("symbols")
if not isinstance(syms, list) or len(syms) == 0:
raise ValueError("scope=symbols 时 symbols 不能为空")
# 其余枚举
if rule.get("severity", "info") not in SEVERITIES:
raise ValueError(f"severity 必须是 {SEVERITIES} 之一")
cd = rule.get("cooldown_seconds", 3600)
if not isinstance(cd, int) or cd < 0:
raise ValueError("cooldown_seconds 必须是非负整数")
def normalize(rule: dict) -> dict:
"""补全默认字段,返回规范化后的规则 (不校验)。"""
r = dict(rule)
r.setdefault("enabled", True)
r.setdefault("scope", "symbols")
r.setdefault("symbols", [])
r.setdefault("sector", None)
r.setdefault("strategy_id", None)
# direction 默认值: ladder 用 "up", 其余用 "entry"
r.setdefault("direction", "up" if r.get("type") == "ladder" else "entry")
r.setdefault("conditions", [])
# ladder 专属默认字段
r.setdefault("metric", "sealed_vol")
r.setdefault("threshold", 0)
r.setdefault("logic", "and")
r.setdefault("cooldown_seconds", 3600)
r.setdefault("severity", "info")
r.setdefault("message", "")
r.setdefault("webhook_url", "")
r.setdefault("webhook_enabled", False)
r.setdefault("created_at", datetime.now(timezone.utc).isoformat())
return r
# 策略监控自动迁移的规则 id 前缀 (固定, 保证幂等)
STRATEGY_RULE_PREFIX = "mr_strategy_"
def strategy_rule_id(strategy_id: str) -> str:
"""策略监控规则 id = mr_strategy_{strategy_id}"""
return f"{STRATEGY_RULE_PREFIX}{strategy_id}"
def migrate_strategy_monitors(data_dir: Path, strategy_ids: list[str], strategy_names: dict[str, str]) -> list[dict]:
"""把 preferences.strategy_monitor_ids 里的策略,同步生成/更新 type=strategy 规则。
幂等: 已存在的策略规则会被更新 (方向/名称),不会重复创建
已从 strategy_ids 移除的策略, 其规则会被停用 (enabled=False) 而非删除 (保留历史触发记录的关联)
Args:
data_dir: 数据目录
strategy_ids: 当前监控池中的策略 id 列表
strategy_names: {strategy_id: 策略名} 用于规则显示名
Returns:
本次生成/更新的规则列表
"""
desired = set(strategy_ids)
existing = load_all(data_dir)
# 已存在的策略规则 {strategy_id: rule}
existing_strategy_rules: dict[str, dict] = {}
for r in existing:
rid = r.get("id", "")
if rid.startswith(STRATEGY_RULE_PREFIX):
sid = rid[len(STRATEGY_RULE_PREFIX):]
if sid:
existing_strategy_rules[sid] = r
touched: list[dict] = []
# 1. 为当前监控池的策略 upsert 规则
for sid in desired:
rule_id = strategy_rule_id(sid)
name = strategy_names.get(sid, sid)
rule = existing_strategy_rules.get(sid)
if rule is None:
rule = normalize({
"id": rule_id,
"name": f"策略监控 · {name}",
"type": "strategy",
"scope": "all",
"strategy_id": sid,
"direction": "entry",
"conditions": [],
"cooldown_seconds": 3600,
"enabled": True,
})
else:
rule = dict(rule)
rule["enabled"] = True
rule["strategy_id"] = sid
rule["name"] = f"策略监控 · {name}"
rule.setdefault("scope", "all")
rule.setdefault("direction", "entry")
save_one(data_dir, rule)
touched.append(rule)
# 2. 不在监控池的策略 → 停用其规则 (不删除)
for sid, rule in existing_strategy_rules.items():
if sid not in desired and rule.get("enabled") is not False:
rule = dict(rule)
rule["enabled"] = False
save_one(data_dir, rule)
return touched
@@ -22,7 +22,6 @@ class Cap(StrEnum):
INTRADAY_BATCH = "intraday.batch"
DEPTH5 = "depth5"
DEPTH5_BATCH = "depth5.batch"
WEBSOCKET = "websocket"
FINANCIAL = "financial"
ADJ_FACTOR = "adj_factor"
+1 -18
View File
@@ -18,7 +18,6 @@ from app import secrets_store
_sync_client: TickFlow | None = None
_async_client: AsyncTickFlow | None = None
_paid_realtime_client: TickFlow | None = None
# ===== 服务器归属判定 =====
@@ -72,27 +71,11 @@ def get_async_client() -> AsyncTickFlow:
return _async_client
def get_paid_realtime_client() -> TickFlow | None:
"""实时行情专用付费服务器客户端。
none/free 的历史日K仍走 get_client() free-api实时行情全部走付费服务器
Free 档如果有有效 key也使用这里的 paid endpoint 调按标的实时接口
"""
global _paid_realtime_client
key = secrets_store.get_tickflow_key()
if not key:
return None
if _paid_realtime_client is None:
_paid_realtime_client = TickFlow(api_key=key, base_url=_base_url())
return _paid_realtime_client
def reset_clients() -> None:
"""Key 变化后调用 — 让下一次 get_client() 拿新实例。"""
global _sync_client, _async_client, _paid_realtime_client
global _sync_client, _async_client
_sync_client = None
_async_client = None
_paid_realtime_client = None
def current_mode() -> str:
+1 -8
View File
@@ -236,12 +236,6 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
defaults(Cap.ADJ_FACTOR))
# websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断
# 若 expert 的其他 cap 都通,则推断 websocket 也可用
if (Cap.FINANCIAL in available and Cap.INTRADAY_BATCH in available):
available[Cap.WEBSOCKET] = CapabilityLimits(
subscribe=defaults(Cap.WEBSOCKET).get("subscribe", 100),
)
log.append("✓ websocket (inferred from expert tier)")
return CapabilitySet(available), log
@@ -297,7 +291,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
# 拥有**任意一个**即认作该档及以上。自上而下匹配。
# 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。
TIER_SIGNATURES: dict[str, set[Cap]] = {
"expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH, Cap.WEBSOCKET},
"expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH},
"pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL,
Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH},
"starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
@@ -359,7 +353,6 @@ _CAP_ALIASES: dict[Cap, str] = {
Cap.INTRADAY_BATCH: "批量分时",
Cap.DEPTH5: "五档",
Cap.DEPTH5_BATCH: "批量五档",
Cap.WEBSOCKET: "WS",
Cap.FINANCIAL: "财务",
Cap.ADJ_FACTOR: "复权",
Cap.QUOTE_BATCH: "批量行情",
+1 -16
View File
@@ -3,7 +3,6 @@
Phase 1 实现:
- 常用指数成份(沪深 300 / 中证 500 / 上证 50) TickFlow `quote.pool` 端点拉取并缓存
- A 通过 instruments.batch 获取
- 自选池 = 用户的 watchlist
"""
from __future__ import annotations
@@ -19,7 +18,7 @@ from app.tickflow.client import get_client
logger = logging.getLogger(__name__)
PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index", "watchlist"]
PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index"]
# TickFlow universe id 是它内部命名(见 tf.universes.list())。
# 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。
@@ -54,9 +53,6 @@ def _pool_cache_path(pool_id: str) -> Path:
def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]:
"""返回标的池里的 symbol 列表。"""
if pool_id == "watchlist":
return _load_watchlist()
cache = _pool_cache_path(pool_id)
if cache.exists() and not refresh:
df = pl.read_parquet(cache)
@@ -132,17 +128,6 @@ def _fetch_pool(pool_id: PoolId) -> list[str]:
return []
def _load_watchlist() -> list[str]:
"""读取用户自选(由 watchlist service 维护)。"""
path = settings.data_dir / "user_data" / "watchlist.parquet"
if not path.exists():
return []
df = pl.read_parquet(path)
if df.is_empty() or "symbol" not in df.columns:
return []
return df["symbol"].to_list()
# 兜底:Free 用户/无 API 时给一个小型可用集合,让 UI 不至于空白
DEMO_SYMBOLS = [
"600000.SH", # 浦发银行
+2 -3
View File
@@ -56,7 +56,6 @@ class DataStore:
"instruments_ext",
"kline_ext",
"pools",
"backtest_results",
"screener_results",
"ai_cache",
"user_data",
@@ -77,7 +76,7 @@ class DataStore:
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
新版改为 exe_dir / "data" (子目录)老用户首次升级时旧数据在兄弟目录,
若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)
若不迁移会导致历史行情/策略/监控全部"丢失"(实际还在旧位置)
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
1. 旧目录存在且新 data/ 还基本为空 整目录搬迁 (shutil.move, 跨盘符安全)
@@ -408,7 +407,7 @@ class KlineRepository:
how="left",
)
# 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用
# 缓存完整历史 (含指标+必要基础信息)
self._enriched_history_cache = df_full
self._enriched_history_start = df_full["date"].min()
logger.info("enriched 历史缓存: %d rows, %s ~ %s",
@@ -1,422 +0,0 @@
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
from app.backtest.engine import BacktestEngine, MatcherConfig
def _panel(symbols: list[str], days: int = 4, price: float = 10.0, overrides: dict[tuple[str, int], dict] | None = None) -> pl.DataFrame:
overrides = overrides or {}
start = date(2024, 1, 1)
rows = []
for sym in symbols:
for i in range(days):
patch = overrides.get((sym, i), {})
rows.append({
"symbol": sym,
"name": sym,
"date": start + timedelta(days=i),
"open": patch.get("open", price),
"high": patch.get("high", price),
"low": patch.get("low", price),
"close": patch.get("close", price),
"volume": patch.get("volume", 100_000),
"score": patch.get("score", {"A": 4, "B": 3, "C": 2, "D": 1}.get(sym, 0)),
"signal_limit_up": patch.get("signal_limit_up", False),
"signal_limit_down": patch.get("signal_limit_down", False),
})
return pl.DataFrame(rows).sort(["symbol", "date"])
def _mask(panel: pl.DataFrame, marks: set[tuple[str, int]]) -> pl.Series:
values = []
base = date(2024, 1, 1)
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
day = (row["date"] - base).days
values.append((row["symbol"], day) in marks)
return pl.Series(values, dtype=pl.Boolean)
def _engine() -> BacktestEngine:
return BacktestEngine(repo=None) # simulate_portfolio 不访问 repo
def test_max_exposure_sets_target_position_and_caps_count():
panel = _panel(["A", "B", "C", "D"], days=3)
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0), ("D", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=3,
max_exposure_pct=0.6,
initial_capital=100_000,
),
)
assert len(result.trades) == 3
assert {t.symbol for t in result.trades} == {"A", "B", "C"}
assert all(abs(t.position_pct - 0.2) < 0.001 for t in result.trades)
assert result.stats["max_exposure"] <= 0.61
def test_one_price_limit_up_blocks_buy():
panel = _panel(
["A"],
days=3,
overrides={
("A", 1): {"open": 11, "high": 11, "low": 11, "close": 11, "signal_limit_up": True},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_positions=1, initial_capital=100_000),
)
assert result.trades == []
assert result.stats["execution"]["buy_limit_up"] == 1
def test_failed_open_exit_keeps_slot_and_blocks_replacement_buy():
panel = _panel(
["A", "B", "C", "D"],
days=4,
overrides={
("A", 2): {"open": 9, "high": 9, "low": 9, "close": 9, "signal_limit_down": True},
},
)
entries = _mask(panel, {
("A", 0), ("B", 0), ("C", 0),
("D", 1),
})
exits = _mask(panel, {("A", 1)})
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=3,
max_exposure_pct=0.6,
initial_capital=100_000,
),
)
assert "D" not in {t.symbol for t in result.trades}
assert result.stats["execution"]["sell_limit_down"] == 1
assert result.stats["execution"]["pending_exit"] == 1
assert result.stats["execution"]["buy_no_slot"] >= 1
a_trade = next(t for t in result.trades if t.symbol == "A")
assert a_trade.blocked_exit_days == 1
assert a_trade.exit_reason == "signal"
def test_trailing_stop_uses_high_water_mark():
panel = _panel(
["A"],
days=5,
overrides={
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
("A", 3): {"open": 12, "high": 12, "low": 11.3, "close": 11.3},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
initial_capital=100_000,
trailing_stop_pct=0.05,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.exit_reason == "trailing_stop"
assert trade.exit_price == 11.4
def test_trailing_take_profit_requires_activation():
panel = _panel(
["A"],
days=5,
overrides={
("A", 2): {"open": 10, "high": 10.8, "low": 10.4, "close": 10.8},
("A", 3): {"open": 10.8, "high": 10.8, "low": 10.4, "close": 10.4},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
initial_capital=100_000,
trailing_take_profit_activate_pct=0.10,
trailing_take_profit_drawdown_pct=0.03,
),
)
assert result.trades[0].exit_reason == "end"
def test_trailing_take_profit_exits_after_activation():
panel = _panel(
["A"],
days=5,
overrides={
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
("A", 3): {"open": 12, "high": 12, "low": 11.5, "close": 11.5},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
initial_capital=100_000,
trailing_take_profit_activate_pct=0.10,
trailing_take_profit_drawdown_pct=0.03,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.exit_reason == "trailing_take_profit"
assert trade.exit_price == 11.7
def test_score_filter_uses_signal_day_score_range():
panel = _panel(
["A", "B", "C"],
days=3,
overrides={
("A", 0): {"score": 70},
("B", 0): {"score": 80},
("C", 0): {"score": 90},
("A", 1): {"score": 100},
("B", 1): {"score": 1},
("C", 1): {"score": 1},
},
)
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=3,
initial_capital=100_000,
score_min=71,
score_max=85,
),
)
assert {t.symbol for t in result.trades} == {"B"}
assert result.trades[0].entry_score == 80
assert result.stats["execution"]["buy_score_filter"] == 2
def test_independent_candidates_allow_overlapping_same_symbol_trades():
panel = _panel(
["A"],
days=5,
overrides={
("A", 0): {"close": 10},
("A", 1): {"close": 11},
("A", 2): {"close": 12},
("A", 3): {"close": 13},
("A", 4): {"close": 14},
},
)
entries = _mask(panel, {("A", 0), ("A", 1)})
exits = _mask(panel, set())
result = _engine().simulate_independent_candidates(
panel,
entries,
exits,
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, max_hold_days=2),
)
assert result.stats["full_kind"] == "candidate_execution"
assert result.stats["n_candidates"] == 2
assert len(result.trades) == 2
assert [t.entry_date for t in result.trades] == ["2024-01-01", "2024-01-02"]
assert [t.exit_date for t in result.trades] == ["2024-01-03", "2024-01-04"]
assert all(t.exit_reason == "max_hold" for t in result.trades)
def test_independent_candidates_apply_stop_loss():
panel = _panel(
["A"],
days=4,
overrides={
("A", 0): {"close": 10, "low": 10},
("A", 1): {"open": 10, "high": 10, "low": 8.9, "close": 9},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_independent_candidates(
panel,
entries,
exits,
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, stop_loss_pct=0.1),
)
assert len(result.trades) == 1
assert result.trades[0].exit_reason == "stop_loss"
assert result.trades[0].exit_price == 9.0
def test_signal_exit_takes_priority_over_max_hold():
"""同一日既有卖点信号又到期 → 应按 signal 平仓 (卖点优先于 max_hold 兜底)。"""
panel = _panel(
["A"],
days=4,
overrides={
# day1 次日开盘买入 (open_t+1), 价 10
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
# day2 持有 (hold_days 计到 1)
("A", 2): {"open": 11, "high": 11, "low": 11, "close": 11},
# day3: 既到期 (hold_days=2 >= max_hold_days=2) 又有卖点信号 → signal 优先
("A", 3): {"open": 12, "high": 12, "low": 12, "close": 12},
},
)
entries = _mask(panel, {("A", 0)}) # day0 收盘确认 → day1 开盘买
exits = _mask(panel, {("A", 2)}) # day2 收盘确认卖点 → day3 开盘卖
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
max_hold_days=2,
initial_capital=100_000,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.exit_reason == "signal"
assert trade.exit_price == 12.0 # 卖点用 day3 开盘 (exit_fill 跟随 matching=open_t+1)
def test_stop_loss_triggers_even_when_expired_in_open_mode():
"""open_t+1 模式下仓位到期且当日破止损 → 应按 stop_loss 平仓 (风控优先于 max_hold)。"""
panel = _panel(
["A"],
days=4,
overrides={
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
# day3 开盘跳空跌破止损 (-10%): open=8.9 < 9.0 止损线, low=8.5
("A", 3): {"open": 8.9, "high": 8.9, "low": 8.5, "close": 8.7},
},
)
entries = _mask(panel, {("A", 0)})
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
max_positions=1,
max_hold_days=2,
stop_loss_pct=0.1,
initial_capital=100_000,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.exit_reason == "stop_loss"
# 风控盘中触发: 开盘价 8.9 <= 止损线 9.0 → 按开盘价 8.9 成交
assert trade.exit_price == 8.9
def test_default_fill_is_buy_open_sell_close():
"""拆分口径: 建仓=次日开盘, 清仓=收盘。entry_price 用次日 open, exit_price 用收盘价。"""
panel = _panel(
["A"],
days=4,
overrides={
# day1: 次日开盘买入, 开盘 10
("A", 1): {"open": 10, "high": 10.5, "low": 9.5, "close": 10.2},
# day2: 到期 (max_hold_days=1), 收盘卖
("A", 2): {"open": 11, "high": 11, "low": 10, "close": 10.8},
},
)
entries = _mask(panel, {("A", 0)}) # day0 收盘确认
exits = _mask(panel, set())
result = _engine().simulate_portfolio(
panel,
entries,
exits,
MatcherConfig(
entry_fill="open_t+1",
exit_fill="close_t",
fees_pct=0,
slippage_bps=0,
max_positions=1,
max_hold_days=1,
initial_capital=100_000,
),
)
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.entry_price == 10.0 # 次日开盘
assert trade.exit_price == 10.8 # 到期日收盘
assert trade.exit_reason == "max_hold"
@@ -1,59 +0,0 @@
"""全量模拟 (full mode) 尾部执行回归测试。"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
from app.backtest.engine import BacktestEngine, MatcherConfig
def _panel_with_tail(symbols: list[str], n_data_days: int) -> pl.DataFrame:
start = date(2024, 1, 1)
rows = []
for sym in symbols:
for i in range(n_data_days):
px = 10.0 + i
rows.append({
"symbol": sym,
"date": start + timedelta(days=i),
"open": px,
"high": px,
"low": px,
"close": px,
"volume": 100_000,
"signal_limit_up": False,
"signal_limit_down": False,
})
return pl.DataFrame(rows).sort(["symbol", "date"])
def test_full_simulation_executes_signal_at_tail():
"""信号集中在正式区间最后一天时, tail 数据应允许次日开盘买入并按策略退出。"""
n_days = 6
panel = _panel_with_tail(["A"], n_days + 3)
start = date(2024, 1, 1)
end = start + timedelta(days=n_days - 1)
entry_vals = []
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
entry_vals.append(row["date"] == end)
entry_mask = pl.Series(entry_vals, dtype=pl.Boolean)
exit_mask = pl.Series([False] * len(panel), dtype=pl.Boolean)
result = BacktestEngine(repo=None).simulate_independent_candidates( # type: ignore[arg-type]
panel,
entry_mask,
exit_mask,
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_hold_days=2),
)
assert not result.stats.get("error"), f"unexpected error: {result.stats.get('error')}"
assert result.stats.get("full_kind") == "candidate_execution"
assert result.stats.get("n_candidates") == 1
assert result.stats.get("n_trades") == 1
assert len(result.trades) == 1
trade = result.trades[0]
assert trade.entry_signal_date == str(end)
assert trade.entry_date == str(end + timedelta(days=1))
assert trade.exit_reason == "max_hold"
@@ -1,163 +0,0 @@
from __future__ import annotations
from datetime import date, timedelta
from types import SimpleNamespace
import polars as pl
from app.backtest.engine import BacktestEngine, SimResult
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
from app.strategy.engine import StrategyDef
def _strategy(**kwargs) -> StrategyDef:
defaults = dict(
meta={"id": "test", "name": "test", "scoring": {}, "params": [], "limit": 100},
basic_filter={"enabled": True, "amount_min": 100.0},
entry_signals=[],
exit_signals=[],
stop_loss=None,
trailing_stop=None,
trailing_take_profit_activate=None,
trailing_take_profit_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=lambda df, params: pl.lit(True),
filter_history_fn=None,
lookback_days=1,
source="custom",
file_path=None,
)
defaults.update(kwargs)
return StrategyDef(**defaults)
class _StrategyEngineStub:
def __init__(self, strategy: StrategyDef) -> None:
self.strategy = strategy
def get(self, strategy_id: str) -> StrategyDef:
return self.strategy
class _RepoStub:
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
return pl.DataFrame()
class _EngineStub:
def __init__(self, panel: pl.DataFrame) -> None:
self.panel = panel
self.repo = _RepoStub()
self.load_args = None
self.sim_panel: pl.DataFrame | None = None
self.sim_entries: pl.Series | None = None
def load_panel(self, symbols, start: date, end: date) -> pl.DataFrame:
self.load_args = (symbols, start, end)
return self.panel
def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None) -> SimResult:
self.sim_panel = panel
self.sim_entries = entries
return SimResult(
equity_curve=[{"date": "2024-01-01", "value": config.initial_capital}],
drawdown_curve=[{"date": "2024-01-01", "value": 0.0}],
trades=[],
per_symbol_stats=[],
stats={"total_return": 0.0, "n_trades": 0},
)
def test_basic_filter_only_limits_entries_not_panel_rows():
start = date(2024, 1, 1)
rows = []
for i, amount in enumerate([1000.0, 0.0, 1000.0]):
rows.append({
"symbol": "A",
"name": "A",
"date": start + timedelta(days=i),
"open": 10.0 + i,
"high": 10.0 + i,
"low": 10.0 + i,
"close": 10.0 + i,
"volume": 100_000,
"amount": amount,
"signal_limit_up": False,
"signal_limit_down": False,
})
panel = pl.DataFrame(rows).sort(["symbol", "date"])
engine = _EngineStub(panel)
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(_strategy()))
result = service.run(StrategyBacktestConfig(
strategy_id="test",
symbols=None,
start=start,
end=start + timedelta(days=2),
matching="close_t",
mode="position",
))
assert result.error is None
assert engine.sim_panel is not None
assert engine.sim_panel.height == 3
assert engine.sim_panel.filter(pl.col("amount") == 0.0).height == 1
assert engine.sim_entries is not None
assert engine.sim_entries.to_list() == [True, False, True]
assert engine.load_args is not None
assert engine.load_args[1] < start # warmup 只用于计算, 不参与正式交易
def test_score_normalizes_inside_strategy_candidate_universe():
panel = pl.DataFrame({
"symbol": ["A", "B", "C"],
"date": [date(2024, 1, 1)] * 3,
"factor": [10.0, 20.0, 1000.0],
})
universe = pl.Series([True, True, False], dtype=pl.Boolean)
strategy = SimpleNamespace(meta={"scoring": {"factor": 1.0}, "order_by": "score", "descending": True})
scored = StrategyBacktestService._apply_score(panel, strategy, None, universe_mask=universe)
scores = dict(zip(scored["symbol"].to_list(), scored["score"].to_list()))
assert scores["A"] == 0.0
assert scores["B"] == 100.0
assert scores["C"] == 0.0
def test_full_mode_executes_every_candidate_with_strategy_rules():
start = date(2024, 1, 1)
panel = pl.DataFrame([
{"symbol": "A", "name": "A", "date": start, "open": 10.0, "high": 10.0, "low": 10.0, "close": 10.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
{"symbol": "A", "name": "A", "date": start + timedelta(days=1), "open": 11.0, "high": 11.0, "low": 11.0, "close": 11.0, "volume": 1, "amount": 0.0, "signal_limit_up": False, "signal_limit_down": False},
{"symbol": "A", "name": "A", "date": start + timedelta(days=2), "open": 20.0, "high": 20.0, "low": 20.0, "close": 20.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
]).sort(["symbol", "date"])
engine = BacktestEngine(repo=None) # type: ignore[arg-type]
engine.load_panel = lambda symbols, s, e: panel # type: ignore[method-assign]
strategy = _strategy(
filter_fn=lambda df, params: pl.col("date") == start,
max_hold_days=1,
)
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(strategy))
result = service.run(StrategyBacktestConfig(
strategy_id="test",
symbols=None,
start=start,
end=start,
mode="full",
matching="open_t+1",
fees_pct=0,
slippage_bps=0,
holding_days=1,
))
assert result.error is None
assert result.stats["full_kind"] == "candidate_execution"
assert result.stats["n_candidates"] == 1
assert result.stats["n_trades"] == 1
assert result.trades[0]["entry_date"] == str(start + timedelta(days=1))
assert result.trades[0]["exit_reason"] == "max_hold"
assert result.stats["avg_return"] == round(20 / 11 - 1, 4)
+1 -1
View File
@@ -2491,7 +2491,7 @@ all = [
[[package]]
name = "tickflow-stock-panel-backend"
version = "0.1.66"
version = "0.1.70"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
+1 -1
View File
@@ -7,7 +7,7 @@ services:
dockerfile: Dockerfile
args:
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
container_name: TickFlow_Stock_Panel
container_name: stock_panel
ports:
- "${PORT:-3018}:3018"
env_file:
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#8B5CF6" />
<title>TickFlow Stock Panel · Quant Terminal</title>
<title>A股工作台</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -1,5 +1,5 @@
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns'
import type { ColumnConfig } from '@/lib/list-columns'
interface ColumnCustomizerProps {
columns: ColumnConfig[]
@@ -12,7 +12,7 @@ export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCus
return (
<ListColumnCustomizer
columns={columns}
groups={COLUMN_GROUPS}
groups={[]}
onChange={onChange}
open={open}
onClose={onClose}
+72 -400
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useQuoteStream } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
@@ -10,26 +9,13 @@ import { AiReportBubble } from '@/components/financials/AiReportBubble'
import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost'
import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble'
import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useVersion,
} from '@/lib/useSharedQueries'
import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import {
Star,
ScanSearch,
History,
FileText,
Settings,
Key,
Database,
Loader2,
LayoutDashboard,
Tags,
TrendingUp,
@@ -38,199 +24,36 @@ import {
Sparkles,
Layers3,
Landmark,
Cable,
RadioTower,
CheckCircle2,
BookOpenCheck,
ExternalLink,
X,
Moon,
Sun,
User,
Users,
Shield,
LogOut,
} from 'lucide-react'
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
const BRAND = '#8B5CF6'
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
const CORE_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
] as const
type CoreIndex = (typeof CORE_INDEXES)[number]
const nav = [
{ to: '/', label: '看板', icon: LayoutDashboard },
{ to: '/watchlist', label: '自选', icon: Star },
{ to: '/screener', label: '策略', icon: ScanSearch },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
{ to: '/financials', label: '财务分析', icon: FileText },
{ to: '/monitor', label: '监控中心', icon: RadioTower },
{ to: '/review', label: '复盘', icon: BookOpenCheck },
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/trading', label: '交易', icon: Cable },
{ to: '/data', label: '数据', icon: Database },
] as const
function fmtIndexValue(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(2)
}
function fmtIndexPct(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`
}
function indexPctClass(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return 'text-muted'
const n = Number(v)
if (n === 0) return 'text-foreground'
return n > 0 ? 'text-bull' : 'text-bear'
}
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
function MonitorBadge({ active }: { active: boolean }) {
const unread = useUnreadAlerts()
// 尊重用户设置: 可在菜单设置里关闭数字提示
const badgeEnabled = (() => {
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
})()
if (active || unread <= 0 || !badgeEnabled) return null
return (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white animate-pulse">
{unread > 99 ? '99+' : unread}
</span>
)
}
function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) {
if (items.length === 0) return null
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
return (
<div className="mt-2 grid grid-cols-2 gap-1.5">
{items.map(item => {
const q = quoteBySymbol.get(item.symbol)
const value = q?.last_price ?? q?.close
const pct = q?.change_pct
return (
<NavLink
key={item.symbol}
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
className="block rounded bg-elevated/60 px-2 py-1.5 transition-colors hover:bg-elevated"
title={`${item.name} ${item.symbol}`}
>
<div className="flex items-center justify-between gap-1">
<span className="text-[10px] text-secondary">{item.name}</span>
<span className={`text-[10px] font-mono ${indexPctClass(pct)}`}>{fmtIndexPct(pct)}</span>
</div>
<div className="mt-0.5 truncate font-mono text-[10px] text-foreground/80">
{fmtIndexValue(value)}
</div>
</NavLink>
)
})}
</div>
)
}
// ===== 档位卡片 =====
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase()
const isNone = base === 'none'
const tierConfig: Record<string, {
desc: string
tagBg: React.CSSProperties
dotStyle: React.CSSProperties
labelTextStyle: React.CSSProperties
}> = {
none: {
desc: '未配置 Key · 仅历史日K',
tagBg: { background: 'rgba(113,113,122,0.15)' },
dotStyle: { background: '#52525b' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 自选实时',
tagBg: { background: 'rgba(113,113,122,0.3)' },
dotStyle: { background: '#71717a' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '批量同步 · 行情池',
tagBg: { background: 'rgba(59,130,246,0.2)' },
dotStyle: { background: '#3b82f6' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
desc: '分钟K · 实时行情 · 盘口',
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
expert: {
desc: 'WebSocket · 财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
}
const t = tierConfig[base] || tierConfig.none
// none 档显示英文「None」,无 label 时也显示「None」
const displayLabel = isNone ? 'None' : (label || 'None')
return (
<NavLink
to="/settings?tab=account"
className="mt-2.5 group block -mx-2.5"
title="API 设置"
>
<div className="relative overflow-hidden rounded-lg border border-blue-400/20 bg-gradient-to-br from-blue-500/[0.12] via-surface to-surface px-3 py-2 transition-all hover:border-blue-400/35 hover:from-blue-500/[0.16]">
<div className="absolute -right-5 -top-6 h-14 w-14 rounded-full bg-blue-500/10 blur-2xl" />
<div className="relative flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-blue-400/10 text-blue-300 ring-1 ring-blue-400/20">
<Key className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground">TickFlow</span>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ ...t.dotStyle, ...(base === 'expert' ? { animation: 'pulse 2s infinite' } : {}) }}
/>
</div>
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
{isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
</div>
</div>
<span
className="inline-flex h-[18px] max-w-[68px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none"
style={t.tagBg}
>
<span className="truncate" style={t.labelTextStyle}>{displayLabel}</span>
</span>
<Settings className="h-3 w-3 shrink-0 text-muted group-hover:text-blue-300 transition-colors" />
</div>
</div>
</NavLink>
)
}
function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) {
return (
<NavLink
to="/settings?tab=ai"
to="/settings/ai"
className="mt-2 group block -mx-2.5"
title="AI 配置"
>
@@ -258,83 +81,44 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
export function Layout() {
// ===== 共享 hooks (替代内联 useQuery) =====
const { data: caps } = useCapabilities()
const { data: settingsState } = useSettings()
const { data: versionData } = useVersion()
const { data: prefs } = usePreferences()
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
const { data: quoteStatus } = useQuoteStatus({ poll: true })
const { data: analysisMenus } = useQuery({
queryKey: QK.analysisMenus,
queryFn: api.analysisMenus,
})
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
const { data: pipelineJobs } = useQuery({
queryKey: QK.pipelineJobs,
queryFn: () => api.pipelineJobs(1),
refetchInterval: (query) => (query.state.data?.active_id ? 2000 : 15000),
refetchIntervalInBackground: true,
const { data: authData } = useQuery({
queryKey: ['auth-status'],
queryFn: api.authStatus,
refetchInterval: 60_000,
})
const isDataSyncing = !!pipelineJobs?.active_id
// 数据同步完成的"瞬时反馈": isDataSyncing 从 true→false 时显示绿色对勾,
// 闪烁约 3 秒后自动消失。
const [dataSyncJustDone, setDataSyncJustDone] = useState(false)
const prevSyncingRef = useRef(false)
useEffect(() => {
// 仅在"刚结束"(true→false)且非首次挂载时触发
if (prevSyncingRef.current && !isDataSyncing) {
setDataSyncJustDone(true)
const t = setTimeout(() => setDataSyncJustDone(false), 3000)
prevSyncingRef.current = isDataSyncing
return () => clearTimeout(t)
}
prevSyncingRef.current = isDataSyncing
}, [isDataSyncing])
const qc = useQueryClient()
const username = authData?.username
const role = authData?.role
const navigate = useNavigate()
const version = versionData?.version
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
const [dismissFreeHint, setDismissFreeHint] = useState(false)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
// 卡片数据:固定显示时也拉取(即使实时行情关闭)
const showSidebarQuotes = indicesPinned || realtimeEnabled
const { data: sidebarIndexQuotes } = useQuery({
queryKey: [...QK.indexQuotes, 'sidebar', sidebarIndexSymbols.join(',')] as const,
queryFn: () => api.indexQuotes(sidebarIndexes.map(p => p.symbol)),
enabled: showSidebarQuotes && sidebarIndexes.length > 0,
placeholderData: (prev) => prev,
const qc = useQueryClient()
const [darkMode, setDarkMode] = useState(() => {
if (typeof document === 'undefined') return true
try {
const saved = localStorage.getItem('theme')
if (saved === 'light' || saved === 'dark') return saved === 'dark'
} catch {}
return document.documentElement.classList.contains('dark')
})
// SSE: 行情更新时自动刷新相关 queries + 告警通知
useQuoteStream(realtimeEnabled, prefs?.sse_refresh_pages)
// 切换暗夜模式
const toggleDark = () => {
const next = !darkMode
setDarkMode(next)
document.documentElement.classList.toggle('dark', next)
try { localStorage.setItem('theme', next ? 'dark' : 'light') } catch {}
}
const toggleQuote = useToggleRealtimeQuotes()
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isWatchlistMode = tier === 0
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({
queryKey: ['alerts-total'],
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
refetchInterval: 15000,
refetchIntervalInBackground: true,
select: (data) => data.total,
})
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
const alertsTotal = alertsTotalQuery.data
useEffect(() => {
if (alertsTotal != null) setAlertTotal(alertsTotal)
}, [alertsTotal])
const handleLogout = async () => {
try { await api.authLogout() } catch {}
qc.invalidateQueries({ queryKey: ['auth-status'] })
navigate('/login', { replace: true })
}
// 合并内置页面 + 可见的扩展分析菜单
const analysisNav = (analysisMenus?.items ?? [])
@@ -358,27 +142,6 @@ export function Layout() {
const hiddenIds = new Set(prefs?.nav_hidden ?? [])
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
const handleToggle = async (enabled: boolean) => {
// 开启时重新校验档位
if (enabled) {
const fresh = await qc.fetchQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
const freshTier = tierRank(fresh.label ?? '')
if (freshTier < 0) return
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
navigate('/watchlist')
return
}
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
if (enabled && isTrading) {
api.intradayRefresh().catch(() => {})
}
}
return (
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
@@ -391,27 +154,18 @@ export function Layout() {
style={{ color: BRAND }}
/>
<div
className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight"
className="text-sm font-bold tracking-tight text-foreground leading-tight"
style={{ textShadow: `0 0 10px ${BRAND}44` }}
>
<div>TickFlow</div>
<div>Stock Panel</div>
A股工作台
</div>
</div>
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
Quant · Terminal
</div>
<div
className="mt-3 h-px"
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
/>
<TierBadge
label={caps?.label ?? ''}
hasKey={settingsState?.mode !== 'none'}
/>
<AIConfigBadge
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
model={settingsState?.ai_model}
@@ -432,140 +186,58 @@ export function Layout() {
)
}
>
{({ isActive }) => (
{() => (
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
{/* 个股分析 Beta 标识 */}
{(to === '/stock-analysis' || to === '/review') && (
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
Beta
</span>
)}
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
{to === '/data' && isDataSyncing && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
)}
{to === '/data' && !isDataSyncing && dataSyncJustDone && (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-bull animate-pulse" />
)}
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
{to === '/monitor' && <MonitorBadge active={isActive} />}
</>
)}
</NavLink>
))}
</nav>
{/* 全局行情开关 */}
<div className="border-t border-border px-3 py-2.5 shrink-0">
{isNoneTier ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
Free+
</span>
</div>
<div className="mt-1.5 text-[10px] leading-snug text-muted">
<a
href={TICKFLOW_REGISTER_URL}
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
>
TickFlow
<ExternalLink className="h-2.5 w-2.5 self-center" />
</a>
</div>
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block h-1.5 w-1.5 rounded-full shrink-0 ${
realtimeEnabled && isRunning && isTrading
? 'bg-accent animate-pulse'
: realtimeEnabled
? 'bg-warning/60'
: 'bg-muted'
}`} />
<span className="text-xs text-secondary truncate">
· {realtimeModeLabel}
</span>
<button
onClick={() => navigate('/settings?tab=monitoring')}
className="text-secondary hover:text-foreground transition-colors shrink-0"
title="实时监控设置"
>
<Settings className="h-3 w-3" />
</button>
</div>
<div className="border-t border-border px-2 py-2.5 space-y-1 shrink-0">
{/* 登录用户 */}
{username && (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-foreground/70">
<User className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate flex-1">{username}</span>
<button
onClick={() => handleToggle(!realtimeEnabled)}
disabled={toggleQuote.isPending}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
realtimeEnabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggleQuote.isPending ? 'opacity-50' : 'cursor-pointer'}`}
onClick={handleLogout}
className="rounded p-1 text-muted hover:text-danger hover:bg-danger/10 transition-colors"
title="退出登录"
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
realtimeEnabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
<LogOut className="h-3 w-3" />
</button>
</div>
)}
{/* 状态提示 */}
{realtimeEnabled && !isNoneTier && (
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
{isWatchlistMode && !dismissFreeHint && (
<div className="flex items-start gap-1 text-amber-400/80">
<span className="flex-1"> 5 Starter+</span>
<button
onClick={() => setDismissFreeHint(true)}
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
title="关闭提示"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
)}
{isRunning && isTrading ? (
<div className="text-accent"></div>
) : realtimeEnabled && !isTrading ? (
<div className="text-warning/70"></div>
) : null}
</div>
{/* 用户管理 (admin only) */}
{role === 'admin' && (
<NavLink
to="/settings/users"
className={({ isActive }) =>
'flex items-center gap-2 px-3 py-1.5 text-xs text-foreground/70 hover:bg-elevated hover:text-foreground transition-colors' +
(isActive ? ' bg-accent/10 text-accent' : '')
}
>
<Users className="h-3.5 w-3.5" />
<Shield className="h-3 w-3 text-amber-400" />
<span className="flex-1"></span>
</NavLink>
)}
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
</div>
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
<NavLink
to="/settings"
className={({ isActive }) =>
cn(
'flex items-center justify-between gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
)
}
>
<span className="flex items-center gap-3">
<Settings className="h-4 w-4 shrink-0" />
<span></span>
</span>
<span className="font-mono text-[10px] text-muted/50 select-none">
{version ?? ''}
</span>
</NavLink>
{/* 暗夜模式 */}
<div className="px-1">
<button
onClick={toggleDark}
className="flex items-center gap-2 w-full rounded-btn px-2 py-1.5 text-xs text-foreground/70 hover:bg-elevated hover:text-foreground transition-colors"
title={darkMode ? '切换为日间模式' : '切换为暗夜模式'}
>
{darkMode ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
<span>{darkMode ? '暗夜' : '日间'}</span>
</button>
</div>
</div>
</aside>
+1 -1
View File
@@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) {
className={className}
style={style}
role="img"
aria-label="TickFlow Stock Panel"
aria-label="A股工作台"
>
{/* 左方括号 */}
<path
@@ -1,9 +1,7 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { X, RefreshCw, Clock } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cnSignal } from '@/lib/signals'
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
import { DatePicker } from '@/components/DatePicker'
@@ -44,21 +42,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
const qc = useQueryClient()
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
enabled: !!symbol,
})
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
const toggleWatchlist = useMutation({
mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
},
})
// ESC 关闭
useEffect(() => {
if (!symbol) return
@@ -239,8 +222,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
dateRange={dateRange}
onMonitor={() => setShowMonitorEditor(true)}
inWatchlist={inWatchlist}
onToggleWatchlist={() => toggleWatchlist.mutate()}
/>
</div>
@@ -1,143 +0,0 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences, useCapabilities } from '@/lib/useSharedQueries'
import { isExpertOrAbove } from '@/lib/capability-labels'
/**
* sealed() (, , Card )
*
* - 轮询间隔: Pro 10~120s / Expert 3~300s
* - 盘后定版时间: 15:01~18:00, 15:02
* - disabled ()
*/
// 注: 文件名保留 DepthConfigCard.tsx, 导出 DepthConfigContent(纯内容无外框)
export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
const qc = useQueryClient()
const prefs = usePreferences()
const caps = useCapabilities()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const tierLabel = caps.data?.label ?? ''
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 }
const interval = prefs.data?.depth_polling_interval ?? 20
const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 }
const [intervalInput, setIntervalInput] = useState(String(Math.round(interval)))
const [finalizeHour, setFinalizeHour] = useState(String(finalizeTime.hour))
const [finalizeMinute, setFinalizeMinute] = useState(String(finalizeTime.minute))
useEffect(() => { setIntervalInput(String(Math.round(interval))) }, [interval])
useEffect(() => {
setFinalizeHour(String(finalizeTime.hour))
setFinalizeMinute(String(finalizeTime.minute))
}, [finalizeTime.hour, finalizeTime.minute])
const saveInterval = useMutation({
mutationFn: (v: number) => api.updateDepthPollingInterval(v),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const saveFinalize = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updateDepthFinalizeTime(hour, minute),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
// 无能力: 显示升级提示
if (!hasDepth) {
return (
<p className="text-xs text-muted leading-relaxed">
, <span className="text-accent">Pro </span>
()()
</p>
)
}
const inputCls = `w-16 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`
return (
<div className="space-y-3">
{/* 盘中轮询间隔 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> {range.lo}~{range.hi} · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={range.lo}
max={range.hi}
value={intervalInput}
disabled={disabled}
onChange={e => setIntervalInput(e.target.value)}
onBlur={() => {
if (disabled) return
let v = Number(intervalInput)
if (!Number.isFinite(v)) v = range.lo
v = Math.max(range.lo, Math.min(range.hi, v))
saveInterval.mutate(v)
}}
className={inputCls}
/>
<span className="text-xs text-muted"></span>
</div>
</div>
{/* 盘后定版时间 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> 15:01~18:00 · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={15}
max={18}
value={finalizeHour}
disabled={disabled}
onChange={e => setFinalizeHour(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
<span className="text-xs text-muted">:</span>
<input
type="number"
min={0}
max={59}
value={finalizeMinute}
disabled={disabled}
onChange={e => setFinalizeMinute(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
)
}
@@ -1,165 +0,0 @@
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Activity, Settings } from 'lucide-react'
import { Skeleton } from './Skeleton'
export function QuoteConfigCard({ enabled, running, isTrading, lastFetchMs, intervalS, intervalMin, intervalMax, loading, onToggle, toggling, showIntervalEdit, onShowIntervalEdit, onIntervalChange }: {
enabled: boolean
running: boolean
isTrading: boolean
lastFetchMs: number | null
intervalS: number
intervalMin: number
intervalMax: number
loading: boolean
onToggle: (enabled: boolean) => void
toggling: boolean
showIntervalEdit: boolean
onShowIntervalEdit: () => void
onIntervalChange: (v: number) => void
}) {
const statusColor = running && isTrading
? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.5)]'
: enabled && running
? 'bg-warning/60'
: 'bg-muted'
const statusText = !enabled
? '已关闭'
: !isTrading
? '非交易时段'
: running
? '行情运行中'
: '已停止'
const lastFetchTime = lastFetchMs
? new Date(lastFetchMs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
: null
return (
<div className="rounded-card border border-border bg-surface p-4 relative">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<button
onClick={() => onToggle(!enabled)}
disabled={toggling}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
enabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggling ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
enabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
{loading ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><Skeleton w="w-8" /><Skeleton w="w-16" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-20" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-10" /><Skeleton w="w-14" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-12" /></div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<div className="flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${statusColor} ${running && isTrading ? 'animate-pulse' : ''}`} />
<span className="font-mono text-secondary">{statusText}</span>
</div>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className={`font-mono ${isTrading ? 'text-accent' : 'text-muted'}`}>{isTrading ? '交易中' : '休市'}</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"></span>
<button
onClick={() => onShowIntervalEdit()}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showIntervalEdit ? 'text-accent' : 'text-secondary'}`}
title="设置轮询间隔"
>
<Settings className="h-3 w-3" />
</button>
</div>
<span className="font-mono text-secondary">{intervalS}s</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">{lastFetchTime ?? '—'}</span>
</div>
</div>
)}
<AnimatePresence>
{showIntervalEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<IntervalEditor
min={intervalMin}
max={intervalMax}
value={intervalS}
onChange={onIntervalChange}
/>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function IntervalEditor({ min, max, value, onChange }: {
min: number; max: number; value: number; onChange: (v: number) => void
}) {
const [draft, setDraft] = useState(value)
const clamped = Math.max(min, Math.min(max, draft))
const step = min < 1 ? 0.1 : min < 3 ? 0.5 : 1
const presets = min <= 3 ? [3, 5, 10, 30, 60] : [5, 10, 15, 30, 60]
return (
<div className="mt-2 pt-2 border-t border-border/50">
<div className="text-[10px] text-muted mb-1.5">
<span className="text-muted/60">({min}s ~ {max}s)</span>
</div>
<div className="flex flex-wrap gap-1 mb-2">
{presets.map(p => (
<button
key={p}
onClick={() => { setDraft(p); onChange(p) }}
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors ${
Math.abs(clamped - p) < 0.01
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-elevated text-secondary hover:text-foreground border border-transparent'
}`}
>
{p}s
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={min} max={max} step={step}
value={clamped}
onChange={e => { const v = parseFloat(e.target.value); setDraft(v); onChange(v) }}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] font-mono text-foreground w-8 text-right">
{clamped < 1 ? clamped.toFixed(1) : clamped.toFixed(0)}s
</span>
</div>
</div>
)
}
@@ -177,7 +177,7 @@ export function AiAnalysisDialog({ task, mode, minimized }: Props) {
<div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && (
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
<button onClick={() => { window.location.href = '/settings/ai' }}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" /> AI
</button>
@@ -45,14 +45,14 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
const { data: prefs } = usePreferences()
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const feishuConfigured = !!((prefs as any)?.feishu_webhook_url)
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>(
rule
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) },
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!((prefs as any)?.webhook_enabled_default) },
)
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
@@ -6,7 +6,7 @@
* scoresignalscandleext //
*/
import { useState, type CSSProperties, type ReactNode } from 'react'
import { Check, Plus, Eye, EyeOff } from 'lucide-react'
import { Eye, EyeOff } from 'lucide-react'
import type { KlineRow } from '@/lib/api'
import { fmtPrice } from '@/lib/format'
import type { ColumnConfig } from '@/lib/screener-columns'
@@ -22,10 +22,7 @@ interface ScreenerTableProps {
strategyIdToName: Record<string, string>
symbolStrategyMap: Map<string, string[]>
activeStrategy: string | null
watchlistSet: Set<string>
onPreview: (symbol: string, name: string) => void
onToggleWatchlist: (symbol: string, inList: boolean) => void
watchlistPending: boolean
/** symbol → 日k 数据,仅当启用日k列时传入 */
klineData?: Record<string, KlineRow[]>
/** 日k蜡烛图是否显示(表头眼睛开关) */
@@ -114,7 +111,7 @@ function renderExtValue(
export function ScreenerTable({
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
onPreview, klineData = {},
dailyKChartVisible = true, onToggleDailyKChart,
sort, onSortToggle,
}: ScreenerTableProps) {
@@ -164,7 +161,6 @@ export function ScreenerTable({
switch (key) {
case 'symbol': {
const board = boardTag(r.symbol)
const inWatchlist = watchlistSet.has(r.symbol)
return (
<td key={col.id} className="px-4 py-2">
<div className="flex items-center gap-2">
@@ -189,25 +185,10 @@ export function ScreenerTable({
</span>
)}
</button>
{isExpired ? (
{isExpired && (
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
</span>
) : (
<button
type="button"
onClick={() => onToggleWatchlist(r.symbol, inWatchlist)}
disabled={watchlistPending}
className={`shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full border transition-colors cursor-pointer
disabled:opacity-50
${inWatchlist
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border text-muted hover:border-accent/40 hover:text-accent'
}`}
title={inWatchlist ? '移出自选' : '加入自选'}
>
{inWatchlist ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</button>
)}
</div>
</td>
@@ -310,7 +310,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
<div className="rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 flex items-center gap-3">
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
<div className="flex-1 text-xs text-amber-400/80">AI API Key </div>
<button onClick={() => { persist(); window.location.href = '/settings?tab=ai' }}
<button onClick={() => { persist(); window.location.href = '/settings/ai' }}
className="h-7 px-3 rounded-lg bg-amber-400/15 border border-amber-400/30 text-amber-400 text-xs font-medium flex items-center gap-1.5 hover:bg-amber-400/20 shrink-0">
<Settings2 className="h-3 w-3" />
</button>
@@ -2,25 +2,36 @@
import { motion, AnimatePresence } from 'framer-motion'
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { color } from '@/lib/colors'
import { SignalPicker } from './SignalPicker'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
// 内置列名 → 中文标签
const FIELD_LABEL: Record<string, string> = {}
for (const c of BUILTIN_COLUMNS) {
if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label
}
// enriched 列名别名
Object.assign(FIELD_LABEL, {
// 字段名 → 中文标签
const FIELD_LABEL: Record<string, string> = {
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
momentum_5d: '5D动量', momentum_10d: '10D动量',
momentum_20d: '20D动量', momentum_30d: '30D动量',
momentum_60d: '60D动量', turnover_rate: '换手率',
change_amount: '涨跌额', amplitude: '振幅',
volume: '成交量', amount: '成交额',
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
vol_ratio: '量比',
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
boll_upper: '布林上轨', boll_lower: '布林下轨',
})
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
atr14: 'ATR14', annual_vol: '年化波动',
high_60d: '60日高', low_60d: '60日低',
eps: 'EPS', bps: 'BPS', roe: 'ROE', pe_ttm: 'PE(TTM)', pb: 'PB',
gross_margin: '毛利率', net_margin: '净利率',
revenue_yoy: '营收增速', net_income_yoy: '净利增速',
debt_ratio: '负债率',
score: '评分',
limit_ups: '连板', limit_downs: '连跌',
float_val: '流通值', price: '现价', pct: '涨跌幅', turnover: '换手率',
}
interface Props {
strategyId: string | null
@@ -170,7 +170,7 @@ export function StockAnalysisDialog({ task, mode, minimized }: Props) {
<div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && (
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
<button onClick={() => { window.location.href = '/settings/ai' }}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" /> AI
</button>
+22 -298
View File
@@ -192,23 +192,6 @@ export interface KlineRow {
[key: string]: any
}
// ===== Watchlist =====
export interface WatchlistEntry {
symbol: string
added_at: string
note?: string
name?: string | null
}
export interface Quote {
symbol: string
price?: number
pct?: number
close?: number
change_pct?: number
[key: string]: any
}
export interface IndexInstrument {
symbol: string
name?: string | null
@@ -505,111 +488,6 @@ export interface LimitLadderResult {
sealed_counts_down?: { real: number; fake: number; pending: number }
}
// ===== Backtest =====
export interface BacktestResult {
run_id: string
config: any
stats: Record<string, any>
equity_curve: { date: string; value: number }[]
trades: any[]
per_symbol_stats: { symbol: string; total_return: number }[]
}
// ===== Factor Backtest =====
export interface FactorColumn {
id: string
label: string
group: string
desc: string
}
export interface GroupStat {
group: number
label: string
total_return: number
annual_return: number
max_drawdown: number
sharpe: number
win_rate: number
}
export interface FactorBacktestResult {
run_id: string
config: Record<string, any>
ic_mean: number | null
ic_std: number | null
ir: number | null
ic_win_rate: number | null
ic_series: { date: string; ic: number }[]
group_stats: GroupStat[]
group_nav: Record<string, any>[]
long_short_stats: Record<string, any>
long_short_nav: { date: string; value: number }[]
elapsed_ms: number
n_symbols: number
n_dates: number
error: string | null
}
// ===== Strategy Backtest =====
export interface StrategyBacktestTrade {
symbol: string
name?: string
entry_date: string
exit_date: string
entry_price: number
exit_price: number
pnl_pct: number
duration: number
exit_reason: string
shares?: number
lots?: number
position_pct?: number
entry_value?: number
exit_value?: number
pnl_amount?: number
entry_score?: number | null
entry_signal_date?: string | null
exit_signal_date?: string | null
blocked_exit_days?: number
}
export interface StrategyBacktestResult {
run_id: string
config: Record<string, any>
stats: Record<string, any>
equity_curve: { date: string; value: number; cash?: number; positions?: number; exposure?: number }[]
drawdown_curve: { date: string; value: number }[]
benchmark_curve?: { date: string; value: number; close?: number; name?: string; symbol?: string }[]
trades: StrategyBacktestTrade[]
per_symbol_stats: {
symbol: string
n_trades: number
total_return: number
win_rate: number
best: number
worst: number
}[]
strategy_info: {
id: string
name: string
description: string
entry_signals: string[]
exit_signals: string[]
stop_loss: number | null
take_profit: number | null
trailing_stop: number | null
trailing_take_profit_activate: number | null
trailing_take_profit_drawdown: number | null
score_min: number | null
score_max: number | null
max_hold_days: number | null
source: string
}
elapsed_ms: number
error: string | null
}
// ===== Settings =====
/** 端点发现清单 —— 对应 tickflow.org/endpoints.json */
@@ -669,20 +547,12 @@ export interface SaveTickflowKeyResult {
}
export interface Preferences {
realtime_quotes_enabled: boolean
indices_nav_pinned: boolean
minute_sync_enabled: boolean
minute_sync_days: number
daily_data_provider?: string
adj_factor_provider?: string
minute_data_provider?: string
realtime_data_provider?: string
realtime_watchlist_symbols?: string[]
realtime_pull_stock?: boolean
realtime_pull_etf?: boolean
realtime_pull_index?: boolean
realtime_index_mode?: 'core' | 'all'
realtime_index_symbols?: string[]
pipeline_pull_a_share: boolean
pipeline_pull_etf: boolean
pipeline_pull_index: boolean
@@ -691,18 +561,9 @@ export interface Preferences {
instruments_schedule: { hour: number; minute: number }
enriched_batch_size: number
index_daily_batch_size: number
limit_ladder_monitor_enabled: boolean
depth_polling_interval: number
depth_finalize_time: { hour: number; minute: number }
review_schedule: { enabled: boolean; hour: number; minute: number }
review_push_channels: string[]
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
system_notify_enabled: boolean
feishu_webhook_url?: string
feishu_webhook_secret?: string
webhook_enabled_default?: boolean
sidebar_index_symbols: string[]
nav_order: string[]
nav_hidden: string[]
@@ -728,16 +589,16 @@ export const api = {
// ===== Auth (访问认证) =====
authStatus: () =>
request<{ configured: boolean; authenticated: boolean }>('/api/auth/status'),
request<{ configured: boolean; authenticated: boolean; username?: string | null; role?: string | null }>('/api/auth/status'),
authSetup: (password: string) =>
request<{ ok: boolean }>('/api/auth/setup', {
method: 'POST',
body: JSON.stringify({ password }),
}),
authLogin: (password: string) =>
request<{ ok: boolean }>('/api/auth/login', {
authLogin: (username: string, password: string) =>
request<{ ok: boolean; username?: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
body: JSON.stringify({ username, password }),
}),
authLogout: () =>
request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
@@ -746,6 +607,22 @@ export const api = {
method: 'POST',
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
}),
authUsers: () =>
request<{ users: { username: string; role: string; created_at?: number }[] }>('/api/auth/users'),
authCreateUser: (username: string, password: string, role: string) =>
request<{ ok: boolean }>('/api/auth/users', {
method: 'POST',
body: JSON.stringify({ username, password, role }),
}),
authDeleteUser: (username: string) =>
request<{ ok: boolean }>(`/api/auth/users/${encodeURIComponent(username)}`, {
method: 'DELETE',
}),
authResetPassword: (username: string, newPassword: string) =>
request<{ ok: boolean }>(`/api/auth/users/${encodeURIComponent(username)}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword }),
}),
settings: () => request<SettingsState>('/api/settings'),
saveTickflowKey: (api_key: string) =>
@@ -793,67 +670,11 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ symbols }),
}),
updateRealtimeQuotes: (enabled: boolean) =>
request<{ realtime_quotes_enabled: boolean; realtime_allowed?: boolean; mode?: string; error?: string }>('/api/settings/preferences/realtime-quotes', {
method: 'PUT',
body: JSON.stringify({ realtime_quotes_enabled: enabled }),
}),
updateRealtimeQuoteScope: (cfg: Partial<Pick<Preferences, 'realtime_pull_stock' | 'realtime_pull_etf' | 'realtime_pull_index' | 'realtime_index_mode' | 'realtime_index_symbols'>>) =>
request<Partial<Preferences>>('/api/settings/preferences/realtime-quote-scope', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updateIndicesNavPinned: (pinned: boolean) =>
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
method: 'PUT',
body: JSON.stringify({ indices_nav_pinned: pinned }),
}),
quoteStatus: () =>
request<{
enabled: boolean
running: boolean
mode?: 'none' | 'watchlist' | 'full_market'
realtime_allowed?: boolean
interval_s: number
symbol_count: number
watchlist_symbol_count?: number
index_symbol_count?: number
etf_symbol_count?: number
quote_age_ms: number | null
is_trading_hours: boolean
last_fetch_ms: number | null
}>('/api/intraday/status'),
quoteInterval: () =>
request<{ interval: number; min_interval: number; max_interval: number }>(
'/api/settings/preferences/quote-interval',
),
updateQuoteInterval: (interval: number) =>
request<{ interval: number; min_interval: number; max_interval: number }>(
'/api/settings/preferences/quote-interval',
{ method: 'PUT', body: JSON.stringify({ interval }) },
),
intradayRefresh: () => request<{ status: string }>('/api/intraday/refresh', { method: 'POST' }),
indexQuotes: (symbols?: string[]) =>
request<{ rows: IndexQuote[]; count: number }>(
`/api/intraday/indices${symbols?.length ? `?symbols=${encodeURIComponent(symbols.join(','))}` : ''}`,
),
updateRealtimeMonitorConfig: (cfg: {
sse_refresh_pages?: Record<string, boolean>
strategy_monitor_enabled?: boolean
strategy_monitor_ids?: string[]
sidebar_index_symbols?: string[]
screener_auto_run?: boolean
}) =>
request<{
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
sidebar_index_symbols: string[]
screener_auto_run: boolean
}>('/api/settings/preferences/realtime-monitor', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updateSystemNotify: (enabled: boolean) =>
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
method: 'PUT',
@@ -929,15 +750,6 @@ export const api = {
body: JSON.stringify({ size }),
}),
// 自选列表列配置
watchlistColumns: () =>
request<{ columns: any[] | null }>('/api/settings/preferences/watchlist-columns'),
updateWatchlistColumns: (columns: any[]) =>
request<{ columns: any[] }>('/api/settings/preferences/watchlist-columns', {
method: 'PUT',
body: JSON.stringify({ columns }),
}),
// 策略结果列表列配置
screenerResultColumns: () =>
request<{ columns: any[] | null }>('/api/settings/preferences/screener-result-columns'),
@@ -1049,37 +861,6 @@ export const api = {
method: 'POST',
}),
watchlistList: () => request<{ symbols: WatchlistEntry[] }>('/api/watchlist'),
watchlistAdd: (symbol: string, note = '') =>
request<{ symbols: WatchlistEntry[] }>('/api/watchlist', {
method: 'POST',
body: JSON.stringify({ symbol, note }),
}),
watchlistBatchAdd: (symbols: string[], note = '') =>
request<{ symbols: WatchlistEntry[]; added: number }>('/api/watchlist/batch', {
method: 'POST',
body: JSON.stringify({ symbols, note }),
}),
watchlistRemove: (symbol: string) =>
request<{ symbols: WatchlistEntry[] }>(
`/api/watchlist/${encodeURIComponent(symbol)}`,
{ method: 'DELETE' },
),
watchlistMoveToTop: (symbol: string) =>
request<{ symbols: WatchlistEntry[] }>(
`/api/watchlist/${encodeURIComponent(symbol)}/top`,
{ method: 'POST' },
),
watchlistClear: () =>
request<{ removed: number }>('/api/watchlist', { method: 'DELETE' }),
watchlistQuotes: () => request<{ quotes: Quote[] }>('/api/watchlist/quotes'),
watchlistEnriched: (extColumns?: string) =>
request<{ rows: any[]; as_of: string | null; elapsed_ms: number }>(
extColumns
? `/api/watchlist/enriched?ext_columns=${encodeURIComponent(extColumns)}`
: '/api/watchlist/enriched',
),
screenerStrategies: () => request<{ presets: ScreenerStrategy[] }>('/api/screener/strategies'),
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) =>
request<ScreenerResult>('/api/screener/run_preset', {
@@ -1101,8 +882,8 @@ export const api = {
? `/api/screener/cached?ext_columns=${encodeURIComponent(extColumns)}`
: '/api/screener/cached',
),
marketSnapshot: () =>
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>('/api/screener/market-snapshot'),
marketSnapshot: (asOf?: string) =>
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>(`/api/screener/market-snapshot${asOf ? `?as_of=${asOf}` : ''}`),
overviewMarket: (asOf?: string) => request<OverviewMarket>(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`),
// 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序
@@ -1120,63 +901,6 @@ export const api = {
)
},
backtestStatus: () => request<{ available: boolean }>('/api/backtest/status'),
backtestRun: (payload: {
symbols: string[]
entries: string[]
exits: string[]
start?: string
end?: string
stop_loss_pct?: number
max_hold_days?: number
matching?: 'close_t' | 'open_t+1'
}) =>
request<BacktestResult>('/api/backtest/run', {
method: 'POST',
body: JSON.stringify(payload),
}),
factorColumns: () =>
request<{ columns: FactorColumn[] }>('/api/backtest/factor/columns'),
factorRun: (payload: {
factor_name: string
symbols?: string[] | null
start?: string | null
end?: string | null
n_groups?: number
rebalance?: 'daily' | 'weekly' | 'monthly'
weight?: 'equal' | 'factor_weight'
fees_pct?: number
slippage_bps?: number
}) =>
request<FactorBacktestResult>('/api/backtest/factor/run', {
method: 'POST',
body: JSON.stringify(payload),
}),
strategyBacktestRun: (payload: {
strategy_id: string
symbols?: string[] | null
start?: string | null
end?: string | null
params?: Record<string, any> | null
overrides?: Record<string, any> | null
matching?: 'close_t' | 'open_t+1'
entry_fill?: 'close_t' | 'open_t+1' | null
exit_fill?: 'close_t' | 'open_t+1' | null
fees_pct?: number
slippage_bps?: number
max_positions?: number
initial_capital?: number
position_sizing?: 'equal' | 'score_weight'
}) =>
request<StrategyBacktestResult>('/api/backtest/strategy/run', {
method: 'POST',
body: JSON.stringify(payload),
}),
pipelineRun: () => request<{ job_id: string; reused: boolean }>(
'/api/pipeline/run', { method: 'POST' },
),
-227
View File
@@ -1,227 +0,0 @@
import { useSyncExternalStore } from 'react'
import type { StrategyBacktestResult } from './api'
/**
* (SSE + + )
*
* :
* - 实时进度: EventSource SSE, day/total/equity
* - 可取消: POST /strategy/cancel/{job_key}, cancel_event
* - /刷新保持: 后端按参数 hash ,
* - 切页: 模块级 store , EventSource ,
* - 刷新: localStorage job ,
*/
export interface BacktestProgress {
day: number
total: number
date: string
equity: number
}
export interface BacktestTask {
id: number
isPending: boolean
result: StrategyBacktestResult | null
progress: BacktestProgress | null
error: string | null
}
let current: BacktestTask | null = null
const listeners = new Set<() => void>()
let taskSeq = 0
let eventSource: EventSource | null = null
const RECONNECT_KEY = 'backtest_reconnect'
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function getSnapshot() {
return current
}
function getServerSnapshot() {
return null
}
/** 查询字符串构建 */
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') sp.set(k, String(v))
}
return sp.toString()
}
/** 连接 SSE (新建或重连都用这个) */
function connectSSE(url: string): void {
const id = current?.id ?? ++taskSeq
// 关闭旧连接
if (eventSource) {
eventSource.close()
eventSource = null
}
const es = new EventSource(url)
eventSource = es
es.addEventListener('progress', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const prog = JSON.parse(e.data) as BacktestProgress
current = { ...current, progress: prog }
emit()
} catch { /* ignore */ }
})
es.addEventListener('done', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const result = JSON.parse(e.data) as StrategyBacktestResult
current = { ...current, isPending: false, result, error: null }
emit()
} catch {
current = { ...current, isPending: false, error: '结果解析失败' }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
})
es.addEventListener('error', (e: MessageEvent) => {
if (current?.id !== id) return
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
if (e.data) {
try {
const msg = JSON.parse(e.data)?.message ?? '回测出错'
current = { ...current, isPending: false, error: msg }
emit()
} catch {
current = { ...current, isPending: false, error: '回测出错' }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
}
// 无 data: 连接异常断开, EventSource 会自动重连, 不改变状态
})
}
/** 启动一次 SSE 回测任务 */
export function startBacktest(params: {
strategy_id: string
symbols?: string[] | null
start?: string | null
end?: string | null
matching?: string
entry_fill?: string
exit_fill?: string
fees_pct?: number
slippage_bps?: number
max_positions?: number
max_exposure_pct?: number
initial_capital?: number
position_sizing?: string
params?: Record<string, any> | null
overrides?: Record<string, any> | null
mode?: 'position' | 'full'
holding_days?: number
}): void {
// 取消之前的任务状态
if (eventSource) {
eventSource.close()
eventSource = null
}
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
const qs = buildQuery({
strategy_id: params.strategy_id,
symbols: params.symbols?.join(','),
start: params.start ?? undefined,
end: params.end ?? undefined,
matching: params.matching,
entry_fill: params.entry_fill,
exit_fill: params.exit_fill,
fees_pct: params.fees_pct,
slippage_bps: params.slippage_bps,
max_positions: params.max_positions,
max_exposure_pct: params.max_exposure_pct,
initial_capital: params.initial_capital,
position_sizing: params.position_sizing,
params: params.params ? JSON.stringify(params.params) : undefined,
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
mode: params.mode,
holding_days: params.holding_days,
})
// 存 reconnect 信息 (刷新后用)
localStorage.setItem(RECONNECT_KEY, qs)
connectSSE(`/api/backtest/strategy/stream?${qs}`)
}
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
export async function stopBacktest(): Promise<void> {
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
const qs = localStorage.getItem(RECONNECT_KEY)
if (qs) {
// 解析出参数, 用 fetch 调 cancel
try {
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
// 后端会找到同一个 job 并返回它的 job_key? 不行。
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
await fetch('/api/backtest/strategy/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qs }),
}).catch(() => {})
} catch { /* ignore */ }
}
if (eventSource) {
eventSource.close()
eventSource = null
}
if (current?.isPending) {
current = { ...current, isPending: false, error: '已取消' }
emit()
}
localStorage.removeItem(RECONNECT_KEY)
}
/** 清除任务状态 (隐藏提示) */
export function clearBacktest(): void {
current = null
emit()
}
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
export function tryReconnect(): boolean {
const qs = localStorage.getItem(RECONNECT_KEY)
if (!qs) return false
// 有未完成的任务, 重连
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
connectSSE(`/api/backtest/strategy/stream?${qs}`)
return true
}
/** React hook: 读取当前全局回测任务状态 */
export function useBacktestTask(): BacktestTask | null {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
+1 -2
View File
@@ -9,7 +9,6 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
}
@@ -63,7 +62,7 @@ const TIER_STYLE: Record<string, TierStyle> = {
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
expert: {
desc: 'WebSocket · 财务数据',
desc: '财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
+2 -23
View File
@@ -2,7 +2,6 @@
* React Query key
*
* -
* - SSE invalidation SSE_INVALIDATE_PREFIXES key useQuoteStream
*/
// ===== Query Key 工厂 =====
@@ -14,17 +13,10 @@ export const QK = {
endpoints: ['endpoints'] as const,
version: ['version'] as const,
preferences: ['preferences'] as const,
quoteStatus: ['quote-status'] as const,
quoteInterval: ['quote-interval'] as const,
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
indexQuotes: ['index-quotes'] as const,
indexList: ['index-list'] as const,
// Watchlist
watchlist: ['watchlist'] as const,
watchlistQuotes: ['watchlist-quotes'] as const,
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
// Search
instrumentSearch: (q: string) => ['instrument-search', q] as const,
// Screener
@@ -35,9 +27,6 @@ export const QK = {
marketSnapshot: ['market-snapshot'] as const,
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
// Backtest
backtestStatus: ['backtest-status'] as const,
// Data / Pipeline
dataStatus: ['data-status'] as const,
pipelineJobs: ['pipeline-jobs'] as const,
@@ -78,14 +67,4 @@ export const QK = {
rpsRotation: (days: number) => ['rps-rotation', days] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
// 新增需要 SSE 推送的查询,只需在此加一行
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
'quote-status',
'index-quotes',
'overview-market',
'limit-ladder',
'screener',
] as const
// SSE invalidation has been removed along with real-time functionality.
+2 -58
View File
@@ -35,10 +35,6 @@ const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null
let state: ReviewState = { ...INITIAL }
let abortCtrl: AbortController | null = null
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
// 用于区分两条流, 避免互相丢弃事件或重复归档。
let generatingSource: 'manual' | 'sse' | null = null
// ===== 订阅机制 =====
type Listener = () => void
const listeners = new Set<Listener>()
@@ -80,7 +76,6 @@ export async function startReviewGeneration(
// 已在生成中,不重复启动
if (isReviewGenerating()) return
generatingSource = 'manual'
state = { phase: 'loading', content: '', error: '', meta: null, focus }
notify()
@@ -114,7 +109,7 @@ export async function startReviewGeneration(
if (buf && !failed) {
state = { ...state, phase: 'done' }
notify()
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
// 自动归档
if (buf && !failed) {
onDone?.(buf, doneMeta)
}
@@ -126,7 +121,6 @@ export async function startReviewGeneration(
}
} finally {
abortCtrl = null
generatingSource = null
}
}
@@ -167,54 +161,4 @@ export function resetReview(): void {
notify()
}
/**
* SSE ()
*
* 用途: 定时复盘在后端流式生成, /api/intraday/stream review_progress
* meta/delta/done , store
* ,
*
* recap_market_stream :
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
*
* :
* - (isReviewGenerating), SSE (, )
* - done archived=true(): , done
* - retry: 后端 LLM ,
*/
export function feedReviewEvent(evt: any): void {
if (!evt || typeof evt !== 'object') return
const t = evt.type
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
if (generatingSource === 'manual') return
if (t === 'meta') {
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
generatingSource = 'sse'
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
notify()
} else if (t === 'delta' && evt.content) {
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
if (generatingSource !== 'sse') return
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
notify()
} else if (t === 'retry') {
if (generatingSource !== 'sse') return
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
state = { ...state, content: '', phase: 'streaming' }
notify()
} else if (t === 'error') {
if (generatingSource !== 'sse') return
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
notify()
generatingSource = null
} else if (t === 'done') {
if (generatingSource !== 'sse') return
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
state = { ...state, phase: 'done' }
notify()
generatingSource = null
}
}
// feedReviewEvent (SSE-only) removed along with real-time SSE channel.
@@ -1,8 +1,5 @@
/**
*
*
* (watchlist-columns)
* // list-columns
*/
import { storage } from '@/lib/storage'
import {
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* /
*
*
* ID backtest/strategy.py:_build_signal_mask
* ID
* (signal_* , csg_ )
*/
-37
View File
@@ -27,27 +27,15 @@ export const storage = {
/** 策略池 (screener) */
strategyPool: kv<string[]>('strategy-pool'),
/** 自选列表列配置 */
watchlistColumns: kv<unknown[]>('watchlist_columns'),
/** 个股日K信息条指标配置 */
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
/** 自选列表视图模式 table | card */
watchlistView: kv<string>('watchlist_view'),
/** 自选列表日K蜡烛图显示状态 */
watchlistCandle: kv<boolean>('watchlist_showCandle'),
/** 策略结果列表日K蜡烛图显示状态 */
screenerCandle: kv<boolean>('screener_showCandle'),
/** 自选列表板块筛选 */
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
/** Screener 卡片尺寸 */
screenerCardSize: kv<string>('screener-card-size'),
@@ -78,31 +66,6 @@ export const storage = {
/** 已保存策略的原始规则(策略ID → 规则文本) */
strategyRules: kv<Record<string, string>>('strategy-rules'),
/** 策略回测快捷区间按钮配置 */
strategyBacktestQuickRanges: kv<unknown>('strategy-backtest-quick-ranges'),
/** 策略回测最后一次成功结果和参数 */
strategyBacktestLast: kv<{
selectedStrategy: string | null
symbols: string
start: string
end: string
matching: 'close_t' | 'open_t+1'
entryFill: 'close_t' | 'open_t+1'
exitFill: 'close_t' | 'open_t+1'
fees: string
slippage: string
maxPositions: string
maxExposure: string
initialCapital: string
positionSizing: 'equal' | 'score_weight'
mode: 'position' | 'full'
holdingDays: string
params?: Record<string, any>
overrides?: Record<string, any>
result: any
} | null>('strategy-backtest-last'),
/** 概念分析页面字段配置 */
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
+1 -4
View File
@@ -31,10 +31,7 @@ function loadConfig(): QueryConfig {
}
}
/**
*
* useQuoteStream 使
*/
/** 轻量版:只读取当前配置。 */
export function getQueryConfig(): QueryConfig {
return loadConfig()
}
-147
View File
@@ -1,147 +0,0 @@
import { useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToasts } from '@/components/AlertToast'
import { feedReviewEvent } from './reviewStore'
import type { StrategyAlertEvent } from './api'
/**
* SSE hook: 监听后端行情更新推送 +
*
* - (quotes_updated): sseRefreshPages invalidation
* - (strategy_alert): onAlert toast
*
* Layout
*/
export function useQuoteStream(
enabled: boolean,
sseRefreshPages: Record<string, boolean> | undefined,
onAlert?: (alerts: StrategyAlertEvent[]) => void,
) {
const qc = useQueryClient()
const esRef = useRef<EventSource | null>(null)
const retryRef = useRef<ReturnType<typeof setTimeout>>()
const pagesRef = useRef(sseRefreshPages)
pagesRef.current = sseRefreshPages
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
// depth 系统接管通知: 单独处理, 不走 strategy 回调
const depthAlerts = alerts.filter(a => a.source === 'depth')
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
for (const a of depthAlerts.slice(0, 1)) {
toast(a.message, 'success')
}
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
if (strategyAlerts.length > 0) {
// 有 onAlert 回调时走回调, 否则弹 AlertToast
if (onAlert) {
onAlert(strategyAlerts)
}
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
pushAlertToasts(strategyAlerts as any)
}
}, [onAlert])
const enabledRef = useRef(enabled)
enabledRef.current = enabled
useEffect(() => {
// SSE 始终连接 — 监控告警不依赖实时行情开关
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
const connect = () => {
const es = new EventSource('/api/intraday/stream')
esRef.current = es
// sse-starlette ping 心跳走 SSE comment,不会到达这里
es.addEventListener('quotes_updated', () => {
// 实时行情未开启时不处理行情刷新
if (!enabledRef.current) return
// 根据用户配置过滤 invalidation
const pages = pagesRef.current
if (pages) {
// 只 invalidate 开启的页面对应的 prefix
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
// 'quote-status' 始终刷新 (全局状态)
if (p === 'quote-status') return true
return pages[p] !== false
})
qc.invalidateQueries({
predicate: (query) =>
activePrefixes.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
} else {
// 无配置时全部刷新 (向后兼容)
qc.invalidateQueries({
predicate: (query) =>
SSE_INVALIDATE_PREFIXES.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
}
})
es.addEventListener('depth_updated', () => {
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
qc.invalidateQueries({ queryKey: ['overview-market'] })
})
es.addEventListener('strategy_alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
const alerts: StrategyAlertEvent[] = data.alerts || []
if (alerts.length > 0) {
handleAlerts(alerts)
// 实时刷新触发记录列表 + 监控中心徽标
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
}
} catch {
// 忽略解析错误
}
})
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
es.addEventListener('review_progress', (e: MessageEvent) => {
try {
const evt = JSON.parse(e.data)
feedReviewEvent(evt)
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
if (evt.type === 'done') {
qc.invalidateQueries({ queryKey: QK.reviewReports })
}
} catch {
// 忽略解析错误
}
})
es.onerror = () => {
es.close()
esRef.current = null
const delay = getQueryConfig().sse.reconnectDelay
retryRef.current = setTimeout(connect, delay)
}
}
connect()
return () => {
clearTimeout(retryRef.current)
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
}
}, [qc, handleAlerts])
}
@@ -1,42 +1,3 @@
/**
* mutation hooks useMutation
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api'
import { QK } from './queryKeys'
/** 切换实时行情 — Layout / Data 共用 */
export function useToggleRealtimeQuotes() {
const qc = useQueryClient()
return useMutation({
mutationFn: (enabled: boolean) => api.updateRealtimeQuotes(enabled),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 更新行情轮询间隔 — Layout / Data 共用 */
export function useUpdateQuoteInterval() {
const qc = useQueryClient()
return useMutation({
mutationFn: (v: number) => api.updateQuoteInterval(v),
onSuccess: (data) => {
qc.setQueryData(QK.quoteInterval, data)
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 批量添加自选 — Screener / Intraday 共用 */
export function useWatchlistBatchAdd() {
const qc = useQueryClient()
return useMutation({
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
},
})
}
@@ -1,8 +1,5 @@
/**
* query hooks useQuery
*
* SSE invalidation
* 线 SSE refetchInterval
*/
import { useQuery } from '@tanstack/react-query'
import { api } from './api'
@@ -34,33 +31,6 @@ export function usePreferences() {
})
}
/** SSE quotes_updated
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s ,
* (11:30午休 / 12:55开盘 / 15:05收盘) is_trading_hours
* (SSE ), SSE ,
* (Layout) poll=true, ;
* queryKey ,
*/
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
return useQuery({
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
enabled: opts?.enabled ?? true,
refetchInterval: opts?.poll
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
: false,
})
}
/** 行情间隔 — Layout / Data 共用 */
export function useQuoteInterval() {
return useQuery({
queryKey: QK.quoteInterval,
queryFn: api.quoteInterval,
})
}
/** 版本号 — Layout 专用 */
export function useVersion() {
return useQuery({
-166
View File
@@ -1,166 +0,0 @@
/**
*
*
* //
* list-columns
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam as buildExtColumnsParamBase,
createExtColumn as createExtColumnBase,
mergeColumns as mergeColumnsBase,
serializeColumns as serializeColumnsBase,
type ColumnConfig,
type ColumnGroup,
type ColumnSource,
type ExtColumnDisplayConfig,
type CandleColumnConfig,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
// ===== 内置列注册表(与当前硬编码一一对应) =====
export const BUILTIN_COLUMNS: ColumnConfig[] = [
// 固定列
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '代码/名称', visible: true, pinned: true, align: 'left' },
// 价格
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'center' },
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'center' },
// 均线
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'center' },
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'center' },
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'center' },
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'center' },
// 区间
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'center' },
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'center' },
// 技术指标
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'center' },
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'center' },
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'center' },
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'center' },
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'center' },
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'center' },
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'center' },
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'center' },
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'center' },
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'center' },
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'center' },
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'center' },
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'center' },
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'center' },
// 动量
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum' }, label: '60D 动量', visible: true, align: 'center' },
// 连板
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
// 信号 & 图表
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'center' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'center' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'center' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'center' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
]
export const COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
]
// 操作列(始终显示,不参与自定义)
export const ACTION_COLUMN_ID = 'builtin:action'
// ===== localStorage 持久化 =====
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action */
export function serializeColumns(columns: ColumnConfig[]): ColumnConfig[] {
return serializeColumnsBase(columns, ACTION_COLUMN_ID)
}
/** 序列化并保存到后端 + localStorage */
export async function saveColumnConfig(columns: ColumnConfig[]): Promise<void> {
const saveable = serializeColumns(columns)
// 同时写 localStorage(即时)和后端(持久化)
storage.watchlistColumns.set(saveable)
try {
const { api } = await import('@/lib/api')
await api.updateWatchlistColumns(saveable)
} catch {
// 后端不可用时 localStorage 仍有效
}
}
/** 加载列配置:优先后端,回退 localStorage,最终用默认值 */
export async function loadColumnConfig(): Promise<ColumnConfig[]> {
// 1. 尝试从后端加载
try {
const { api } = await import('@/lib/api')
const res = await api.watchlistColumns()
if (res.columns && res.columns.length > 0) {
const merged = mergeColumns(res.columns, BUILTIN_COLUMNS)
// 同步到 localStorage
storage.watchlistColumns.set(serializeColumns(merged))
return merged
}
} catch {
// 后端不可用,继续尝试 localStorage
}
// 2. 尝试从 localStorage 加载
const saved = storage.watchlistColumns.get([]) as ColumnConfig[]
if (saved.length > 0) {
return mergeColumns(saved, BUILTIN_COLUMNS)
}
// 3. 默认值
return [...BUILTIN_COLUMNS]
}
/** 合并用户保存的列与默认列 */
function mergeColumns(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
return mergeColumnsBase(saved, defaults, { actionColumnId: ACTION_COLUMN_ID })
}
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口 */
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
return buildExtColumnsParamBase(columns)
}
/** 根据 ext schema 数据创建 ext 列配置 */
export function createExtColumn(
configId: string,
configLabel: string,
fieldName: string,
fieldLabel?: string,
): ColumnConfig {
return createExtColumnBase(configId, configLabel, fieldName, fieldLabel)
}
+13
View File
@@ -0,0 +1,13 @@
import { PageHeader } from '@/components/PageHeader'
import { SettingsAIPanel } from './settings/AI'
export function AiSettings() {
return (
<>
<PageHeader title="AI 设置" subtitle="管理 AI 提供商连接与模型配置" />
<div className="px-8 py-6">
<SettingsAIPanel />
</div>
</>
)
}

Some files were not shown because too many files have changed in this diff Show More