Compare commits

..

16 Commits

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:58:31 +08:00
fish 4cd74b06f1 指数页移除日K来源文案
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:54:37 +08:00
fish ef6e308ad1 指数页移除分时数据面板,K线图占满
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:51:50 +08:00
31 changed files with 616 additions and 748 deletions
+49 -1
View File
@@ -1 +1,49 @@
# README
# A股智能量化工作台
本项目是一个面向个人散户与量化爱好者的 **A 股「选股 + 监控 + 回测」量化工作台**,采用前后端分离架构,支持 Docker 一键部署。
## 目录结构
```
stock/
├── local/ # 本地完整版(满血功能)
└── serve/ # 服务端轻量版(备份 + 基础查看)
```
### local/ — 本地完整版
自托管的满血工作站,所有功能本地运行:
- **选股**:20+ 内置策略,支持自定义信号与 AI 生成策略
- **回测**:基于 vectorbt 的向量化回测,支持 T+1、手续费、止损等真实约束
- **实时监控**:策略 / 个股信号 / 价格 / 异动四类规则,SSE 实时告警 + 飞书推送
- **个股分析**:日 K 图表、9 类关键价位、AI 四维分析
- **数据扩展**TickFlow 多源数据 + 第三方 HTTP / CSV / Excel 接入
详细说明与部署指南见 [`local/README.md`](./local/README.md)。
### serve/ — 服务端轻量版
`local/` 的云端伴侣,用于**数据备份**与**远程基础查看**:
- 接收 `local/` 推送的选股结果、复盘报告、历史 K 线等基础数据
- 支持多用户(admin / viewer)角色隔离
- 不包含回测、实时监控、实时行情等需要本地运行的能力
详细说明与部署指南见 [`serve/README.md`](./serve/README.md)。
## 快速选择
| 场景 | 使用目录 |
| :--- | :--- |
| 在自己的电脑 / 服务器上跑完整功能 | [`local/`](./local/README.md) |
| 只需要远程备份和查看基础数据 | [`serve/`](./serve/README.md) |
| 既要本地分析,又要远程查看 | 同时部署 `local/` + `serve/`,并通过 `SYNC_SERVE_URL` 同步 |
## 数据安全提示
两个目录下的 `data/` 文件夹均为运行时生成的用户数据,**不纳入 git 管理**。更新代码时 `git pull` 不会影响已有数据,但请勿使用 `git clean -fdx``git reset --hard`,以免误删数据。
## 开源协议
[MIT](./LICENSE)
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 tickflow-stock-panel contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+112 -106
View File
@@ -1,10 +1,10 @@
<div align="center">
# 📈 A股智能量化工作台
# 📈 A股智能量化工作台(本地完整版)
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
**自托管、满血功能的 A 股「选股 + 监控 + 回测」量化工作台**
**面向个人散户与量化爱好者而生**
**面向个人散户与量化爱好者,所有功能本地运行**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/)
@@ -17,26 +17,23 @@
<div align="center">
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[路线图](#-路线图)**
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[同步到服务端](#-同步到服务端)**
</div>
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
- 🇨🇳 **A 股专用**盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业
- 🆓 **开箱即用** — 留空 Key 即进 None 模式历史日 K 免费体验**无需付费**
- 🏠 **自托管零运维** — Docker 单容器部署数据完全掌握在自己手里
- 🔍 **三位一体** — 选股20 内置策略+ 实时监控 + 向量化回测Polars 毫秒级扫描全 A 股
- 🤖 **AI 加持** — 一句话生成策略代码任意 OpenAI 兼容接口均可接入留空即关闭
- 📡 **实时行情与监控** — 自选股实时行情、五档盘口、监控规则命中后 SSE 弹窗 + 飞书推送
- 🔌 **自由扩展**自有量化项目数据,与内置数据同台分析
- 🇨🇳 **A 股专用** — 盘后自动 AI 复盘并推送至飞书等;连板梯队、涨停动量、内置 ths 概念 / 行业
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
> ⚠️ 考虑到 tickflow 数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的 ths 概念/ths 行业(后续更新在这里)
>
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.comq群 109338242
觉得有用可以点个 Star,蟹蟹 🌹
@@ -44,7 +41,9 @@
## 🎯 项目定位
**面向个人散户与量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具
`local/` 目录下的版本是**本地完整版**,面向希望在自己的电脑或局域网中运行全部功能的用户
它包含完整的选股、回测、实时监控、个股分析、财务分析、连板梯队、概念/行业分析等全部能力,是项目功能的「满血」形态。同时支持把数据同步到远程 `serve/` 服务端,作为云端备份和基础数据查看入口。
---
@@ -96,19 +95,19 @@
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| `pnpm` | 9 | `npm i -g pnpm` |
### 方式 A:Dev 模式(二次开发推荐)
### 方式 ADev 模式二次开发推荐
```bash
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
cp .env.example .env # 按需填 TICKFLOW_API_KEY留空 = None 模式
./dev.sh # Windows: .\dev.ps1
```
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
自动检查 / 下载依赖、释放端口、同时起前后端Ctrl-C 一并关闭。默认
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
- 自定义端口`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
### 方式 B:Docker(部署最省心)
### 方式 BDocker部署最省心
```bash
cp .env.example .env
@@ -116,12 +115,14 @@ docker compose up --build
# 打开 http://localhost:3018
```
容器名:`TickFlow_Local`。默认端口 `3018`,数据持久化到 `./data/`
<details>
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
<summary><b>环境适配与高级选项老 CPU · 手动启动 · 回测依赖</b></summary>
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`
**老 CPU 兼容avx2/fma 缺失报错或 exit 132**桌面客户端安装包已内置兼容内核新老 CPU 通吃。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建会给 Polars 切到 `rtcompat` 运行时需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`
**手动分别启动:**
**手动分别启动**
```bash
# 后端
@@ -132,42 +133,42 @@ uv run uvicorn app.main:app --reload --port 3018
cd frontend && pnpm install && pnpm dev # http://localhost:3011
```
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
**回测依赖**vectorbt → numba 体积较大作为可选 extras`uv sync --extra backtest`。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
</details>
### 🔄 更新代码(已部署用户必读)
### 🔄 更新代码已部署用户必读
拉取新版本只需一条命令:
拉取新版本只需一条命令
```bash
git pull
```
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录乃至概念/行业扩展数据全部是程序运行时生成/拉取的用户数据`git pull` 物理上无法影响它们。新用户首次启动时概念/行业两份扩展数据会自动从远程接口拉取无需任何手动操作。
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
> ⚠️ **切勿使用以下命令"解决冲突"或"清理"它们会一次性删光 `data/` 下所有未被 git 跟踪的数据**
> - `git clean -fdx`最危险会删掉所有 `.gitignore` 忽略的文件
> - `git reset --hard`
> - 直接删除整个项目文件夹重新 `git clone`
>
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
> 若 `git pull` 报冲突通常是本地误改了被跟踪的文件请先 `git stash` 暂存再 pull或单独联系作者不要直接执行上面的命令。
### 🧭 跑起来后的第一次使用
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
2. **设置****立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
1. **设置 → 凭据与能力** → 点 **重新检测**确认档位标签
2. **设置****立即跑盘后管道**拉日 K + 计算 enriched 表None / Free 走 free-api当日数据盘后 1-2 小时可用
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细SSE 实时进度
5. **监控中心**配规则策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
---
## ✨ 核心功能
### 🔍 选股引擎(Screener)
### 🔍 选股引擎Screener
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
**20 个内置策略**每个策略一个独立 Python 文件基于 Polars 表达式向量化实现`backend/app/strategy/builtin/`):
| 类型 | 代表策略 |
| :---------- | :------------------------------------------------------- |
@@ -175,79 +176,81 @@ git pull
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
**扩展策略的三种方式:**
**扩展策略的三种方式**
| 方式 | 说明 |
| :---------------- | :---------------------------------------------------------------------------------------------------- |
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(`ast` 校验)→ 落入 `data/strategies/ai/` |
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
| **🎛️ 自定义信号** | 不写代码UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
| **🤖 AI 生成** | 一句话描述思路LLM 读 `strategy-guide.md` 生成完整策略文件`ast` 校验→ 落入 `data/strategies/ai/` |
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`引擎自动发现 |
### 📊 指标流水线(Indicators)
### 📊 指标流水线Indicators
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
原生 Polars 向量化全 A 股一次扫表落盘 enriched Parquet
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**:基于除权因子自动前复权,回测与指标口径一致
- **均线 / 趋势**MA5-60· EMA · MACD · 动量 · 布林带
- **震荡 / 波动**RSI · KDJ · ATR · 年化波动率 · 振幅
- **量能 / 涨跌停**量比 · 量均线 · 涨停信号 · 连板数
- **原子信号**MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**基于除权因子自动前复权回测与指标口径一致
### 🧪 回测引擎(Backtest)
### 🧪 回测引擎Backtest
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
基于 vectorbt**三种模式**个股 / 策略组合 / 自由信号组合),真实约束T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理最大持仓 · 敞口 · 等权 / 自定义仓位。SSE 流式进度支持切页重连输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
### 📡 监控中心(Monitor)
### 📡 监控中心Monitor
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
统一规则引擎一个页面管理**四类监控**策略 · 个股信号 · 价格涨跌 · 全市场异动):
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
- **触发记录详情**:每条记录展示命中的具体条件(`RSI>80`)与当前价位,一眼看清为何触发
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
- 多条件 AND/OR + 冷却期去重 + 严重级别info/warn/critical
- 多入口配置监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
- 命中后右下角弹窗可配声效+ 持久化到 `alerts.jsonl`菜单未读徽标
- **触发记录详情**每条记录展示命中的具体条件`RSI>80`与当前价位一眼看清为何触发
- **飞书 Webhook 推送**全局一处配置飞书群机器人地址启用推送的规则命中即推送到飞书群支持签名校验);可在设置页设「默认推送渠道」新建规则自动预填
- **SSE 实时推送**:本地运行时,命中告警通过 Server-Sent Events 实时推送到前端
### 📈 个股分析(Beta)
### 📈 个股分析Beta
以「行情 + 关键价位」为主体的单标的决策页:
以「行情 + 关键价位」为主体的单标的决策页
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
- **专用日 K 图表**主图 + 成交量 + 滑块默认近 6 个月
- **9 类关键价位**纯函数实时计算毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**技术 / 基本面 / 财务 / 消息面流式生成实战派交易员视角
### 🧰 数据与扩展
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
- **TickFlow 多源数据**日 K / 分钟 K / 指数 / 财务 / 实时行情
- **🔌 第三方接入重点**Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入自动 schema 发现 + 符号归一页面可视化配置**可与自有量化项目数据并入 DuckDB 同台分析**
- **盘后定时管道**APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
- **令牌桶限流**适配各档位 rpm / batch批量合并 + 增量拉取
---
## ⚙️ 配置
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
所有配置从根目录 `.env` 读取复制 `.env.example` 开始),也可在面板 **设置** 页修改。
### 数据源:TickFlow
### 数据源TickFlow
```ini
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
TICKFLOW_API_KEY= # 留空 = None 模式历史日K免费);填 Key = 按订阅档位解锁
```
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
留空即 None 模式通过 free-api 使用历史日 K当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式开启自选股实时监控。**实时行情按档位**
| 档位 | 实时能力 |
| :------- | :--------------------------------------- |
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
| None | 无实时行情,仅历史日 K |
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
| Starter+ | 全市场实时行情 |
| Pro | 分钟 K + 盘口 |
| Expert | WebSocket + 财务数据 |
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/)高等档位含较低档全部权益。
### AI(可选)
### AI可选
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
用于自然语言生成策略。**所有配置留空即跳过**不影响核心功能。支持任意 OpenAI 兼容接口
```ini
AI_PROVIDER=openai_compat # openai_compat | ollama
@@ -268,15 +271,33 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
### 访问密码
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
面板首次设置访问密码时出于安全考虑**仅允许本机或内网访问**防公网陌生人抢先设置锁死面板。公网服务器部署有两种方式设首个密码
1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取):
1. **环境变量预置推荐** — 在 `.env` 填入 `AUTH_PASSWORD`首次启动自动初始化哈希后写入 `auth.json`之后不再读取):
```ini
AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖
AUTH_PASSWORD=你的密码 # 至少 6 位仅首次生效已设过则不覆盖
```
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`浏览器开 `http://127.0.0.1:3018` 设密码
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI`设置 → 修改密码`
---
## 🔄 同步到服务端
本地完整版支持把核心数据同步到远程 `serve/` 服务端,作为:
- **异地备份**:防止本地磁盘损坏导致历史数据丢失
- **远程查看**:在手机或其他设备上通过浏览器查看基础行情与选股结果
配置方式:
```ini
SYNC_SERVE_URL=https://your-serve.example.com
SYNC_KEY=your-shared-secret
```
> 同步是**单向**的:本地 → 服务端。服务端只接收并展示基础数据,不包含本地版的实时行情、回测、监控等复杂能力。详细说明见 `../serve/README.md`。
---
@@ -285,12 +306,13 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
| 层 | 选型 |
| :----------- | :------------------------------------------------------------------------------------------------ |
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
| **回测** | vectorbt(全项目唯一 pandas 边界) |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
| **数据** | Polars计算· DuckDB查询· Parquet存储 |
| **回测** | vectorbt全项目唯一 pandas 边界 |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK |
| **AI**可选 | OpenAI 兼容接口DeepSeek / 通义 / Ollama 等 |
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
| **部署** | Docker 两阶段构建前端 dist 拷进后端镜像**单容器** |
| **桌面端** | PyInstaller + Inno SetupWindows 安装包) |
---
@@ -301,28 +323,12 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
| 6 | 个股分析专用日 K + 9 类关键价位 + AI 四维分析 | ✅ |
| **v2** | Webhook 推送QMT/掘金下单· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
---
## 📚 文档与贡献
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南AI 生成与手写规范
- [docs/](./docs) —— 策略构建步骤、示例
欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
---
## ⚠️ 免责声明
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
## 📄 License
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
## 社区
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 tickflow-stock-panel contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+132 -244
View File
@@ -1,94 +1,44 @@
<div align="center">
# 📈 A股智能量化工作台
# 📈 A股智能量化工作台(服务端备份/查看版)
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
**本地完整版的云端伴侣:数据备份 + 基础数据查看**
**面向个人散户与量化爱好者而生**
**面向已部署 `local/` 完整版的用户,提供远程备份与轻量查看能力**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/)
[![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/)
[![Data: TickFlow](https://img.shields.io/badge/Data-TickFlow-00b386.svg)](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
[![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile)
[![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](https://github.com/shy3130/tickflow-stock-panel/stargazers)
</div>
<div align="center">
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[路线图](#-路线图)**
**[快速开始](#-快速开始)** · **[功能范围](#-功能范围)** · **[接收本地同步](#-接收本地同步)** · **[部署说明](#-部署说明)**
</div>
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
- 🇨🇳 **A 股专用** — 盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
觉得有用可以点个 Star,蟹蟹 🌹
---
## 🎯 项目定位
**面向个人散户与量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具。
`serve/` 目录下的版本是 **服务端轻量版**,它不是 `local/` 的替代品,而是其**配套服务端**:
- **数据备份**:接收本地完整版推送的核心数据,防止本地磁盘故障导致历史数据丢失
- **远程查看**:在手机、平板或其他设备上通过浏览器查看基础行情、选股结果、复盘报告等
- **多用户隔离**:支持 admin / viewer 角色,适合家庭或小团队共享查看
**它不是满血版**,不包含实时行情、回测、实时监控、五档盘口、AI 策略生成等需要本地运行或消耗大量计算/Quota 的能力。所有复杂分析和决策功能,请在 `local/` 本地完整版中使用。
---
## 📸 界面预览
<table>
<tr>
<td width="50%" align="center"><b>看板 Dashboard</b></td>
<td width="50%" align="center"><b>策略 Screener</b></td>
</tr>
<tr>
<td width="50%"><img src="./screenshots/dashboard.png" alt="看板页面"></td>
<td width="50%"><img src="./screenshots/screener.png" alt="策略页"></td>
</tr>
<tr>
<td width="50%" align="center"><b>回测 Backtest</b></td>
<td width="50%" align="center"><b>监控中心 Monitor</b></td>
</tr>
<tr>
<td width="50%"><img src="./screenshots/backtest.png" alt="回测页"></td>
<td width="50%"><img src="./screenshots/monitor.png" alt="监控中心"></td>
</tr>
<tr>
<td width="50%" align="center"><b>连板梯队 Limit Ladder</b></td>
<td width="50%" align="center"><b>概念分析 Concept</b></td>
</tr>
<tr>
<td width="50%"><img src="./screenshots/limit-ladder.png" alt="连板梯队页"></td>
<td width="50%"><img src="./screenshots/concept-analysis.png" alt="概念分析"></td>
</tr>
</table>
<div align="center">
### 📸 [查看更多界面截图 »](./screenshots/README.md)
</div>
---
## 🚀 快速开始
## ⚡ 快速开始
### 前置依赖
`local/` 相同:
| 工具 | 版本 | 安装 |
| :--------------------------------- | :----- | :------------------------------------------------- |
| Python | ≥ 3.11 | [python.org](https://www.python.org/) |
@@ -96,19 +46,19 @@
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| `pnpm` | 9 | `npm i -g pnpm` |
### 方式 A:Dev 模式(二次开发推荐)
### 方式 ADev 模式
```bash
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
cp .env.example .env # 配置监听端口、同步密钥等
./dev.sh # Windows: .\dev.ps1
```
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
默认
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
- 自定义端口`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
### 方式 B:Docker(部署最省心)
### 方式 BDocker(推荐)
```bash
cp .env.example .env
@@ -116,213 +66,151 @@ docker compose up --build
# 打开 http://localhost:3018
```
<details>
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
容器名:`stock_panel`。默认端口 `3018`
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`
**手动分别启动:**
```bash
# 后端
cd backend && uv sync --extra backtest # 含回测依赖
uv run uvicorn app.main:app --reload --port 3018
# 前端
cd frontend && pnpm install && pnpm dev # http://localhost:3011
```
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
</details>
### 🔄 更新代码(已部署用户必读)
拉取新版本只需一条命令:
```bash
git pull
```
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
> - `git reset --hard`
> - 直接删除整个项目文件夹重新 `git clone`
>
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
### 🧭 跑起来后的第一次使用
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
2. **设置****立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
> 与 `local/` 不同,`serve/` 的 `data/` 目录**默认被 `.dockerignore` 忽略**,容器重启不会保留容器内的数据。请通过本地 `local/` 主动同步,或将 `data/` 挂载到持久化卷
---
## ✨ 核心功能
## 📦 功能范围
### 🔍 选股引擎(Screener)
| 功能 | serve/ 服务端 | 说明 |
| :--- | :---: | :--- |
| 看板 Dashboard | ✅ | 展示已同步的基础市场概况 |
| 选股结果查看 | ✅ | 查看 local/ 推送的选股结果 |
| 概念 / 行业分析 | ✅ | 查看已同步的概念/行业数据 |
| 复盘报告 | ✅ | 查看 local/ 生成的盘后 AI 复盘 |
| 指数 / 个股基础 K 线 | ✅ | 查看已同步的历史 K 线 |
| 多用户认证(admin/viewer) | ✅ | 支持多用户、角色隔离 |
| 数据同步接收 | ✅ | 接收 local/ 推送的数据 |
| 回测 | ❌ | 请在 local/ 中完成 |
| 实时监控 / SSE 告警 | ❌ | 请在 local/ 中使用 |
| 五档盘口 / 实时行情 | ❌ | 请在 local/ 中使用 |
| 自选实时监控 | ❌ | 请在 local/ 中使用 |
| AI 策略生成 | ❌ | 请在 local/ 中使用 |
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
### 适用场景
| 类型 | 代表策略 |
| :---------- | :------------------------------------------------------- |
| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 |
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
- **异地灾备**:local/ 每天收盘后自动把 enriched 数据、选股结果、复盘报告推送到 serve/
- **移动查看**:出差时用手机浏览器访问 serve/,查看本地已分析好的结果
- **家庭共享**:家人用 viewer 账号查看,admin 账号管理同步密钥
**扩展策略的三种方式:**
---
| 方式 | 说明 |
| :---------------- | :---------------------------------------------------------------------------------------------------- |
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
## 🔄 接收本地同步
### 📊 指标流水线(Indicators)
### 1. 服务端配置
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
`serve/.env` 中设置同步密钥(与 local/ 保持一致):
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**:基于除权因子自动前复权,回测与指标口径一致
```ini
SYNC_KEY=your-shared-secret
```
### 🧪 回测引擎(Backtest)
首次启动时建议通过 `AUTH_PASSWORD` 预置管理员密码:
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
```ini
AUTH_PASSWORD=你的密码
```
### 📡 监控中心(Monitor)
### 2. 本地端配置
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
`local/.env` 中填写服务端地址和密钥:
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
```ini
SYNC_SERVE_URL=https://your-serve.example.com
SYNC_KEY=your-shared-secret
```
### 📈 个股分析(Beta)
### 3. 触发同步
以「行情 + 关键价位」为主体的单标的决策页:
在 local/ 的「设置 → 数据同步」页面手动触发,或等待盘后定时任务自动推送。
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
### 4. 安全建议
### 🧰 数据与扩展
- 务必通过 HTTPS 暴露 serve/
- `SYNC_KEY` 应使用强随机字符串,并定期更换
- 不要对外开放 22 / 数据库等无关端口
- 若暴露在公网,务必先设置 `AUTH_PASSWORD`
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
---
## 🏗️ 部署说明
### 最小资源
| 资源 | 建议值 |
| :--- | :--- |
| CPU | 1 核 |
| 内存 | 512 MB |
| 磁盘 | 根据数据量,建议 ≥ 10 GB |
### 持久化
serve/ 的数据主要来自 local/ 同步。如果你希望容器重启后保留数据,请在 `docker-compose.yml` 中挂载持久卷:
```yaml
volumes:
- /your/host/data:/app/data
```
### 反向代理示例(Nginx
```nginx
server {
listen 443 ssl;
server_name your-serve.example.com;
location / {
proxy_pass http://127.0.0.1:3018;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
---
## ⚙️ 配置
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
### 数据源:TickFlow
```ini
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
# 同步密钥(必须和 local/.env 里的 SYNC_KEY 一致)
SYNC_KEY=your-shared-secret
# 服务端口
HOST=0.0.0.0
PORT=3018
# 首次管理员密码(仅首次启动生效,写入 auth.json 后不再读取)
AUTH_PASSWORD=你的密码
# 日志级别
LOG_LEVEL=INFO
# 数据目录(Docker 中建议挂载到宿主机)
DATA_DIR=./data
```
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
完整配置项参考 `serve/.env.example`
| 档位 | 实时能力 |
| :------- | :--------------------------------------- |
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
| Starter+ | 全市场实时行情 |
| Pro | 分钟 K + 盘口 |
| Expert | WebSocket + 财务数据 |
---
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
## 🗺️ 与 local/ 的关系
### AI(可选)
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
```ini
AI_PROVIDER=openai_compat # openai_compat | ollama
AI_BASE_URL=https://api.deepseek.com/v1
AI_API_KEY= # 留空 = 关闭 AI
AI_MODEL=deepseek-chat
AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
```
┌─────────────────────────────────────┐
│ local/ 本地完整版 │
│ 选股 · 回测 · 实时监控 · AI 分析 │
│ ↓ 定时/手动同步 │
└─────────────────────────────────────┘
▼ HTTPS + SYNC_KEY
┌─────────────────────────────────────┐
│ serve/ 服务端轻量版 │
│ 数据备份 · 基础查看 · 多用户 │
└─────────────────────────────────────┘
```
### 服务与数据
```ini
HOST=0.0.0.0 # 监听地址
PORT=3018 # 服务端口
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
DATA_DIR=./data # Parquet / DuckDB 数据存储目录
```
### 访问密码
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取):
```ini
AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖
```
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)。
---
## 🏗️ 技术栈
| 层 | 选型 |
| :----------- | :------------------------------------------------------------------------------------------------ |
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
| **回测** | vectorbt(全项目唯一 pandas 边界) |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
---
## 🗺️ 路线图
| Phase | 内容 | 状态 |
| :----- | :----------------------------------------------------------------- | :--- |
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
---
## 📚 文档与贡献
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
- [docs/](./docs) —— 策略构建步骤、示例
欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
---
## ⚠️ 免责声明
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
## 📄 License
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
## 社区
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
- **local/**:功能核心,承担所有计算、实时行情、回测、监控
- **serve/**:数据落地与轻量展示,不承担计算,不直接调用 TickFlow 高配额接口
+92 -4
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
import logging
import os
import tarfile
import tempfile
from io import BytesIO
from pathlib import Path
import polars as pl
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form
from app.config import settings
@@ -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:
"""简单的鉴权 — 校验 X-Sync-Key 头。"""
key = request.headers.get("X-Sync-Key", "")
@@ -105,6 +190,7 @@ async def upload_sync(
raise HTTPException(status_code=400, detail="上传文件为空")
extracted = 0
action_counter: dict[str, int] = {}
try:
with tarfile.open(fileobj=BytesIO(raw), mode="r:gz") as tar:
for member in tar.getmembers():
@@ -123,13 +209,15 @@ async def upload_sync(
with tar.extractfile(member) as src:
if src is None:
continue
target.write_bytes(src.read())
extracted += 1
ok, action = _merge_parquet(target, src.read())
if ok:
extracted += 1
action_counter[action] = action_counter.get(action, 0) + 1
except tarfile.TarError as exc:
raise HTTPException(status_code=400, detail=f"压缩包解析失败: {exc}") from exc
# 刷新缓存
_refresh_views(request)
logger.info("sync uploaded: parts=%s files=%d", parts, extracted)
return {"ok": True, "parts": sorted(requested), "file_count": extracted}
logger.info("sync uploaded: parts=%s files=%d actions=%s", parts, extracted, action_counter)
return {"ok": True, "parts": sorted(requested), "file_count": extracted, "actions": action_counter}
+1 -1
View File
@@ -125,7 +125,7 @@ async def analyze_financials(request: Request, req: AnalyzeRequest):
data_dir = request.app.state.repo.store.data_dir
async def stream_gen():
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus):
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
+1 -1
View File
@@ -55,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
async def stream_gen():
async for chunk in recap_market_stream(repo, as_of, req.focus):
async for chunk in recap_market_stream(repo, as_of, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
+1
View File
@@ -55,6 +55,7 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
async def stream_gen():
async for chunk in analyze_rotation_stream(
repo, days, req.focus,
username=request.state.username,
):
yield chunk + "\n"
+4 -3
View File
@@ -296,13 +296,14 @@ def get_cached(
@router.get("/market-snapshot")
def market_snapshot(request: Request):
"""最新全市场轻量行情快照,供板块/概念聚合分析使用。"""
def market_snapshot(request: Request, as_of: date | None = None):
"""全市场轻量行情快照(可选指定日期),供板块/概念聚合分析使用。"""
import polars as pl
repo = request.app.state.repo
svc = ScreenerService(repo)
as_of = svc.latest_date()
if as_of is None:
as_of = svc.latest_date()
if not as_of:
return {"as_of": None, "rows": []}
+26 -4
View File
@@ -49,14 +49,38 @@ class TickflowKeyIn(BaseModel):
def _get_ai_config(username: str | None, key: str, default: str = "") -> str:
"""读 AI 配置: 用户级优先, 无则全局。"""
"""读 AI 配置: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro。"""
if username:
val = secrets_store.load_ai_config(username).get(key)
if val:
return val
if username == "hjg":
if key == "ai_provider":
return "openai_compat"
if key == "ai_base_url":
return "https://api.deepseek.com/v1"
if key == "ai_model":
return "deepseek-chat"
if key == "ai_user_agent":
return (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
return secrets_store.get_ai_config(key, default)
def _get_ai_api_key(username: str | None) -> str:
"""读 AI API Key: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro Key。"""
if username:
val = secrets_store.load_ai_config(username).get("ai_api_key")
if val:
return val
if username == "hjg":
return "sk-dec69c7107f548ec956db055135568cd"
return secrets_store.get_ai_key()
@router.get("")
def get_settings(request: Request) -> dict:
"""返回当前配置概况(Key 脱敏)。"""
@@ -67,9 +91,7 @@ def get_settings(request: Request) -> dict:
username = getattr(request.state, "username", None)
key = secrets_store.get_tickflow_key()
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
if not ai_api_key:
ai_api_key = secrets_store.get_ai_key()
ai_api_key = _get_ai_api_key(username)
return {
"mode": tf_client.current_mode(),
"tickflow_api_key_masked": secrets_store.mask(key),
+1 -1
View File
@@ -164,7 +164,7 @@ async def analyze_stock(request: Request, req: AnalyzeRequest):
data_dir = repo.store.data_dir
async def stream_gen():
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
+2 -2
View File
@@ -345,7 +345,7 @@ async def build_strategy(req: BuildRequest, request: Request):
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
try:
result = await gen.generate(prompt)
result = await gen.generate(prompt, username=request.state.username)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return result
@@ -356,7 +356,7 @@ async def build_strategy(req: BuildRequest, request: Request):
async def ai_generate(req: AIGenerateRequest, request: Request):
try:
gen = AIStrategyGenerator()
result = await gen.generate(req.prompt)
result = await gen.generate(req.prompt, username=request.state.username)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
+81 -17
View File
@@ -25,16 +25,74 @@ Message = dict[str, str]
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
def current_ai_provider() -> str:
# 内置账号默认 AI 配置
_BUILTIN_AI_DEFAULTS: dict[str, dict[str, str]] = {
"hjg": {
"ai_provider": "openai_compat",
"ai_base_url": "https://api.deepseek.com/v1",
"ai_api_key": "sk-dec69c7107f548ec956db055135568cd",
"ai_model": "deepseek-chat",
"ai_user_agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
},
}
def _builtin_ai_config(username: str | None, key: str) -> str | None:
"""读取内置账号的默认 AI 配置。仅当用户未自行配置时作为回退。"""
if not username:
return None
defaults = _BUILTIN_AI_DEFAULTS.get(username)
if not defaults:
return None
return defaults.get(key)
def current_ai_provider(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_provider")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
def current_ai_model() -> str:
if current_ai_provider() == CODEX_CLI_PROVIDER:
def current_ai_model(username: str | None = None) -> str:
if current_ai_provider(username) == CODEX_CLI_PROVIDER:
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
# 用户未配置时使用内置默认值(不读 config.py 的默认模型,避免泄露通用配置)
builtin = _builtin_ai_config(username, "ai_model")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_model", settings.ai_model)
def current_ai_base_url(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_base_url")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_base_url", settings.ai_base_url) or ""
def current_ai_api_key(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_api_key")
if builtin:
return builtin
return secrets_store.get_ai_key() or ""
def current_ai_user_agent(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_user_agent")
if builtin:
return builtin
return (
secrets_store.get_ai_config("ai_user_agent", "")
or settings.ai_user_agent
or ""
)
def current_codex_command() -> str:
return normalize_codex_command(
secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command),
@@ -82,11 +140,11 @@ def codex_cli_available() -> bool:
return False
def ai_configured(provider: str | None = None) -> bool:
provider = provider or current_ai_provider()
def ai_configured(provider: str | None = None, username: str | None = None) -> bool:
provider = provider or current_ai_provider(username)
if is_codex_cli_provider(provider):
return codex_cli_available()
return bool(secrets_store.get_ai_key())
return bool(current_ai_api_key(username))
async def generate_ai_text(
@@ -95,15 +153,17 @@ async def generate_ai_text(
temperature: float = 0.3,
max_tokens: int = 3000,
timeout: float = 180.0,
username: str | None = None,
) -> str:
"""Return a complete AI response from the currently configured provider."""
if is_codex_cli_provider():
if is_codex_cli_provider(current_ai_provider(username)):
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
username=username,
)
@@ -113,13 +173,14 @@ async def stream_ai_text(
temperature: float = 0.5,
max_tokens: int = 4000,
timeout: float = 180.0,
username: str | None = None,
) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits.
"""
if is_codex_cli_provider():
if is_codex_cli_provider(current_ai_provider(username)):
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return
@@ -128,6 +189,7 @@ async def stream_ai_text(
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
username=username,
):
yield chunk
@@ -138,14 +200,15 @@ async def _run_openai_once(
temperature: float,
max_tokens: int,
timeout: float,
username: str | None = None,
) -> str:
ai_key = secrets_store.get_ai_key()
ai_key = current_ai_api_key(username)
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
client = _openai_client(ai_key, timeout, username=username)
resp = await client.chat.completions.create(
model=current_ai_model(),
model=current_ai_model(username),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
@@ -161,14 +224,15 @@ async def _stream_openai(
temperature: float,
max_tokens: int,
timeout: float,
username: str | None = None,
) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key()
ai_key = current_ai_api_key(username)
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
client = _openai_client(ai_key, timeout, username=username)
stream = await client.chat.completions.create(
model=current_ai_model(),
model=current_ai_model(username),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
@@ -181,13 +245,13 @@ async def _stream_openai(
yield delta.content
def _openai_client(api_key: str, timeout: float):
def _openai_client(api_key: str, timeout: float, username: str | None = None):
from openai import AsyncOpenAI
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
user_agent = current_ai_user_agent(username)
return AsyncOpenAI(
api_key=api_key,
base_url=normalize_openai_base_url(secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)),
base_url=normalize_openai_base_url(current_ai_base_url(username)),
timeout=timeout,
max_retries=2,
default_headers={"User-Agent": user_agent},
@@ -285,6 +285,8 @@ async def analyze_rotation_stream(
repo,
days: int = 12,
focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
@@ -329,7 +331,7 @@ async def analyze_rotation_stream(
try:
from app.services.ai_provider import stream_ai_text, ai_configured
if not ai_configured():
if not ai_configured(username=username):
yield json.dumps({
"type": "error",
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
@@ -344,6 +346,7 @@ async def analyze_rotation_stream(
],
temperature=0.5,
max_tokens=4000,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -141,6 +141,8 @@ async def analyze_financials_stream(
data_dir: Path,
symbol: str,
focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式分析:yield 出每个文本 chunk。
@@ -176,6 +178,7 @@ async def analyze_financials_stream(
],
temperature=0.4,
max_tokens=4000,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -255,6 +255,8 @@ async def recap_market_stream(
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式大盘复盘:yield 出每个 NDJSON 事件。
@@ -298,6 +300,7 @@ async def recap_market_stream(
],
temperature=0.5,
max_tokens=4500,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -251,6 +251,8 @@ async def analyze_stock_stream(
data_dir: Path,
symbol: str,
focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式个股分析:yield 出每个 NDJSON 事件。
@@ -298,6 +300,7 @@ async def analyze_stock_stream(
],
temperature=0.5,
max_tokens=4500,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
+4 -3
View File
@@ -50,7 +50,7 @@ class AIStrategyGenerator:
self._guide_cache = ""
return self._guide_cache
async def generate(self, user_prompt: str) -> dict:
async def generate(self, user_prompt: str, *, username: str | None = None) -> dict:
"""根据用户描述生成策略代码
Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None}
@@ -58,7 +58,7 @@ class AIStrategyGenerator:
guide = self._get_guide()
# 调用 LLM
code = await self._call_llm(user_prompt, guide)
code = await self._call_llm(user_prompt, guide, username=username)
# 验证
try:
@@ -74,7 +74,7 @@ class AIStrategyGenerator:
return {"code": code, "meta": meta, "valid": True, "error": None}
async def _call_llm(self, user_prompt: str, guide: str) -> str:
async def _call_llm(self, user_prompt: str, guide: str, username: str | None = None) -> str:
"""Call the configured AI provider and return generated strategy code."""
from app.services.ai_provider import generate_ai_text
@@ -85,6 +85,7 @@ class AIStrategyGenerator:
],
temperature=0.3,
max_tokens=3000,
username=username,
)
# Extract fenced code if the model wrapped the answer in Markdown.
if "```python" in content:
+1 -1
View File
@@ -2491,7 +2491,7 @@ all = [
[[package]]
name = "tickflow-stock-panel-backend"
version = "0.1.66"
version = "0.1.70"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
+1 -1
View File
@@ -7,7 +7,7 @@ services:
dockerfile: Dockerfile
args:
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
container_name: TickFlow_Stock_Panel
container_name: stock_panel
ports:
- "${PORT:-3018}:3018"
env_file:
+18
View File
@@ -28,6 +28,8 @@ import {
Moon,
Sun,
User,
Users,
Shield,
LogOut,
} from 'lucide-react'
import { Logo } from './Logo'
@@ -92,6 +94,7 @@ export function Layout() {
})
const username = authData?.username
const role = authData?.role
const navigate = useNavigate()
const qc = useQueryClient()
const [darkMode, setDarkMode] = useState(() => {
@@ -209,6 +212,21 @@ export function Layout() {
</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">
<button
+2 -2
View File
@@ -882,8 +882,8 @@ export const api = {
? `/api/screener/cached?ext_columns=${encodeURIComponent(extColumns)}`
: '/api/screener/cached',
),
marketSnapshot: () =>
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>('/api/screener/market-snapshot'),
marketSnapshot: (asOf?: string) =>
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>(`/api/screener/market-snapshot${asOf ? `?as_of=${asOf}` : ''}`),
overviewMarket: (asOf?: string) => request<OverviewMarket>(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`),
// 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序
+2 -7
View File
@@ -13,14 +13,14 @@ import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
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 { Logo } from '@/components/Logo'
import { cn } from '@/lib/cn'
export function Auth() {
const navigate = useNavigate()
const [username, setUsername] = useState('admin')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
const [showPwd, setShowPwd] = useState(false)
@@ -196,11 +196,6 @@ export function Auth() {
</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>
</div>
)
+8 -5
View File
@@ -15,6 +15,7 @@ import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { DatePicker } from '@/components/DatePicker'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -238,6 +239,7 @@ export function ConceptAnalysis() {
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [showRps, setShowRps] = useState(false)
const [asOf, setAsOf] = useState('')
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
@@ -249,14 +251,14 @@ export function ConceptAnalysis() {
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
queryKey: QK.extDataRows(activeConfigId, asOf || undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { date: asOf || undefined, limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
queryKey: ['market-snapshot', asOf],
queryFn: () => api.marketSnapshot(asOf || undefined),
staleTime: 60_000,
})
@@ -317,9 +319,10 @@ export function ConceptAnalysis() {
<>
<PageHeader
title="概念分析"
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
subtitle={`${asOf || marketQuery.data?.as_of || rowsQuery.data?.date || '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
{/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}
<button
onClick={() => setShowRps(true)}
+3 -8
View File
@@ -7,8 +7,7 @@ import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum } from '@/lib/format'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { useDataStatus, useSettings } from '@/lib/useSharedQueries'
import { SettingsModal } from '@/components/data/SettingsModal'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
@@ -371,11 +370,7 @@ export function Dashboard() {
placeholderData: (prev) => prev,
})
const data = overview.data
const caps = useCapabilities()
const settings = useSettings()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const sealedReady = !!data?.limit?.sealed_ready
const isSealedDegrade = !hasDepth || !sealedReady
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
const isNoKey = settings.data?.mode === 'none'
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
@@ -541,7 +536,7 @@ export function Dashboard() {
<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">{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={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" />
@@ -611,7 +606,7 @@ export function Dashboard() {
<aside className="min-w-0 space-y-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} />
</section>
<section className="rounded-card border border-border bg-surface/80 p-3">
+30 -174
View File
@@ -1,16 +1,14 @@
import { useState, useEffect } from 'react'
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, CheckCircle2, Hourglass, Lightbulb, ExternalLink } from 'lucide-react'
import { useState } from 'react'
import { Loader2, Search, FileText, Database, Lightbulb, ExternalLink, X } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities } from '@/lib/useSharedQueries'
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
import { useFinancialStatus } from '@/lib/useFinancials'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
import { LastStockChip } from '@/components/LastStockChip'
import { useLastStock } from '@/lib/useLastStock'
import { fmtBigNum } from '@/lib/format'
import { toast } from '@/components/Toast'
const TABLE_LABELS: Record<string, string> = {
metrics: '核心指标',
@@ -27,63 +25,32 @@ const TABLE_ICON: Record<string, typeof FileText> = {
}
export function Financials() {
const { data: caps } = useCapabilities()
const hasFinancial = caps?.capabilities?.['financial'] != null
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 [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
const pick = (symbol: string, name: string) => {
setSelected({ symbol, name })
rememberStock(symbol, name)
}
// 无 Expert 能力时: 等 status 加载完, 判断是否有从 local 同步来的数据
if (!hasFinancial) {
if (isLoading) {
return (
<>
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" />
<div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted" />
</div>
</>
)
}
if (!status?.available) {
// 既无 Expert 能力, 也无同步数据 → 锁定页
return (
<>
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" />
<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 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">
// 等 status 加载完, 判断是否有从 local 同步来的数据
if (isLoading) {
return (
<>
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析" />
<div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted" />
</div>
</>
)
}
if (!status?.available) {
// 无同步数据
return (
<>
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析" />
<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="flex items-center gap-1.5 text-xs font-medium text-accent">
<Lightbulb className="h-3.5 w-3.5 shrink-0" />
@@ -100,72 +67,15 @@ export function Financials() {
Issues
<ExternalLink className="h-3 w-3" />
</a>
</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 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 (
<>
@@ -175,40 +85,13 @@ export function Financials() {
right={
<div className="flex items-center gap-2">
<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 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 && (
@@ -219,49 +102,22 @@ export function Financials() {
const TIcon = TABLE_ICON[key] ?? Database
const hasData = (info?.rows ?? 0) > 0
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
const doneThisRound = tableDoneThisRound(key)
const isThisSyncing = currentSyncingTable === key
const isWaiting = isWaitingTable(key)
return (
<div
key={key}
className={`rounded-card border p-3.5 transition-colors flex flex-col ${
isThisSyncing
? 'border-accent/40 bg-accent/[0.04]'
: isWaiting
? 'border-border/50 bg-elevated/15'
: hasData
? 'border-border bg-surface'
: 'border-dashed border-border/60 bg-elevated/20'
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 gap-1.5">
{doneThisRound ? (
<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'}`} />
)}
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
<span className="text-xs font-medium text-foreground">{label}</span>
</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 className="mt-2 text-xl font-semibold tabular-nums text-foreground">
{fmtBigNum(info?.rows ?? 0)}
+15 -72
View File
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Activity, Lock, Search } from 'lucide-react'
import { api, type IndexInstrument, type KlineRow, type MinuteKlineRow } from '@/lib/api'
import { Activity, Search } from 'lucide-react'
import { api, type IndexInstrument, type KlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useCapabilities } from '@/lib/useSharedQueries'
import { EChartsCandlestick, type OHLC } from '@/components/EChartsCandlestick'
import { EChartsIntraday } from '@/components/EChartsIntraday'
function defaultRange() {
const now = new Date()
@@ -64,10 +63,6 @@ export function Indices() {
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
// 分时数据需 Pro+ (kline.minute.batch) 能力
const caps = useCapabilities()
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
const list = useQuery({
queryKey: QK.indexList,
queryFn: api.indexList,
@@ -108,23 +103,8 @@ export function Indices() {
placeholderData: (prev) => prev,
})
const minute = useQuery({
queryKey: QK.indexMinute(selectedSymbol, selectedDate ?? ''),
queryFn: () => api.indexMinute(selectedSymbol, selectedDate ?? undefined),
enabled: !!selectedSymbol && !!selectedDate && hasMinuteCap,
placeholderData: (prev) => prev,
})
const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
const selectedIdx = selectedDate ? chartRows.findIndex(r => r.date === selectedDate) : -1
const prevClose = selectedIdx > 0
? chartRows[selectedIdx - 1].close
: chartRows.length >= 2
? chartRows[chartRows.length - 2].close
: undefined
useEffect(() => {
setSelectedDate(null)
setLinkedPrice(null)
@@ -192,9 +172,6 @@ export function Indices() {
</h2>
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
</div>
<div className="mt-1 text-xs text-muted">
K来源 {daily.data?.source ?? '--'}
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<input
@@ -221,52 +198,18 @@ export function Indices() {
</div>
)}
{chartRows.length > 0 && (
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<EChartsCandlestick
data={chartRows}
height={620}
showMA={true}
showInfoBar={true}
showMarkers={false}
symbol={selectedSymbol}
linkedPrice={linkedPrice}
onDateClick={setSelectedDate}
visibleBars={48}
activeIndicators={['vol', 'macd']}
/>
</div>
<div className="min-w-0 flex-1 border-l border-border pl-3" style={{ height: 620 }}>
{!hasMinuteCap ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<Lock className="h-5 w-5 text-muted" />
<div className="text-xs text-secondary"> Pro+</div>
<div className="text-[10px] text-muted"></div>
</div>
) : (
<>
{minute.isLoading && <div className="py-2 text-xs text-muted"></div>}
{!minute.isLoading && minuteRows.length === 0 && (
<div className="flex h-full items-center justify-center text-xs text-muted">
</div>
)}
{minuteRows.length > 0 && (
<EChartsIntraday
data={minuteRows}
height={620}
prevClose={prevClose}
date={selectedDate ?? undefined}
symbol={selectedSymbol}
showLimitLines={false}
showAvgLine={false}
onPriceHover={setLinkedPrice}
/>
)}
</>
)}
</div>
</div>
<EChartsCandlestick
data={chartRows}
height={620}
showMA={true}
showInfoBar={true}
showMarkers={false}
symbol={selectedSymbol}
linkedPrice={linkedPrice}
onDateClick={setSelectedDate}
visibleBars={48}
activeIndicators={['vol', 'macd']}
/>
)}
</main>
</div>
+10 -5
View File
@@ -9,6 +9,7 @@ import {
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { DimensionHeatmap, type AnalysisFieldConfig } from '@/components/analysis-shared'
@@ -269,6 +270,7 @@ export function IndustryAnalysis() {
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [asOf, setAsOf] = useState('')
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
@@ -280,14 +282,14 @@ export function IndustryAnalysis() {
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
queryKey: QK.extDataRows(activeConfigId, asOf || undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { date: asOf || undefined, limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
queryKey: ['market-snapshot', asOf],
queryFn: () => api.marketSnapshot(asOf || undefined),
staleTime: 60_000,
})
@@ -367,7 +369,10 @@ export function IndustryAnalysis() {
<>
<PageHeader
title="行业分析"
subtitle={`${industryLevelLabel} · ${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
subtitle={`${industryLevelLabel} · ${asOf || marketQuery.data?.as_of || rowsQuery.data?.date || '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
right={
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(245,158,11,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
+6 -5
View File
@@ -19,6 +19,7 @@ import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import { fmtBigNum } from '@/lib/format'
import { DatePicker } from '@/components/DatePicker'
import { PageHeader } from '@/components/PageHeader'
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
import { toast } from '@/components/Toast'
@@ -67,8 +68,7 @@ function fmtArchivedAt(iso: string): string {
export function Review() {
const qc = useQueryClient()
// 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState)
const asOf: string | undefined = undefined
const [asOf, setAsOf] = useState('')
const [focus, setFocus] = useState('')
// 生成状态走全局 store:切走页面流不中断,回来可恢复
const { phase, content, error, meta } = useReviewState()
@@ -77,8 +77,8 @@ export function Review() {
// 看板数据(与总览页同源)
const marketQuery = useQuery<OverviewMarket>({
queryKey: QK.overviewMarket(asOf),
queryFn: () => api.overviewMarket(asOf),
queryKey: QK.overviewMarket(asOf || undefined),
queryFn: () => api.overviewMarket(asOf || undefined),
staleTime: 5_000,
placeholderData: (prev) => prev,
})
@@ -138,7 +138,7 @@ export function Review() {
if (isReviewGenerating()) return
setViewing(null)
resetReview()
startReviewGeneration(asOf, focus, (full, doneMeta) => {
startReviewGeneration(asOf || undefined, focus, (full, doneMeta) => {
onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ })
})
}, [asOf, focus, onGenerationDone])
@@ -190,6 +190,7 @@ export function Review() {
subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
right={
<div className="flex items-center gap-1">
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
<button
onClick={() => { marketQuery.refetch() }}
disabled={marketQuery.isFetching}
+1 -38
View File
@@ -2,7 +2,6 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
import { Layout } from './components/Layout'
import { Screener } from './pages/Screener'
import { Financials } from './pages/Financials'
import { Onboarding } from './pages/Onboarding'
import { Auth } from './pages/Auth'
import { Data } from './pages/Data'
import { Monitor } from './pages/Monitor'
@@ -19,47 +18,11 @@ import { UserManage } from './pages/UserManage'
import { Settings } from './pages/Settings'
import { Indices } from './pages/Indices'
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([
{ path: '/onboarding', element: <Onboarding /> },
{ path: '/login', element: <Auth /> },
{
path: '/',
element: (
<OnboardingGuard>
<Layout />
</OnboardingGuard>
),
element: <Layout />,
children: [
{ index: true, element: <Dashboard /> },
{ path: 'overview', element: <Navigate to="/" replace /> },