Compare commits
13 Commits
267b8557e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f2b53e03e | |||
| 490fb7ac0a | |||
| 566a8be912 | |||
| 476201abed | |||
| e21d2f69ee | |||
| 0ad4b1f030 | |||
| 7a96d51f8a | |||
| f408362c91 | |||
| 008f92ffe3 | |||
| eae13737ff | |||
| fcebba0a32 | |||
| e7cd30b30d | |||
| c1fa0461c1 |
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -1,10 +1,10 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# 📈 A股智能量化工作台
|
# 📈 A股智能量化工作台(本地完整版)
|
||||||
|
|
||||||
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
|
**自托管、满血功能的 A 股「选股 + 监控 + 回测」量化工作台**
|
||||||
|
|
||||||
**面向个人散户与量化爱好者而生**
|
**面向个人散户与量化爱好者,所有功能本地运行**
|
||||||
|
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -17,26 +17,23 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[路线图](#-路线图)**
|
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[同步到服务端](#-同步到服务端)**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
|
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
|
||||||
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
|
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
|
||||||
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
|
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
|
||||||
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
|
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
|
||||||
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
|
- 📡 **实时行情与监控** — 自选股实时行情、五档盘口、监控规则命中后 SSE 弹窗 + 飞书推送
|
||||||
- 🇨🇳 **A 股专用** — 盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业
|
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
|
||||||
|
- 🇨🇳 **A 股专用** — 盘后自动 AI 复盘并推送至飞书等;连板梯队、涨停动量、内置 ths 概念 / 行业
|
||||||
|
|
||||||
|
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
|
||||||
|
|
||||||
|
> ⚠️ 考虑到 tickflow 数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的 ths 概念/ths 行业(后续更新在这里)
|
||||||
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
|
>
|
||||||
|
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
|
||||||
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
|
|
||||||
|
|
||||||
|
|
||||||
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
|
|
||||||
|
|
||||||
|
|
||||||
觉得有用可以点个 Star,蟹蟹 🌹
|
觉得有用可以点个 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` |
|
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||||
| `pnpm` | 9 | `npm i -g pnpm` |
|
| `pnpm` | 9 | `npm i -g pnpm` |
|
||||||
|
|
||||||
### 方式 A:Dev 模式(二次开发推荐)
|
### 方式 A:Dev 模式(二次开发推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
|
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
|
||||||
./dev.sh # Windows: .\dev.ps1
|
./dev.sh # Windows: .\dev.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
|
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
|
||||||
|
|
||||||
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
|
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
|
||||||
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
||||||
|
|
||||||
### 方式 B:Docker(部署最省心)
|
### 方式 B:Docker(部署最省心)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -116,12 +115,14 @@ docker compose up --build
|
|||||||
# 打开 http://localhost:3018
|
# 打开 http://localhost:3018
|
||||||
```
|
```
|
||||||
|
|
||||||
|
容器名:`TickFlow_Local`。默认端口 `3018`,数据持久化到 `./data/`。
|
||||||
|
|
||||||
<details>
|
<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
|
```bash
|
||||||
# 后端
|
# 后端
|
||||||
@@ -132,42 +133,42 @@ uv run uvicorn app.main:app --reload --port 3018
|
|||||||
cd frontend && pnpm install && pnpm dev # http://localhost:3011
|
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>
|
</details>
|
||||||
|
|
||||||
### 🔄 更新代码(已部署用户必读)
|
### 🔄 更新代码(已部署用户必读)
|
||||||
|
|
||||||
拉取新版本只需一条命令:
|
拉取新版本只需一条命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
|
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
|
||||||
|
|
||||||
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
|
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
|
||||||
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
|
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
|
||||||
> - `git reset --hard`
|
> - `git reset --hard`
|
||||||
> - 直接删除整个项目文件夹重新 `git clone`
|
> - 直接删除整个项目文件夹重新 `git clone`
|
||||||
>
|
>
|
||||||
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
|
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
|
||||||
|
|
||||||
### 🧭 跑起来后的第一次使用
|
### 🧭 跑起来后的第一次使用
|
||||||
|
|
||||||
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
|
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
|
||||||
2. **设置** → **立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
|
2. **设置** → **立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
|
||||||
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
|
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
|
||||||
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
|
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
|
||||||
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
|
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|
||||||
### 🔍 选股引擎(Screener)
|
### 🔍 选股引擎(Screener)
|
||||||
|
|
||||||
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
|
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
|
||||||
|
|
||||||
| 类型 | 代表策略 |
|
| 类型 | 代表策略 |
|
||||||
| :---------- | :------------------------------------------------------- |
|
| :---------- | :------------------------------------------------------- |
|
||||||
@@ -175,79 +176,81 @@ git pull
|
|||||||
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
|
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
|
||||||
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
|
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
|
||||||
|
|
||||||
**扩展策略的三种方式:**
|
**扩展策略的三种方式:**
|
||||||
|
|
||||||
| 方式 | 说明 |
|
| 方式 | 说明 |
|
||||||
| :---------------- | :---------------------------------------------------------------------------------------------------- |
|
| :---------------- | :---------------------------------------------------------------------------------------------------- |
|
||||||
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
|
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
|
||||||
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
|
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
|
||||||
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
|
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
|
||||||
|
|
||||||
### 📊 指标流水线(Indicators)
|
### 📊 指标流水线(Indicators)
|
||||||
|
|
||||||
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
|
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
|
||||||
|
|
||||||
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
|
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
|
||||||
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
|
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
|
||||||
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
|
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
|
||||||
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
|
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
|
||||||
- **复权**:基于除权因子自动前复权,回测与指标口径一致
|
- **复权**:基于除权因子自动前复权,回测与指标口径一致
|
||||||
|
|
||||||
### 🧪 回测引擎(Backtest)
|
### 🧪 回测引擎(Backtest)
|
||||||
|
|
||||||
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
|
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
|
||||||
|
|
||||||
### 📡 监控中心(Monitor)
|
### 📡 监控中心(Monitor)
|
||||||
|
|
||||||
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
|
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
|
||||||
|
|
||||||
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
|
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
|
||||||
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
|
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
|
||||||
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
||||||
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
|
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
|
||||||
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
|
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
|
||||||
|
- **SSE 实时推送**:本地运行时,命中告警通过 Server-Sent Events 实时推送到前端
|
||||||
|
|
||||||
### 📈 个股分析(Beta)
|
### 📈 个股分析(Beta)
|
||||||
|
|
||||||
以「行情 + 关键价位」为主体的单标的决策页:
|
以「行情 + 关键价位」为主体的单标的决策页:
|
||||||
|
|
||||||
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
|
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
|
||||||
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
|
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
|
||||||
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
|
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
|
||||||
|
|
||||||
### 🧰 数据与扩展
|
### 🧰 数据与扩展
|
||||||
|
|
||||||
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
|
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
|
||||||
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
|
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
|
||||||
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
|
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
|
||||||
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
|
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ 配置
|
## ⚙️ 配置
|
||||||
|
|
||||||
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
|
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
|
||||||
|
|
||||||
### 数据源:TickFlow
|
### 数据源:TickFlow
|
||||||
|
|
||||||
```ini
|
```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+ | 全市场实时行情 |
|
| Starter+ | 全市场实时行情 |
|
||||||
| Pro | 分钟 K + 盘口 |
|
| Pro | 分钟 K + 盘口 |
|
||||||
| Expert | WebSocket + 财务数据 |
|
| Expert | WebSocket + 财务数据 |
|
||||||
|
|
||||||
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
|
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
|
||||||
|
|
||||||
### AI(可选)
|
### AI(可选)
|
||||||
|
|
||||||
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
|
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
AI_PROVIDER=openai_compat # openai_compat | ollama
|
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
|
```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 |
|
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
|
||||||
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
|
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
|
||||||
| **回测** | vectorbt(全项目唯一 pandas 边界) |
|
| **回测** | vectorbt(全项目唯一 pandas 边界) |
|
||||||
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
|
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK |
|
||||||
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
|
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
|
||||||
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
|
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
|
||||||
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
|
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
|
||||||
|
| **桌面端** | PyInstaller + Inno Setup(Windows 安装包) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -301,28 +323,12 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
|
|||||||
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
|
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
|
||||||
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
|
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
|
||||||
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
|
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
|
||||||
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
|
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
|
||||||
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
|
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 文档与贡献
|
## 📚 文档与贡献
|
||||||
|
|
||||||
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
|
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
|
||||||
- [docs/](./docs) —— 策略构建步骤、示例
|
- [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)。
|
|
||||||
|
|||||||
@@ -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
@@ -1,94 +1,44 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# 📈 A股智能量化工作台
|
# 📈 A股智能量化工作台(服务端备份/查看版)
|
||||||
|
|
||||||
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
|
**本地完整版的云端伴侣:数据备份 + 基础数据查看**
|
||||||
|
|
||||||
**面向个人散户与量化爱好者而生**
|
**面向已部署 `local/` 完整版的用户,提供远程备份与轻量查看能力**
|
||||||
|
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
[](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
|
|
||||||
[](./Dockerfile)
|
[](./Dockerfile)
|
||||||
[](https://github.com/shy3130/tickflow-stock-panel/stargazers)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[路线图](#-路线图)**
|
**[快速开始](#-快速开始)** · **[功能范围](#-功能范围)** · **[接收本地同步](#-接收本地同步)** · **[部署说明](#-部署说明)**
|
||||||
|
|
||||||
</div>
|
</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/) |
|
| 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` |
|
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||||
| `pnpm` | 9 | `npm i -g pnpm` |
|
| `pnpm` | 9 | `npm i -g pnpm` |
|
||||||
|
|
||||||
### 方式 A:Dev 模式(二次开发推荐)
|
### 方式 A:Dev 模式
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
|
cp .env.example .env # 配置监听端口、同步密钥等
|
||||||
./dev.sh # Windows: .\dev.ps1
|
./dev.sh # Windows: .\dev.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
|
默认:
|
||||||
|
|
||||||
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
|
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
|
||||||
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
||||||
|
|
||||||
### 方式 B:Docker(部署最省心)
|
### 方式 B:Docker(推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -116,213 +66,151 @@ docker compose up --build
|
|||||||
# 打开 http://localhost:3018
|
# 打开 http://localhost:3018
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
容器名:`stock_panel`。默认端口 `3018`。
|
||||||
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
|
|
||||||
|
|
||||||
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`。
|
> 与 `local/` 不同,`serve/` 的 `data/` 目录**默认被 `.dockerignore` 忽略**,容器重启不会保留容器内的数据。请通过本地 `local/` 主动同步,或将 `data/` 挂载到持久化卷。
|
||||||
|
|
||||||
**手动分别启动:**
|
|
||||||
|
|
||||||
```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. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ 核心功能
|
## 📦 功能范围
|
||||||
|
|
||||||
### 🔍 选股引擎(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/`):
|
### 适用场景
|
||||||
|
|
||||||
| 类型 | 代表策略 |
|
- **异地灾备**:local/ 每天收盘后自动把 enriched 数据、选股结果、复盘报告推送到 serve/
|
||||||
| :---------- | :------------------------------------------------------- |
|
- **移动查看**:出差时用手机浏览器访问 serve/,查看本地已分析好的结果
|
||||||
| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 |
|
- **家庭共享**:家人用 viewer 账号查看,admin 账号管理同步密钥
|
||||||
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
|
|
||||||
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
|
|
||||||
|
|
||||||
**扩展策略的三种方式:**
|
---
|
||||||
|
|
||||||
| 方式 | 说明 |
|
## 🔄 接收本地同步
|
||||||
| :---------------- | :---------------------------------------------------------------------------------------------------- |
|
|
||||||
| **🎛️ 自定义信号** | 不写代码,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 · 动量 · 布林带
|
```ini
|
||||||
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
|
SYNC_KEY=your-shared-secret
|
||||||
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
|
```
|
||||||
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
|
|
||||||
- **复权**:基于除权因子自动前复权,回测与指标口径一致
|
|
||||||
|
|
||||||
### 🧪 回测引擎(Backtest)
|
首次启动时建议通过 `AUTH_PASSWORD` 预置管理员密码:
|
||||||
|
|
||||||
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
|
```ini
|
||||||
|
AUTH_PASSWORD=你的密码
|
||||||
|
```
|
||||||
|
|
||||||
### 📡 监控中心(Monitor)
|
### 2. 本地端配置
|
||||||
|
|
||||||
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
|
在 `local/.env` 中填写服务端地址和密钥:
|
||||||
|
|
||||||
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
|
```ini
|
||||||
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
|
SYNC_SERVE_URL=https://your-serve.example.com
|
||||||
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
SYNC_KEY=your-shared-secret
|
||||||
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
|
```
|
||||||
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
|
|
||||||
|
|
||||||
### 📈 个股分析(Beta)
|
### 3. 触发同步
|
||||||
|
|
||||||
以「行情 + 关键价位」为主体的单标的决策页:
|
在 local/ 的「设置 → 数据同步」页面手动触发,或等待盘后定时任务自动推送。
|
||||||
|
|
||||||
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
|
### 4. 安全建议
|
||||||
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
|
|
||||||
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
|
|
||||||
|
|
||||||
### 🧰 数据与扩展
|
- 务必通过 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
|
```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 兼容接口:
|
│ local/ 本地完整版 │
|
||||||
|
│ 选股 · 回测 · 实时监控 · AI 分析 │
|
||||||
```ini
|
│ ↓ 定时/手动同步 │
|
||||||
AI_PROVIDER=openai_compat # openai_compat | ollama
|
└─────────────────────────────────────┘
|
||||||
AI_BASE_URL=https://api.deepseek.com/v1
|
│
|
||||||
AI_API_KEY= # 留空 = 关闭 AI
|
▼ HTTPS + SYNC_KEY
|
||||||
AI_MODEL=deepseek-chat
|
┌─────────────────────────────────────┐
|
||||||
AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
|
│ serve/ 服务端轻量版 │
|
||||||
|
│ 数据备份 · 基础查看 · 多用户 │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 服务与数据
|
- **local/**:功能核心,承担所有计算、实时行情、回测、监控
|
||||||
|
- **serve/**:数据落地与轻量展示,不承担计算,不直接调用 TickFlow 高配额接口
|
||||||
```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)。
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -29,6 +31,89 @@ SYNCABLE_PARTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _verify_sync_key(request: Request) -> None:
|
||||||
"""简单的鉴权 — 校验 X-Sync-Key 头。"""
|
"""简单的鉴权 — 校验 X-Sync-Key 头。"""
|
||||||
key = request.headers.get("X-Sync-Key", "")
|
key = request.headers.get("X-Sync-Key", "")
|
||||||
@@ -105,6 +190,7 @@ async def upload_sync(
|
|||||||
raise HTTPException(status_code=400, detail="上传文件为空")
|
raise HTTPException(status_code=400, detail="上传文件为空")
|
||||||
|
|
||||||
extracted = 0
|
extracted = 0
|
||||||
|
action_counter: dict[str, int] = {}
|
||||||
try:
|
try:
|
||||||
with tarfile.open(fileobj=BytesIO(raw), mode="r:gz") as tar:
|
with tarfile.open(fileobj=BytesIO(raw), mode="r:gz") as tar:
|
||||||
for member in tar.getmembers():
|
for member in tar.getmembers():
|
||||||
@@ -123,13 +209,15 @@ async def upload_sync(
|
|||||||
with tar.extractfile(member) as src:
|
with tar.extractfile(member) as src:
|
||||||
if src is None:
|
if src is None:
|
||||||
continue
|
continue
|
||||||
target.write_bytes(src.read())
|
ok, action = _merge_parquet(target, src.read())
|
||||||
extracted += 1
|
if ok:
|
||||||
|
extracted += 1
|
||||||
|
action_counter[action] = action_counter.get(action, 0) + 1
|
||||||
except tarfile.TarError as exc:
|
except tarfile.TarError as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"压缩包解析失败: {exc}") from exc
|
raise HTTPException(status_code=400, detail=f"压缩包解析失败: {exc}") from exc
|
||||||
|
|
||||||
# 刷新缓存
|
# 刷新缓存
|
||||||
_refresh_views(request)
|
_refresh_views(request)
|
||||||
|
|
||||||
logger.info("sync uploaded: parts=%s files=%d", parts, extracted)
|
logger.info("sync uploaded: parts=%s files=%d actions=%s", parts, extracted, action_counter)
|
||||||
return {"ok": True, "parts": sorted(requested), "file_count": extracted}
|
return {"ok": True, "parts": sorted(requested), "file_count": extracted, "actions": action_counter}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ async def analyze_financials(request: Request, req: AnalyzeRequest):
|
|||||||
data_dir = request.app.state.repo.store.data_dir
|
data_dir = request.app.state.repo.store.data_dir
|
||||||
|
|
||||||
async def stream_gen():
|
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"
|
yield chunk + "\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
|
|||||||
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
|
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
|
||||||
|
|
||||||
async def stream_gen():
|
async def stream_gen():
|
||||||
async for chunk in recap_market_stream(repo, as_of, req.focus):
|
async for chunk in recap_market_stream(repo, as_of, req.focus, username=request.state.username):
|
||||||
yield chunk + "\n"
|
yield chunk + "\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
|
|||||||
async def stream_gen():
|
async def stream_gen():
|
||||||
async for chunk in analyze_rotation_stream(
|
async for chunk in analyze_rotation_stream(
|
||||||
repo, days, req.focus,
|
repo, days, req.focus,
|
||||||
|
username=request.state.username,
|
||||||
):
|
):
|
||||||
yield chunk + "\n"
|
yield chunk + "\n"
|
||||||
|
|
||||||
|
|||||||
@@ -49,14 +49,38 @@ class TickflowKeyIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _get_ai_config(username: str | None, key: str, default: str = "") -> str:
|
def _get_ai_config(username: str | None, key: str, default: str = "") -> str:
|
||||||
"""读 AI 配置: 用户级优先, 无则全局。"""
|
"""读 AI 配置: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro。"""
|
||||||
if username:
|
if username:
|
||||||
val = secrets_store.load_ai_config(username).get(key)
|
val = secrets_store.load_ai_config(username).get(key)
|
||||||
if val:
|
if val:
|
||||||
return 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)
|
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("")
|
@router.get("")
|
||||||
def get_settings(request: Request) -> dict:
|
def get_settings(request: Request) -> dict:
|
||||||
"""返回当前配置概况(Key 脱敏)。"""
|
"""返回当前配置概况(Key 脱敏)。"""
|
||||||
@@ -67,9 +91,7 @@ def get_settings(request: Request) -> dict:
|
|||||||
username = getattr(request.state, "username", None)
|
username = getattr(request.state, "username", None)
|
||||||
key = secrets_store.get_tickflow_key()
|
key = secrets_store.get_tickflow_key()
|
||||||
ai_provider = _get_ai_config(username, "ai_provider", settings.ai_provider)
|
ai_provider = _get_ai_config(username, "ai_provider", settings.ai_provider)
|
||||||
ai_api_key = secrets_store.load_ai_config(username).get("ai_api_key") if username else None
|
ai_api_key = _get_ai_api_key(username)
|
||||||
if not ai_api_key:
|
|
||||||
ai_api_key = secrets_store.get_ai_key()
|
|
||||||
return {
|
return {
|
||||||
"mode": tf_client.current_mode(),
|
"mode": tf_client.current_mode(),
|
||||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ async def analyze_stock(request: Request, req: AnalyzeRequest):
|
|||||||
data_dir = repo.store.data_dir
|
data_dir = repo.store.data_dir
|
||||||
|
|
||||||
async def stream_gen():
|
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"
|
yield chunk + "\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ async def build_strategy(req: BuildRequest, request: Request):
|
|||||||
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
|
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await gen.generate(prompt)
|
result = await gen.generate(prompt, username=request.state.username)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
return result
|
return result
|
||||||
@@ -356,7 +356,7 @@ async def build_strategy(req: BuildRequest, request: Request):
|
|||||||
async def ai_generate(req: AIGenerateRequest, request: Request):
|
async def ai_generate(req: AIGenerateRequest, request: Request):
|
||||||
try:
|
try:
|
||||||
gen = AIStrategyGenerator()
|
gen = AIStrategyGenerator()
|
||||||
result = await gen.generate(req.prompt)
|
result = await gen.generate(req.prompt, username=request.state.username)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -25,16 +25,74 @@ Message = dict[str, str]
|
|||||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
_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
|
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
|
||||||
|
|
||||||
|
|
||||||
def current_ai_model() -> str:
|
def current_ai_model(username: str | None = None) -> str:
|
||||||
if current_ai_provider() == CODEX_CLI_PROVIDER:
|
if current_ai_provider(username) == CODEX_CLI_PROVIDER:
|
||||||
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
|
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)
|
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:
|
def current_codex_command() -> str:
|
||||||
return normalize_codex_command(
|
return normalize_codex_command(
|
||||||
secrets_store.get_ai_config("ai_codex_command", settings.ai_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
|
return False
|
||||||
|
|
||||||
|
|
||||||
def ai_configured(provider: str | None = None) -> bool:
|
def ai_configured(provider: str | None = None, username: str | None = None) -> bool:
|
||||||
provider = provider or current_ai_provider()
|
provider = provider or current_ai_provider(username)
|
||||||
if is_codex_cli_provider(provider):
|
if is_codex_cli_provider(provider):
|
||||||
return codex_cli_available()
|
return codex_cli_available()
|
||||||
return bool(secrets_store.get_ai_key())
|
return bool(current_ai_api_key(username))
|
||||||
|
|
||||||
|
|
||||||
async def generate_ai_text(
|
async def generate_ai_text(
|
||||||
@@ -95,15 +153,17 @@ async def generate_ai_text(
|
|||||||
temperature: float = 0.3,
|
temperature: float = 0.3,
|
||||||
max_tokens: int = 3000,
|
max_tokens: int = 3000,
|
||||||
timeout: float = 180.0,
|
timeout: float = 180.0,
|
||||||
|
username: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return a complete AI response from the currently configured provider."""
|
"""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_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
|
||||||
return await _run_openai_once(
|
return await _run_openai_once(
|
||||||
messages,
|
messages,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
username=username,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,13 +173,14 @@ async def stream_ai_text(
|
|||||||
temperature: float = 0.5,
|
temperature: float = 0.5,
|
||||||
max_tokens: int = 4000,
|
max_tokens: int = 4000,
|
||||||
timeout: float = 180.0,
|
timeout: float = 180.0,
|
||||||
|
username: str | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""Yield text deltas from the configured provider.
|
"""Yield text deltas from the configured provider.
|
||||||
|
|
||||||
Codex CLI only exposes the final assistant message for this use case, so it
|
Codex CLI only exposes the final assistant message for this use case, so it
|
||||||
yields one complete chunk after the command exits.
|
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))
|
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -128,6 +189,7 @@ async def stream_ai_text(
|
|||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
username=username,
|
||||||
):
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
@@ -138,14 +200,15 @@ async def _run_openai_once(
|
|||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
|
username: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
ai_key = secrets_store.get_ai_key()
|
ai_key = current_ai_api_key(username)
|
||||||
if not ai_key:
|
if not ai_key:
|
||||||
raise RuntimeError("AI API 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(
|
resp = await client.chat.completions.create(
|
||||||
model=current_ai_model(),
|
model=current_ai_model(username),
|
||||||
messages=list(messages),
|
messages=list(messages),
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
@@ -161,14 +224,15 @@ async def _stream_openai(
|
|||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
|
username: str | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
ai_key = secrets_store.get_ai_key()
|
ai_key = current_ai_api_key(username)
|
||||||
if not ai_key:
|
if not ai_key:
|
||||||
raise RuntimeError("AI API 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(
|
stream = await client.chat.completions.create(
|
||||||
model=current_ai_model(),
|
model=current_ai_model(username),
|
||||||
messages=list(messages),
|
messages=list(messages),
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
@@ -181,13 +245,13 @@ async def _stream_openai(
|
|||||||
yield delta.content
|
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
|
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(
|
return AsyncOpenAI(
|
||||||
api_key=api_key,
|
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,
|
timeout=timeout,
|
||||||
max_retries=2,
|
max_retries=2,
|
||||||
default_headers={"User-Agent": user_agent},
|
default_headers={"User-Agent": user_agent},
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ async def analyze_rotation_stream(
|
|||||||
repo,
|
repo,
|
||||||
days: int = 12,
|
days: int = 12,
|
||||||
focus: str = "",
|
focus: str = "",
|
||||||
|
*,
|
||||||
|
username: str | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
|
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
|
||||||
|
|
||||||
@@ -329,7 +331,7 @@ async def analyze_rotation_stream(
|
|||||||
try:
|
try:
|
||||||
from app.services.ai_provider import stream_ai_text, ai_configured
|
from app.services.ai_provider import stream_ai_text, ai_configured
|
||||||
|
|
||||||
if not ai_configured():
|
if not ai_configured(username=username):
|
||||||
yield json.dumps({
|
yield json.dumps({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
|
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
|
||||||
@@ -344,6 +346,7 @@ async def analyze_rotation_stream(
|
|||||||
],
|
],
|
||||||
temperature=0.5,
|
temperature=0.5,
|
||||||
max_tokens=4000,
|
max_tokens=4000,
|
||||||
|
username=username,
|
||||||
):
|
):
|
||||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ async def analyze_financials_stream(
|
|||||||
data_dir: Path,
|
data_dir: Path,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
focus: str = "",
|
focus: str = "",
|
||||||
|
*,
|
||||||
|
username: str | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""流式分析:yield 出每个文本 chunk。
|
"""流式分析:yield 出每个文本 chunk。
|
||||||
|
|
||||||
@@ -176,6 +178,7 @@ async def analyze_financials_stream(
|
|||||||
],
|
],
|
||||||
temperature=0.4,
|
temperature=0.4,
|
||||||
max_tokens=4000,
|
max_tokens=4000,
|
||||||
|
username=username,
|
||||||
):
|
):
|
||||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ async def recap_market_stream(
|
|||||||
as_of: date | None = None,
|
as_of: date | None = None,
|
||||||
focus: str = "",
|
focus: str = "",
|
||||||
news: list[dict] | None = None,
|
news: list[dict] | None = None,
|
||||||
|
*,
|
||||||
|
username: str | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""流式大盘复盘:yield 出每个 NDJSON 事件。
|
"""流式大盘复盘:yield 出每个 NDJSON 事件。
|
||||||
|
|
||||||
@@ -298,6 +300,7 @@ async def recap_market_stream(
|
|||||||
],
|
],
|
||||||
temperature=0.5,
|
temperature=0.5,
|
||||||
max_tokens=4500,
|
max_tokens=4500,
|
||||||
|
username=username,
|
||||||
):
|
):
|
||||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ async def analyze_stock_stream(
|
|||||||
data_dir: Path,
|
data_dir: Path,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
focus: str = "",
|
focus: str = "",
|
||||||
|
*,
|
||||||
|
username: str | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""流式个股分析:yield 出每个 NDJSON 事件。
|
"""流式个股分析:yield 出每个 NDJSON 事件。
|
||||||
|
|
||||||
@@ -298,6 +300,7 @@ async def analyze_stock_stream(
|
|||||||
],
|
],
|
||||||
temperature=0.5,
|
temperature=0.5,
|
||||||
max_tokens=4500,
|
max_tokens=4500,
|
||||||
|
username=username,
|
||||||
):
|
):
|
||||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class AIStrategyGenerator:
|
|||||||
self._guide_cache = ""
|
self._guide_cache = ""
|
||||||
return 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}
|
Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None}
|
||||||
@@ -58,7 +58,7 @@ class AIStrategyGenerator:
|
|||||||
guide = self._get_guide()
|
guide = self._get_guide()
|
||||||
|
|
||||||
# 调用 LLM
|
# 调用 LLM
|
||||||
code = await self._call_llm(user_prompt, guide)
|
code = await self._call_llm(user_prompt, guide, username=username)
|
||||||
|
|
||||||
# 验证
|
# 验证
|
||||||
try:
|
try:
|
||||||
@@ -74,7 +74,7 @@ class AIStrategyGenerator:
|
|||||||
|
|
||||||
return {"code": code, "meta": meta, "valid": True, "error": None}
|
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."""
|
"""Call the configured AI provider and return generated strategy code."""
|
||||||
from app.services.ai_provider import generate_ai_text
|
from app.services.ai_provider import generate_ai_text
|
||||||
|
|
||||||
@@ -85,6 +85,7 @@ class AIStrategyGenerator:
|
|||||||
],
|
],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=3000,
|
max_tokens=3000,
|
||||||
|
username=username,
|
||||||
)
|
)
|
||||||
# Extract fenced code if the model wrapped the answer in Markdown.
|
# Extract fenced code if the model wrapped the answer in Markdown.
|
||||||
if "```python" in content:
|
if "```python" in content:
|
||||||
|
|||||||
Generated
+1
-1
@@ -2491,7 +2491,7 @@ all = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tickflow-stock-panel-backend"
|
name = "tickflow-stock-panel-backend"
|
||||||
version = "0.1.66"
|
version = "0.1.70"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
|
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
|
||||||
container_name: TickFlow_Stock_Panel
|
container_name: stock_panel
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3018}:3018"
|
- "${PORT:-3018}:3018"
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
LogOut,
|
LogOut,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
@@ -92,6 +94,7 @@ export function Layout() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const username = authData?.username
|
const username = authData?.username
|
||||||
|
const role = authData?.role
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
@@ -209,6 +212,21 @@ export function Layout() {
|
|||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 暗夜模式 */}
|
{/* 暗夜模式 */}
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ import { useEffect, useState, type FormEvent } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert, Sparkles } from 'lucide-react'
|
import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Logo } from '@/components/Logo'
|
import { Logo } from '@/components/Logo'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
export function Auth() {
|
export function Auth() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [username, setUsername] = useState('admin')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
|
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
|
||||||
const [showPwd, setShowPwd] = useState(false)
|
const [showPwd, setShowPwd] = useState(false)
|
||||||
@@ -196,11 +196,6 @@ export function Auth() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-muted/60">
|
|
||||||
<Sparkles className="h-3 w-3" />
|
|
||||||
自托管量化工作台 · 数据完全掌握在自己手里
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { DatePicker } from '@/components/DatePicker'
|
|||||||
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api'
|
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api'
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { fmtBigNum } from '@/lib/format'
|
import { fmtBigNum } from '@/lib/format'
|
||||||
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
|
import { useDataStatus, useSettings } from '@/lib/useSharedQueries'
|
||||||
import { SealedBadge } from '@/components/SealedBadge'
|
|
||||||
import { SettingsModal } from '@/components/data/SettingsModal'
|
import { SettingsModal } from '@/components/data/SettingsModal'
|
||||||
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
|
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
|
||||||
|
|
||||||
@@ -371,11 +370,7 @@ export function Dashboard() {
|
|||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
})
|
})
|
||||||
const data = overview.data
|
const data = overview.data
|
||||||
const caps = useCapabilities()
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
|
|
||||||
const sealedReady = !!data?.limit?.sealed_ready
|
|
||||||
const isSealedDegrade = !hasDepth || !sealedReady
|
|
||||||
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
|
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
|
||||||
const isNoKey = settings.data?.mode === 'none'
|
const isNoKey = settings.data?.mode === 'none'
|
||||||
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
|
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
|
||||||
@@ -541,7 +536,7 @@ export function Dashboard() {
|
|||||||
<div className="mb-3 grid grid-cols-6 gap-2">
|
<div className="mb-3 grid grid-cols-6 gap-2">
|
||||||
<KpiCell label="个股涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
|
<KpiCell label="个股涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
|
||||||
<KpiCell label="强势 / 弱势" value={<><span className="text-bull">{strongUp}</span><span className="text-muted">/</span><span className="text-bear">{strongDown}</span></>} sub="涨跌 ≥3%" />
|
<KpiCell label="强势 / 弱势" value={<><span className="text-bull">{strongUp}</span><span className="text-muted">/</span><span className="text-bear">{strongDown}</span></>} sub="涨跌 ≥3%" />
|
||||||
<KpiCell label={<span className="inline-flex items-center gap-1">涨停 / 跌停<SealedBadge degraded={isSealedDegrade} hasDepth={hasDepth} isHistorical={false} sealedReady={sealedReady} sealedCountsUp={{ real: data.limit.limit_up, fake: data.limit.fake_up ?? 0, pending: 0 }} sealedCountsDown={{ real: data.limit.limit_down, fake: data.limit.fake_down ?? 0, pending: 0 }} rawUp={data.limit.limit_up + (data.limit.fake_up ?? 0)} rawDown={data.limit.limit_down + (data.limit.fake_down ?? 0)} invalidateKeys={['overview-market', 'limit-ladder']} /></span>} value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
|
<KpiCell label={<span className="inline-flex items-center gap-1">涨停 / 跌停</span>} value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
|
||||||
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}板`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
|
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}板`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
|
||||||
<KpiCell label="成交额" value={fmtBigNum(data.amount.total)} sub={`均额 ${fmtBigNum(data.amount.avg)}`} />
|
<KpiCell label="成交额" value={fmtBigNum(data.amount.total)} sub={`均额 ${fmtBigNum(data.amount.avg)}`} />
|
||||||
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量占比 ${fmtPrice(data.activity.high_vol_ratio, 1)}%`} tone="accent" />
|
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量占比 ${fmtPrice(data.activity.high_vol_ratio, 1)}%`} tone="accent" />
|
||||||
@@ -611,7 +606,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
<aside className="min-w-0 space-y-3">
|
<aside className="min-w-0 space-y-3">
|
||||||
<section className="rounded-card border border-border bg-surface/80 p-3">
|
<section className="rounded-card border border-border bg-surface/80 p-3">
|
||||||
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}{isSealedDegrade && <span className="text-[9px] px-1 rounded bg-yellow-500/10 text-yellow-600 dark:text-yellow-500">{hasDepth ? '未修正' : '降级'}</span>}</span>} />
|
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}</span>} />
|
||||||
<LadderMini limit={data.limit} />
|
<LadderMini limit={data.limit} />
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-card border border-border bg-surface/80 p-3">
|
<section className="rounded-card border border-border bg-surface/80 p-3">
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, CheckCircle2, Hourglass, Lightbulb, ExternalLink } from 'lucide-react'
|
import { Loader2, Search, FileText, Database, Lightbulb, ExternalLink, X } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { useCapabilities } from '@/lib/useSharedQueries'
|
import { useFinancialStatus } from '@/lib/useFinancials'
|
||||||
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
|
|
||||||
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
||||||
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
|
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
|
||||||
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
|
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
|
||||||
import { LastStockChip } from '@/components/LastStockChip'
|
import { LastStockChip } from '@/components/LastStockChip'
|
||||||
import { useLastStock } from '@/lib/useLastStock'
|
import { useLastStock } from '@/lib/useLastStock'
|
||||||
import { fmtBigNum } from '@/lib/format'
|
import { fmtBigNum } from '@/lib/format'
|
||||||
import { toast } from '@/components/Toast'
|
|
||||||
|
|
||||||
const TABLE_LABELS: Record<string, string> = {
|
const TABLE_LABELS: Record<string, string> = {
|
||||||
metrics: '核心指标',
|
metrics: '核心指标',
|
||||||
@@ -27,63 +25,32 @@ const TABLE_ICON: Record<string, typeof FileText> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Financials() {
|
export function Financials() {
|
||||||
const { data: caps } = useCapabilities()
|
|
||||||
const hasFinancial = caps?.capabilities?.['financial'] != null
|
|
||||||
const { data: status, isLoading } = useFinancialStatus()
|
const { data: status, isLoading } = useFinancialStatus()
|
||||||
const syncMut = useFinancialSync()
|
|
||||||
// 同步进行中 = 服务端真值(status.syncing)或本地乐观态(请求已发出待确认)。
|
|
||||||
// 乐观窗口:点击后到 invalidate 触发的 refetch 返回之间,status.syncing 暂为 false,
|
|
||||||
// 用 syncMut.isPending 覆盖,让按钮立即置灰、避免重复点击。
|
|
||||||
// 后端 trigger() 返回时 syncing 已为 true,refetch 到达后 status.syncing 接管。
|
|
||||||
const syncing = (status?.syncing ?? false) || syncMut.isPending
|
|
||||||
// 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步
|
|
||||||
// (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度)
|
|
||||||
const [syncStartedAt, setSyncStartedAt] = useState<number | null>(null)
|
|
||||||
// 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态
|
|
||||||
const [syncSingleTable, setSyncSingleTable] = useState<string | null>(null)
|
|
||||||
// 同步自然结束(服务端 syncing 由 true→false):清空本次同步记录。
|
|
||||||
// 这是可靠的收尾时机 —— 不依赖 mutation 的 onSettled(它现在瞬间触发,会误清)。
|
|
||||||
useEffect(() => {
|
|
||||||
if (!syncing && syncStartedAt !== null) {
|
|
||||||
setSyncStartedAt(null)
|
|
||||||
setSyncSingleTable(null)
|
|
||||||
}
|
|
||||||
}, [syncing, syncStartedAt])
|
|
||||||
// 选中的个股(模糊搜索结果);null 时显示搜索引导
|
|
||||||
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
|
|
||||||
const { last: lastStock, remember: rememberStock } = useLastStock('financials')
|
const { last: lastStock, remember: rememberStock } = useLastStock('financials')
|
||||||
|
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
|
||||||
const pick = (symbol: string, name: string) => {
|
const pick = (symbol: string, name: string) => {
|
||||||
setSelected({ symbol, name })
|
setSelected({ symbol, name })
|
||||||
rememberStock(symbol, name)
|
rememberStock(symbol, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无 Expert 能力时: 等 status 加载完, 判断是否有从 local 同步来的数据
|
// 等待 status 加载完, 判断是否有从 local 同步来的数据
|
||||||
if (!hasFinancial) {
|
if (isLoading) {
|
||||||
if (isLoading) {
|
return (
|
||||||
return (
|
<>
|
||||||
<>
|
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析" />
|
||||||
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" />
|
<div className="flex items-center justify-center py-16">
|
||||||
<div className="flex items-center justify-center py-16">
|
<Loader2 className="h-5 w-5 animate-spin text-muted" />
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted" />
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
)
|
||||||
)
|
}
|
||||||
}
|
if (!status?.available) {
|
||||||
if (!status?.available) {
|
// 无同步数据
|
||||||
// 既无 Expert 能力, 也无同步数据 → 锁定页
|
return (
|
||||||
return (
|
<>
|
||||||
<>
|
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析" />
|
||||||
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" />
|
<div className="px-8 py-10">
|
||||||
<div className="px-8 py-10">
|
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
|
||||||
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
|
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
|
|
||||||
<Lock className="h-6 w-6 text-warning" />
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-4 text-base font-semibold text-foreground">需要 Expert 套餐</h3>
|
|
||||||
<p className="mt-2 text-xs leading-relaxed text-secondary">
|
|
||||||
财务数据接口仅 Expert 套餐可用。升级后此页自动显示财务数据面板。
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 rounded-btn border border-accent/25 bg-accent/[0.05] px-3.5 py-3 text-left">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-accent">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-accent">
|
||||||
<Lightbulb className="h-3.5 w-3.5 shrink-0" />
|
<Lightbulb className="h-3.5 w-3.5 shrink-0" />
|
||||||
关于数据源
|
关于数据源
|
||||||
@@ -100,72 +67,15 @@ export function Financials() {
|
|||||||
前往 Issues 推荐
|
前往 Issues 推荐
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 有同步数据, 继续展示
|
// 有同步数据, 继续展示
|
||||||
}
|
|
||||||
|
|
||||||
const handleSync = (table: string) => {
|
|
||||||
// 防重复点击:syncing 中不再触发(后端 trigger 也有 _is_syncing 兜底)
|
|
||||||
if (syncing) return
|
|
||||||
// 记录开始时间: 全量同步判断所有 4 张表, 单表同步只判断这一张
|
|
||||||
setSyncStartedAt(Date.now())
|
|
||||||
setSyncSingleTable(table === 'all' ? null : table)
|
|
||||||
syncMut.mutate(table, {
|
|
||||||
onSuccess: (r) => {
|
|
||||||
// 后端 trigger 立即返回 started 状态;若被防并发跳过(已有同步在进行),
|
|
||||||
// 给用户明确反馈,并清空本次误设的记录。
|
|
||||||
if (!r.synced?.started) {
|
|
||||||
if (r.synced?.reason === 'already running') {
|
|
||||||
toast('财务数据正在同步中,请稍候', 'success')
|
|
||||||
} else if (r.synced?.reason === 'no FINANCIAL capability') {
|
|
||||||
// 能力未就绪:通常发生在升级/刷新 Key 后调度器状态未同步 —— 提示用户检查 Key
|
|
||||||
toast('财务数据能力未就绪,请检查 API Key 或刷新页面后重试', 'error')
|
|
||||||
} else {
|
|
||||||
toast(`同步未能开始${r.synced?.reason ? `:${r.synced.reason}` : ''}`, 'error')
|
|
||||||
}
|
|
||||||
setSyncStartedAt(null)
|
|
||||||
setSyncSingleTable(null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
// 请求失败:清空本次记录(request 已弹错误 toast)
|
|
||||||
setSyncStartedAt(null)
|
|
||||||
setSyncSingleTable(null)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const tables = status?.tables ?? {}
|
const tables = status?.tables ?? {}
|
||||||
const available = status?.available ?? false
|
const available = status?.available ?? false
|
||||||
const lastSync = status?.last_sync ?? {}
|
|
||||||
// 本次同步进度: 仅当 syncStartedAt 存在且 syncing 时, 按 last_sync 时间戳判断
|
|
||||||
const isFullSync = syncing && syncStartedAt && !syncSingleTable // 全量同步
|
|
||||||
const isSingleSync = syncing && syncStartedAt && !!syncSingleTable // 单表同步
|
|
||||||
const TABLE_ORDER = ['metrics', 'income', 'balance_sheet', 'cash_flow'] as const
|
|
||||||
const tableDoneThisRound = (key: string): boolean => {
|
|
||||||
if (!syncStartedAt || !syncing) return false
|
|
||||||
// 单表同步: 只判断这一张表是否完成
|
|
||||||
if (syncSingleTable && key !== syncSingleTable) return false
|
|
||||||
const ls = lastSync[key]
|
|
||||||
if (!ls) return false
|
|
||||||
return new Date(ls).getTime() >= syncStartedAt
|
|
||||||
}
|
|
||||||
// 当前正在同步的表:
|
|
||||||
// 全量同步 → 第一个未完成的; 单表同步 → 那张表(未完成时)
|
|
||||||
const currentSyncingTable = syncing && syncStartedAt
|
|
||||||
? (syncSingleTable
|
|
||||||
? (tableDoneThisRound(syncSingleTable) ? null : syncSingleTable)
|
|
||||||
: TABLE_ORDER.find(t => !tableDoneThisRound(t)) ?? null)
|
|
||||||
: null
|
|
||||||
const syncedCount = TABLE_ORDER.filter(t => tableDoneThisRound(t)).length
|
|
||||||
// 卡片三态: 仅全量同步时未轮到的表显示"等待"; 单表同步时其他表保持原样
|
|
||||||
const isWaitingTable = (key: string): boolean =>
|
|
||||||
!!isFullSync && !tableDoneThisRound(key) && currentSyncingTable !== key
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -175,40 +85,13 @@ export function Financials() {
|
|||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LastStockChip stock={lastStock} onSelect={pick} />
|
<LastStockChip stock={lastStock} onSelect={pick} />
|
||||||
{syncing && (
|
|
||||||
<span className="text-xs text-accent/80 flex items-center gap-1.5">
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
{isFullSync
|
|
||||||
? `已同步 ${syncedCount}/4 张表…`
|
|
||||||
: isSingleSync
|
|
||||||
? `同步${TABLE_LABELS[syncSingleTable!] ?? syncSingleTable}…`
|
|
||||||
: '同步中…'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{hasFinancial && (
|
|
||||||
<button
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
onClick={() => handleSync('all')}
|
|
||||||
disabled={syncing}
|
|
||||||
title={syncing ? '正在同步,请稍候…' : '同步全部财务表'}
|
|
||||||
>
|
|
||||||
{syncing
|
|
||||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
: <RefreshCw className="h-3.5 w-3.5" />}
|
|
||||||
{syncing ? '同步中…' : '全部同步'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-8 py-6 space-y-6 max-w-7xl">
|
<div className="px-8 py-6 space-y-6 max-w-7xl">
|
||||||
{syncing && (
|
|
||||||
<div className="flex items-center gap-2 rounded-card border border-accent/30 bg-accent/[0.06] px-3 py-2 text-xs text-accent">
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
|
||||||
正在从 TickFlow 拉取财务数据,请稍候…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
|
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
|
||||||
{!isLoading && available && (
|
{!isLoading && available && (
|
||||||
@@ -219,49 +102,22 @@ export function Financials() {
|
|||||||
const TIcon = TABLE_ICON[key] ?? Database
|
const TIcon = TABLE_ICON[key] ?? Database
|
||||||
const hasData = (info?.rows ?? 0) > 0
|
const hasData = (info?.rows ?? 0) > 0
|
||||||
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
|
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
|
||||||
const doneThisRound = tableDoneThisRound(key)
|
|
||||||
const isThisSyncing = currentSyncingTable === key
|
|
||||||
const isWaiting = isWaitingTable(key)
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={`rounded-card border p-3.5 transition-colors flex flex-col ${
|
className={`rounded-card border p-3.5 transition-colors flex flex-col ${
|
||||||
isThisSyncing
|
hasData
|
||||||
? 'border-accent/40 bg-accent/[0.04]'
|
? 'border-border bg-surface'
|
||||||
: isWaiting
|
: 'border-dashed border-border/60 bg-elevated/20'
|
||||||
? 'border-border/50 bg-elevated/15'
|
|
||||||
: hasData
|
|
||||||
? 'border-border bg-surface'
|
|
||||||
: 'border-dashed border-border/60 bg-elevated/20'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{doneThisRound ? (
|
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
|
|
||||||
) : isThisSyncing ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />
|
|
||||||
) : isWaiting ? (
|
|
||||||
<Hourglass className="h-3.5 w-3.5 text-muted/60" />
|
|
||||||
) : (
|
|
||||||
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium text-foreground">{label}</span>
|
<span className="text-xs font-medium text-foreground">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{hasFinancial ? (
|
|
||||||
<button
|
|
||||||
className="text-muted hover:text-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
onClick={() => handleSync(key)}
|
|
||||||
disabled={syncing}
|
|
||||||
title={syncing ? '正在同步…' : `同步${label}`}
|
|
||||||
>
|
|
||||||
{syncing
|
|
||||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
: <RefreshCw className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">
|
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">
|
||||||
{fmtBigNum(info?.rows ?? 0)}
|
{fmtBigNum(info?.rows ?? 0)}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
|
|||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import { fmtBigNum } from '@/lib/format'
|
import { fmtBigNum } from '@/lib/format'
|
||||||
|
import { DatePicker } from '@/components/DatePicker'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||||
import { toast } from '@/components/Toast'
|
import { toast } from '@/components/Toast'
|
||||||
@@ -67,8 +68,7 @@ function fmtArchivedAt(iso: string): string {
|
|||||||
|
|
||||||
export function Review() {
|
export function Review() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
// 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState)
|
const [asOf, setAsOf] = useState('')
|
||||||
const asOf: string | undefined = undefined
|
|
||||||
const [focus, setFocus] = useState('')
|
const [focus, setFocus] = useState('')
|
||||||
// 生成状态走全局 store:切走页面流不中断,回来可恢复
|
// 生成状态走全局 store:切走页面流不中断,回来可恢复
|
||||||
const { phase, content, error, meta } = useReviewState()
|
const { phase, content, error, meta } = useReviewState()
|
||||||
@@ -77,8 +77,8 @@ export function Review() {
|
|||||||
|
|
||||||
// 看板数据(与总览页同源)
|
// 看板数据(与总览页同源)
|
||||||
const marketQuery = useQuery<OverviewMarket>({
|
const marketQuery = useQuery<OverviewMarket>({
|
||||||
queryKey: QK.overviewMarket(asOf),
|
queryKey: QK.overviewMarket(asOf || undefined),
|
||||||
queryFn: () => api.overviewMarket(asOf),
|
queryFn: () => api.overviewMarket(asOf || undefined),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
})
|
})
|
||||||
@@ -138,7 +138,7 @@ export function Review() {
|
|||||||
if (isReviewGenerating()) return
|
if (isReviewGenerating()) return
|
||||||
setViewing(null)
|
setViewing(null)
|
||||||
resetReview()
|
resetReview()
|
||||||
startReviewGeneration(asOf, focus, (full, doneMeta) => {
|
startReviewGeneration(asOf || undefined, focus, (full, doneMeta) => {
|
||||||
onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ })
|
onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ })
|
||||||
})
|
})
|
||||||
}, [asOf, focus, onGenerationDone])
|
}, [asOf, focus, onGenerationDone])
|
||||||
@@ -190,6 +190,7 @@ export function Review() {
|
|||||||
subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
|
subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
|
||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
|
||||||
<button
|
<button
|
||||||
onClick={() => { marketQuery.refetch() }}
|
onClick={() => { marketQuery.refetch() }}
|
||||||
disabled={marketQuery.isFetching}
|
disabled={marketQuery.isFetching}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
|
|||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { Screener } from './pages/Screener'
|
import { Screener } from './pages/Screener'
|
||||||
import { Financials } from './pages/Financials'
|
import { Financials } from './pages/Financials'
|
||||||
import { Onboarding } from './pages/Onboarding'
|
|
||||||
import { Auth } from './pages/Auth'
|
import { Auth } from './pages/Auth'
|
||||||
import { Data } from './pages/Data'
|
import { Data } from './pages/Data'
|
||||||
import { Monitor } from './pages/Monitor'
|
import { Monitor } from './pages/Monitor'
|
||||||
@@ -19,47 +18,11 @@ import { UserManage } from './pages/UserManage'
|
|||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
import { Indices } from './pages/Indices'
|
import { Indices } from './pages/Indices'
|
||||||
import { Dev } from './pages/Dev'
|
import { Dev } from './pages/Dev'
|
||||||
import { useSettings } from './lib/useSharedQueries'
|
|
||||||
import { Logo } from './components/Logo'
|
|
||||||
|
|
||||||
// 首次使用守卫 —— 未完成向导则重定向到 /onboarding
|
|
||||||
// 只挂在根路由上;/onboarding 本身不被守卫,避免循环重定向。
|
|
||||||
// settings 由 Layout 预取,守卫判定不产生额外请求。
|
|
||||||
function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
// 仅首次加载(本地无缓存)时显示占位。
|
|
||||||
// 后台重取 (isFetching) 时本地已有上一份缓存可用, 直接放行, 避免切页时整屏 logo 闪烁。
|
|
||||||
// 防误重定向已由 Onboarding/AI 等处 invalidate 前的 setQueryData 同步缓存兜底。
|
|
||||||
if (settings.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-base grid place-items-center">
|
|
||||||
<div className="flex flex-col items-center gap-3 text-muted">
|
|
||||||
<Logo size={28} className="text-foreground" />
|
|
||||||
<div className="text-xs">加载中…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询出错或字段缺失时不拦截 —— 宁可放行,也不把用户卡在空白页
|
|
||||||
if (settings.data && settings.data.onboarding_completed === false) {
|
|
||||||
return <Navigate to="/onboarding" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{ path: '/onboarding', element: <Onboarding /> },
|
|
||||||
{ path: '/login', element: <Auth /> },
|
{ path: '/login', element: <Auth /> },
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: (
|
element: <Layout />,
|
||||||
<OnboardingGuard>
|
|
||||||
<Layout />
|
|
||||||
</OnboardingGuard>
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Dashboard /> },
|
{ index: true, element: <Dashboard /> },
|
||||||
{ path: 'overview', element: <Navigate to="/" replace /> },
|
{ path: 'overview', element: <Navigate to="/" replace /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user