diff --git a/.gitignore b/.gitignore index 97d58fa..9e289f2 100644 --- a/.gitignore +++ b/.gitignore @@ -45,10 +45,14 @@ backend/data/** !backend/data/.gitkeep data/** !data/.gitkeep -# 扩展数据: 全部纳入版本控制(含 config.json 表结构 + part.parquet 数据文件) -# 注意 gitignore 否定规则特性: 需先放行目录才能放行内部文件 +# 扩展数据: 仅放行 ext_gn_ths(概念)与 ext_hy_ths(行业)两个数据源 +# 含 config.json 表结构 + part.parquet 数据文件;其余 ext_data 子目录均不入库 +# (gitignore 否定规则特性: 要放行嵌套文件,需逐级放行目录 + /** 放行内部文件) !data/ext_data/ -!data/ext_data/** +!data/ext_data/ext_gn_ths/ +!data/ext_data/ext_gn_ths/** +!data/ext_data/ext_hy_ths/ +!data/ext_data/ext_hy_ths/** # ===== Secrets ===== .env diff --git a/README.md b/README.md index 867e173..f3b53ed 100644 --- a/README.md +++ b/README.md @@ -11,32 +11,24 @@ [![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) -基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据 · 🚀 **开箱即用**(单容器 / None 模式) -能力驱动,适配 None → Expert 全档位订阅 · 🔌 **自由接入第三方扩展数据**(例如 Tushare、自有量化项目数据) - -**[核心功能](#-核心功能)** · **[快速开始](#-快速开始)** · **[配置](#️-配置)** · **[路线图](#-路线图)** +**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[路线图](#-路线图)** -> **⚠️说明**:目前项目基于[TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA)数据源。无需付费即可使用策略+回测等功能.自有个性化数据源可接入扩展数据使用。 +- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费** +- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里 +- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股 +- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭) +- 🔌 **自由扩展** — 接入 Tushare(开发中) / 自有量化项目数据,与内置数据同台分析 +- 🇨🇳 **A 股专用** — 连板梯队、涨停动量、内置ths 概念 / 行业 - 如果觉得有用,请Star支持一下,蟹蟹🌹 +> 基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。 +觉得有用可以点个 Star,蟹蟹 🌹 --- -## 🎯 项目定位 - -让任何**个人散户 / 量化爱好者**,**零运维**地拥有一套**属于自己策略定制**的 A 股分析、选股、监控工作台。 -基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) Key **零成本**获取数据,并使用策略定制+监控+回测功能。 -**内置ths概念、ths行业数据**。可接入第三方自有个性化扩展数据(人气、资金流向等)。 - -**项目所需配置**: - -| 配置项 | 说明 | 是否必填 | -| :--- | :--- | :--- | -| **TickFlow API Key** | 数据源凭证,留空启用 None 模式;免费注册 Key 后进入 Free 模式,可使用历史日K与自选股实时监控 | 可选 | -| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析、财务分析等,任意 OpenAI 兼容接口,留空关闭 | 可选 | +## 📸 界面预览 @@ -44,140 +36,32 @@ - - + + - - - + + - + - - + +
策略 Screener
看板页面策略页看板页面策略页
回测 Backtest 监控中心 Monitor
回测页监控中心回测页监控中心
连板梯队 Limit Ladder概念分析 Concept概念分析 Concept
连板梯队页概念分析连板梯队页概念分析
-> ### ⚠️ 🚧 项目持续优化,功能陆续开放,敬请期待。 +
-> **明确不做**:不对标同花顺/通达信的全功能股票软件;不内置任何「AI 荐股 / 涨停预测」。 +### 📸 [查看更多界面截图 »](./screenshots/README.md) ---- - -## ✨ 核心功能 - -### 🔍 选股引擎(Screener) - -**20 个内置策略** —— 每个策略是一个独立 Python 文件(`backend/app/strategy/builtin/`),基于 Polars 表达式实现: - -| 类型 | 代表策略 | -| :--- | :--- | -| 趋势 | 趋势突破 · 均线多头 · 缩量回踩 | -| 形态 | MA 金叉 · MACD 金叉放量 · 布林突破 | -| 量价 | 量价齐升 · 高换手强势 · 强势高开 | -| 涨停 | 连板股 · 断板反包 · 逼近涨停 · 涨停动量 | -| 反转 | 超跌反弹 · 超卖反转 · 新低反转 | -| 波动 | 低波动龙头 · 回踩 MA20 反弹 | - -- **自定义信号系统** —— 在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,**无需写代码**即可定义自己的买卖信号。 -- **策略商店** —— 内置策略 + 用户自定义策略统一管理,支持参数覆盖(`params` 暴露阈值)。 - -#### ➕ 添加自己的策略 - -除 20 个内置策略外,你可以用两种方式扩展: - -| 方式 | 说明 | 前提 | -| :--- | :--- | :--- | -| **🤖 AI 生成** | 用自然语言描述策略思路,LLM 读取 [strategy-guide.md](./docs/strategy-guide.md) 自动生成完整 Polars 策略文件(经 `ast` 安全校验,限定 `import polars as pl`)。生成后落入 `data/strategies/ai/`,即刻可用 | 需先在 [配置](#️-配置) 中填入 AI Key | -| **📝 代码自定义 / 策略迁移** | 参照 [策略开发指南](./docs/strategy-guide.md) 的文件结构模板,把你**已有的自有策略**改写为 Polars 文件放入 `data/strategies/custom/`(文件名/ID 建议 `custom_时间戳`),引擎自动发现加载——**轻松迁移你现成的量化项目策略**,无需从头重写 | 无 | -| **🎛️ 自定义信号配置** | 不写代码,在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,即可定义自己的买卖信号 | 无 | - -> 引擎按 `source` 标记来源:`builtin`(内置)/ `custom`(手写或迁移)/ `ai`(生成),三者统一进入策略商店管理。 - -### 📊 指标流水线(Indicators) - -原生 Polars 向量化计算,全 A 股一次扫表落盘为 enriched Parquet: - -| 分类 | 指标 | -| :--- | :--- | -| 均线系 | MA(5/10/20/30/60)· EMA(5/10/12/20/26/30/60) | -| 趋势系 | MACD(DIF/DEA/HIST)· 动量(5/10/20/30/60d)· 布林带(上/下轨) | -| 震荡系 | RSI(可配周期)· KDJ(K/D/J) | -| 波动系 | ATR(14)· 年化波动率(20d)· 振幅 | -| 量能系 | 量比(5d/10d)· 量均线 | -| 涨跌停 | 涨停信号 · 连板数 · 涨跌幅 · 涨跌额 | -| 原子信号 | MA 金叉/死叉 · MA20 突破/跌破 · MACD 金叉/死叉 · N 日新高/新低 · 布林突破 | -| 复权 | 基于除权因子自动计算前复权(`ex_factor` / `cum_factor`),回测与指标一致 | - -### 🧪 回测引擎(Backtest) - -基于 vectorbt: - -- **三种回测模式**:个股 · 策略组合 · 自由信号组合 -- **真实约束**:T+1 · 手续费 · 滑点(基点) · 止损 · 最大持仓天数 -- **组合管理**:最大持仓数 · 最大敞口 · 等权 / 自定义仓位 -- **SSE 流式进度**:长任务实时推送进度,支持刷新 / 切页后**重连恢复**(相同参数任务只启动一次) -- **统计输出**:净值曲线 · 夏普 · 最大回撤 · 胜率 · 每笔交易明细 - -### 📡 监控中心(Monitor) - -**统一监控规则引擎** —— 一个页面管理所有类型的监控,实时推送 + 持久化触发记录: - -- **四类监控**:策略监控 · 个股信号监控(选信号即加) · 个股价格/涨跌监控 · 全市场异动监控 -- **灵活条件**:多条件 AND/OR 组合 + 冷却期去重(防刷屏) + 严重级别(info/warn/critical) -- **多入口配置**:监控中心页面新建规则 · 个股详情页「加监控」· 策略卡片一键开启 -- **实时 SSE 推送**:命中规则后右下角弹窗通知(可配声效) + 持久化到 `alerts.jsonl` -- **触发记录**:时间倒序展示,支持按来源过滤 · 单条删除 · 清空 · 点击查看个股日K -- **菜单未读徽标**:离开监控中心后有新触发,菜单显示未读数;进入页面后清零 - -### 🤖 AI 策略生成(可选) - -- **自然语言 → 策略代码**:用一句话描述策略思路,LLM 读取 `docs/strategy-guide.md` 生成完整 Polars 策略文件 -- **沙箱约束**:生成代码经 `ast` 校验、限定 `import polars as pl`,避免逐行循环,优先向量化表达 -- **可插拔**:留空 AI 配置即跳过整个模块,不影响核心功能 - -### 📈 个股分析(Beta) - -**以「行情 + 关键价位」为视觉主体的单标的决策页**,与「财务分析」(财务质量评级)定位互补: - -- **专用日 K 图表**:不复用行情浏览图表,主图 + 成交量 + 滑块三段布局,默认展示近 6 个月,9 类关键价位可逐组开关 -- **9 类关键价位**(均纯函数实时计算,毫秒级): - -| 类型 | 算法 | 说明 | -| :--- | :--- | :--- | -| 压力支撑 | 布林带上下轨 | 近期波动边界 | -| 成交密集区 | 成交量分布 POC + 高成交带 | 筹码密集价位 | -| 枢轴点 | 经典 Pivot P/R1~R3/S1~S3 | 可配档位(1~3 档) | -| 前高前低 | 60/250 日极值 + swing 高低点 | 历史转折参照 | -| Keltner 通道 | MA20/60/120 ± n×ATR(短/中/长) | 波动自适应趋势边界 | -| ATR 止损 | close ± 1.5/2×ATR | 动态止盈止损位 | -| 缺口位 | 近 120 日未回补跳空缺口 | 天然支撑/阻力 | -| 斐波那契 | 近期波段 0.236~0.786 回撤 | 经典回撤位 | -| 整数关口 | 当前价附近心理整数位 | 自适应步长 | - -- **AI 四维分析**:技术面 / 基本面 / 财务面 / 消息面流式生成,NDJSON 推送,「实战派交易员」视角输出买卖区间与操作建议 -- **蓝色胶囊**:与财务分析(紫色)并存的全局气泡,支持最小化后台生成、复制全文、历史报告(最多 50 条) -- **记忆最近查看**:进入页面自动回显上次查看的个股 - -### 🧰 数据与扩展 - -- **多源数据**:TickFlow 日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 实时行情 -- **实时行情分档**:None 仅历史日K(当日数据通常盘后 1-2 小时可用);Free 可监控自选页前 5 个标的(最低 6 秒刷新);Starter+ 使用全市场实时行情 -- **🔌 第三方数据接入(重点)** —— TickFlow 之外的数据也能用: - - 支持 **Tushare** 等第三方数据源,通过 **HTTP 定时拉取**自动入库 - - 支持 **CSV / Excel 上传** · **JSON 写入**,自动 schema 发现与符号归一 - - **页面可视化配置**扩展数据表,无需改代码 - - 可接入**你自己的量化项目数据**,统一并入 DuckDB 查询面,与内置数据同台分析 -- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched 表 + 跑监控 -- **令牌桶限流**:适配各档位 rpm / batch 上限,批量合并 + 增量拉取,同一份数据多面板复用 +
--- @@ -185,119 +69,146 @@ ### 前置依赖 -| 工具 | 版本 | 安装 | -| :--- | :--- | :--- | -| Python | ≥ 3.11 | [python.org](https://www.python.org/) | -| Node | ≥ 20 | [nodejs.org](https://nodejs.org/) | +| 工具 | 版本 | 安装 | +| :--------------------------------- | :----- | :------------------------------------------------- | +| Python | ≥ 3.11 | [python.org](https://www.python.org/) | +| Node | ≥ 20 | [nodejs.org](https://nodejs.org/) | | [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` | -| `pnpm` | 9 | `npm i -g pnpm` 或 `corepack enable && corepack prepare pnpm@9 --activate` | +| `pnpm` | 9 | `npm i -g pnpm` | - - -### 方式 A:Dev 模式(二次开发,最推荐) +### 方式 A:Dev 模式(二次开发推荐) ```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 一并关闭。默认: -| 平台 | 命令 | -| :--- | :--- | -| **macOS / Linux** | `./dev.sh` | -| **Windows (PowerShell)** | `.\dev.ps1` | +- 后端 → · 前端 → +- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh` - - -### 方式 B:Docker(最省心,可部署) +### 方式 B:Docker(部署最省心) ```bash -cp .env.example .env # 按需填写 Key(留空即 None 模式,可直接体验历史日K) +cp .env.example .env docker compose up --build # 打开 http://localhost:3018 ``` -如果你的 VPS / 物理机 CPU 较老,`docker compose up --build` 时出现类似 -`Missing required CPU features: avx2, fma, bmi1...` 或容器 `exit code 132`, -先在 `.env` 里打开 Polars 兼容运行时再重建: - -```ini -BACKEND_EXTRAS=legacy-cpu -# 需要回测可同时启用(空格分隔):BACKEND_EXTRAS=legacy-cpu backtest -``` - -```bash -docker compose up --build -``` - -`legacy-cpu` 会让后端安装 `polars[rtcompat]`,适用于不支持 AVX2/FMA 的老 CPU; -默认留空则保持 Polars 高性能运行时。该 wheel 约 50MB,启用后镜像体积会相应增大。 - - -首次运行会自动安装前后端依赖(约 1-2 分钟),之后直接启动: - -- 后端 → -- 前端 → - -自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`(Windows:`.\dev.ps1 -BackendPort 8000 -FrontendPort 5173`) -
-手动分别启动(备选) +环境适配与高级选项(老 CPU · 手动启动 · 回测依赖) + +**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`。 + +**手动分别启动:** ```bash -# 终端 1:后端 -cd backend -uv sync +# 后端 +cd backend && uv sync --extra backtest # 含回测依赖 uv run uvicorn app.main:app --reload --port 3018 -# 终端 2:前端 -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` 现场编译。 +
-> **启用回测**:`cd backend && uv sync --extra backtest` -> vectorbt → numba 体积较大,故作为可选 extras。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。 +### 🧭 跑起来后的第一次使用 -> **老 CPU 兼容**:`cd backend && uv sync --extra legacy-cpu` -> 当机器缺少 `avx2/fma` 等指令集时,给 Polars 切到 `rtcompat` 运行时。 +1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签 +2. **设置** → **立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用) +3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号 +4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度) +5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录 --- -## 🧭 第一次使用 +## ✨ 核心功能 -1. 打开面板 → **设置 → 凭据与能力** → 点 **重新检测**,确认 Tier Label -2. 点 **立即跑盘后管道** —— 拉日 K + 计算 enriched 表 - - **None / Free 用户**:历史日K走 free-api 通道;当日数据通常盘后 1-2 小时可用 - - **Starter+**:同步全 A 或可获取的 instruments 列表 -3. **自选**页:添加跟踪标的;Free 档实时行情会自动监控自选页前 5 个标的,可用「移到顶部」调整优先级;点代码进 **K 线**页看蜡烛图 + 买卖点 -4. **选股**页:点任一内置策略卡片即时扫描;或用自定义信号组合条件 -5. **回测**页:选策略 / 信号 + 时间区间 → 跑回测 → 看净值 / 夏普 / 交易明细(SSE 实时进度) -6. **监控中心**页:配置监控规则(策略/个股信号/价格/市场异动),盘中 SSE 实时弹窗通知 + 持久化触发记录;或在个股详情页点「加监控」快速添加 +### 🔍 选股引擎(Screener) + +**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`): + +| 类型 | 代表策略 | +| :---------- | :------------------------------------------------------- | +| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 | +| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 | +| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 | + +**扩展策略的三种方式:** + +| 方式 | 说明 | +| :---------------- | :---------------------------------------------------------------------------------------------------- | +| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 | +| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` | +| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 | + +### 📊 指标流水线(Indicators) + +原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet: + +- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带 +- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅 +- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数 +- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破 +- **复权**:基于除权因子自动前复权,回测与指标口径一致 + +### 🧪 回测引擎(Backtest) + +基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。 + +### 📡 监控中心(Monitor) + +统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动): + +- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical) +- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启 +- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标 + +### 📈 个股分析(Beta) + +以「行情 + 关键价位」为主体的单标的决策页: + +- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月 +- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口 +- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角 + +### 🧰 数据与扩展 + +- **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 提供订阅制 A 股数据。**留空 `TICKFLOW_API_KEY` 即启用 None 模式,可通过 free-api 使用历史日K;当日数据通常需盘后 1-2 小时可用**。免费注册并填写 Key 后进入 Free 模式,可开启自选股实时监控。 - ```ini -TICKFLOW_API_KEY= # 留空 = None 模式;填入 Key = 按订阅档位解锁 +TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁 ``` -> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/)。系统启动时会自动探测你的真实能力集,UI 显示「Free / Starter / Pro / Expert」等友好标签。高等档位包含较低档位的全部权益。 -> -> 当前面板使用的实时能力:Free = 自选页前 5 个标的实时监控(最低 6 秒刷新);Starter+ = 全市场实时行情;Pro = 分钟K + 盘口;Expert = WebSocket + 财务数据。 +留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**: -### AI(可选):策略生成 +| 档位 | 实时能力 | +| :------- | :--------------------------------------- | +| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) | +| Starter+ | 全市场实时行情 | +| Pro | 分钟 K + 盘口 | +| Expert | WebSocket + 财务数据 | -AI 模块用于「自然语言生成策略代码」。**所有配置留空即跳过 AI 功能,不影响核心使用**。支持任何 **OpenAI 兼容接口**: +> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。 + +### AI(可选) + +用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口: ```ini AI_PROVIDER=openai_compat # openai_compat | ollama @@ -307,8 +218,6 @@ AI_MODEL=deepseek-chat AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限 ``` -> 切换 `AI_PROVIDER=ollama` 时无需 `AI_API_KEY`,适合本地部署大模型。 - ### 服务与数据 ```ini @@ -322,50 +231,36 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录 ## 🏗️ 技术栈 -| 层 | 选型 | -| :--- | :--- | -| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette | -| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储)· PyArrow | -| **回测** | vectorbt(全项目唯一 pandas 边界) | -| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK(`tickflow[all]`) | -| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) | -| **前端** | React 18 · Vite · TypeScript · Tailwind CSS · Framer Motion · Tanstack Query · Lightweight Charts · ECharts · dnd-kit | -| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** | +| 层 | 选型 | +| :----------- | :------------------------------------------------------------------------------------------------ | +| **后端** | 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** | 仓库骨架 / FastAPI 壳 / Vite + React SPA / Docker 一键起 | ✅ | -| **1** | 能力探测 + Kline 同步 + K 线分析页 | ✅ | -| **2** | Polars enriched 流水线 + Screener + 信号扫描 | ✅ | -| **3** | vectorbt 回测 + T+1 + 手续费 + 止损 + max-hold | ✅ | -| **4** | 监控引擎 + 告警规则 + Webhook + APScheduler 盘后定时 | ✅ | -| **5** | 统一监控中心 + 四类监控规则 + 实时推送 + 持久化触发记录 + 声效通知 | ✅ | -| **6** | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析 + 报告持久化) | ✅ | -| **v2** | Webhook 推送(QMT/掘金下单) · 板块异动 · 早晚报 · 更多扩展 | 🚧 | +| 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/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范) - [docs/](./docs) —— 策略构建步骤、示例 ---- - -## 🤝 贡献 - -欢迎 Issue 和 PR。本地开发: - -```bash -cd backend && uv sync --extra backtest # 含回测依赖 -cd ../frontend && pnpm install && pnpm dev -``` - -新增内置策略:在 `backend/app/strategy/builtin/` 参照现有策略文件,实现 `StrategyDef` 即可被引擎自动发现。 +欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。 --- @@ -373,13 +268,9 @@ cd ../frontend && pnpm install && pnpm dev 本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。 ---- - ## 📄 License -[MIT](./LICENSE) © tickflow-stock-panel contributors - -本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。 +[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。 ## 社区 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 31c4976..27d020f 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,7 +2,7 @@ import sys -__version__ = "0.1.60" +__version__ = "0.1.61" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/backend/app/services/market_recap.py b/backend/app/services/market_recap.py index 6c5ef3d..eb4dc2a 100644 --- a/backend/app/services/market_recap.py +++ b/backend/app/services/market_recap.py @@ -24,6 +24,15 @@ from app.services.market_overview_builder import build_market_overview logger = logging.getLogger(__name__) +# 指数简称映射:摘要里用简称(上/深/创/科),全称太长列表放不下。与前端 INDEX_SHORT 对齐。 +_INDEX_SHORT = { + "上证指数": "上", + "深证成指": "深", + "创业板指": "创", + "科创综指": "科", + "科创50": "科", +} + # ================================================================ # 系统提示词(市场策略师人格 + 固定七节模板) # ================================================================ @@ -54,7 +63,7 @@ _SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深 成交额结构(增量/存量)、市场宽度(上涨占比、站上均线占比)、量能指标(量比)解读;风险偏好是修复还是转弱。 ### 6. 📰 消息催化 -结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动。明确区分"已兑现"与"待发酵"。**若提供了"无新闻数据"的说明,则本节基于量价异动进行[推断],并如实标注,不要编造具体消息。** +结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动,明确区分"已兑现"与"待发酵"。**若无新闻数据,则直接从量价异动推断可能的催化逻辑并给出结论,不要标注"[推断]"之类的过程标签,更不要编造具体消息。** ### 7. 🎯 明日交易计划 - 进攻 / 均衡 / 防守:基于今日盘面给出次日基调 @@ -68,6 +77,7 @@ _SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深 ## 分析准则(务必遵守) +0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤或方法论。不要写"我先按...做结构化复盘""接下来看...""基于上述数据我认为"这类元话语——直接给结论。读者要的是复盘结果,不是你怎么推导出来的。 1. **数据说话**:每个判断引用具体数值,严禁空泛套话("情绪回暖"必须改成"涨停 68 家较前日 +22,封板率 75%") 2. **诚实中立**:看多就写多,看空就写空,不要骑墙;数据不支持时直言无法判断 3. **结构优先**:先看指数同步性与量能结构,再看板块与情绪,最后才是消息 @@ -202,7 +212,7 @@ def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str: "", "## 近期市场新闻", "(暂无新闻数据:本功能新闻检索能力将在后续版本接入。" - "请按系统提示词第 6 节的说明,基于量价异动进行[推断],并如实标注,不要编造具体消息。)", + "消息催化一节请直接从量价异动给出可能的催化逻辑结论,不要编造具体消息,也不要复述本说明。)", ]) if focus.strip(): @@ -216,7 +226,10 @@ def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str: # ================================================================ def _recap_summary(overview: dict) -> str: - """一句话摘要(供 meta 事件与历史列表展示)。""" + """一句话摘要(供 meta 事件与历史列表展示)。 + + 指数用简称(上/深/创/科),与前端摘要条一致,避免列表里全称放不下。 + """ indices = overview.get("indices") or [] emo = overview.get("emotion") or {} lim = overview.get("limit") or {} @@ -224,8 +237,8 @@ def _recap_summary(overview: dict) -> str: total_amount = (amt.get("total") or 0) / 1e8 idx_str = "、".join( - f"{(i.get('name') or '')}{(i.get('change_pct') or 0):+.2f}%" - for i in indices[:3] + f"{_INDEX_SHORT.get(i.get('name') or '', i.get('name') or '')}{(i.get('change_pct') or 0):+.2f}%" + for i in indices[:4] ) or "指数缺失" return ( f"{idx_str} | 情绪{emo.get('score',50)}({emo.get('label','—')}) | " diff --git a/backend/app/services/market_recap_reports.py b/backend/app/services/market_recap_reports.py index f3ad6c4..06be304 100644 --- a/backend/app/services/market_recap_reports.py +++ b/backend/app/services/market_recap_reports.py @@ -28,7 +28,7 @@ from pathlib import Path logger = logging.getLogger(__name__) -MAX_REPORTS = 30 +MAX_REPORTS = 20 def _path() -> Path: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2d7b567..4bc1742 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tickflow-stock-panel-backend" -version = "0.1.60" +version = "0.1.61" description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/data/ext_data/ext_gn_ths/config.json b/data/ext_data/ext_gn_ths/config.json index e83ed20..c935093 100644 --- a/data/ext_data/ext_gn_ths/config.json +++ b/data/ext_data/ext_gn_ths/config.json @@ -29,7 +29,7 @@ "label": "所属概念" } ], - "description": "ths概念、日更", + "description": "", "symbol_map": { "type": "mapped", "col": "股票代码" @@ -39,6 +39,6 @@ "from": "symbol", "method": "strip_exchange" }, - "created_at": "2026-06-19T19:56:33.098883", - "updated_at": "2026-06-19T19:56:33.098883" + "created_at": "2026-06-27T12:18:23.629390", + "updated_at": "2026-06-27T12:18:23.629390" } \ No newline at end of file diff --git a/data/ext_data/ext_gn_ths/part.parquet b/data/ext_data/ext_gn_ths/part.parquet index 53d1c9d..1525760 100644 Binary files a/data/ext_data/ext_gn_ths/part.parquet and b/data/ext_data/ext_gn_ths/part.parquet differ diff --git a/data/ext_data/ext_hy_ths/config.json b/data/ext_data/ext_hy_ths/config.json index 411faf3..97dabc8 100644 --- a/data/ext_data/ext_hy_ths/config.json +++ b/data/ext_data/ext_hy_ths/config.json @@ -26,10 +26,20 @@ { "name": "所属同花顺行业", "dtype": "string", - "label": "所属行业" + "label": "所属同花顺行业" + }, + { + "name": "thscode", + "dtype": "string", + "label": "thscode" + }, + { + "name": "time", + "dtype": "string", + "label": "time" } ], - "description": "ths行业、月更", + "description": "", "symbol_map": { "type": "mapped", "col": "股票代码" @@ -39,6 +49,6 @@ "from": "symbol", "method": "strip_exchange" }, - "created_at": "2026-06-19T19:57:17.004819", - "updated_at": "2026-06-19T19:57:17.004819" + "created_at": "2026-06-27T12:18:42.205794", + "updated_at": "2026-06-27T12:18:42.205794" } \ No newline at end of file diff --git a/data/ext_data/ext_hy_ths/part.parquet b/data/ext_data/ext_hy_ths/part.parquet index b41abe0..018630b 100644 Binary files a/data/ext_data/ext_hy_ths/part.parquet and b/data/ext_data/ext_hy_ths/part.parquet differ diff --git a/data/ext_data/ext_kz_ths/config.json b/data/ext_data/ext_kz_ths/config.json deleted file mode 100644 index 540b5d2..0000000 --- a/data/ext_data/ext_kz_ths/config.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "id": "ext_kz_ths", - "label": "扩展快照ths", - "mode": "snapshot", - "fields": [ - { - "name": "symbol", - "dtype": "string", - "label": "标的代码" - }, - { - "name": "code", - "dtype": "string", - "label": "代码" - }, - { - "name": "股票代码", - "dtype": "string", - "label": "股票代码" - }, - { - "name": "股票简称", - "dtype": "string", - "label": "股票简称" - }, - { - "name": "所属概念", - "dtype": "string", - "label": "所属概念" - }, - { - "name": "人气排名", - "dtype": "int", - "label": "人气排名" - }, - { - "name": "资金流向", - "dtype": "float", - "label": "资金流向" - }, - { - "name": "PE", - "dtype": "float", - "label": "PE" - }, - { - "name": "PB", - "dtype": "float", - "label": "PB" - }, - { - "name": "ROE", - "dtype": "float", - "label": "ROE" - }, - { - "name": "营收", - "dtype": "float", - "label": "营收" - }, - { - "name": "净利润", - "dtype": "float", - "label": "净利润" - } - ], - "description": "", - "symbol_map": { - "type": "mapped", - "col": "股票代码" - }, - "code_map": { - "type": "computed", - "from": "symbol", - "method": "strip_exchange" - }, - "created_at": "2026-06-05T13:48:50.034495", - "updated_at": "2026-06-05T16:02:52.060612" -} \ No newline at end of file diff --git a/data/ext_data/ext_kz_ths/part.parquet b/data/ext_data/ext_kz_ths/part.parquet deleted file mode 100644 index e6e5782..0000000 Binary files a/data/ext_data/ext_kz_ths/part.parquet and /dev/null differ diff --git a/frontend/package.json b/frontend/package.json index d7d3f5f..ff873ea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.60", + "version": "0.1.61", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/financials/MarkdownRenderer.tsx b/frontend/src/components/financials/MarkdownRenderer.tsx index 8fdfc93..53fa293 100644 --- a/frontend/src/components/financials/MarkdownRenderer.tsx +++ b/frontend/src/components/financials/MarkdownRenderer.tsx @@ -143,7 +143,7 @@ export function MarkdownRenderer({ content }: { content: string }) { {header.map((cell, ci) => ( - + {renderInline(cell, `th-${key}-${ci}`)} ))} @@ -153,7 +153,7 @@ export function MarkdownRenderer({ content }: { content: string }) { {body.map((row, ri) => ( {row.map((cell, ci) => ( - + {renderInline(cell, `td-${key}-${ri}-${ci}`)} ))} @@ -178,7 +178,7 @@ export function MarkdownRenderer({ content }: { content: string }) { blocks.push(
    {items.map((item, ii) => ( -
  • +
  • {renderInline(item, `li-${key}-${ii}`)}
  • @@ -202,7 +202,7 @@ export function MarkdownRenderer({ content }: { content: string }) { {ii + 1} - {renderInline(item, `ol-${key}-${ii}`)} + {renderInline(item, `ol-${key}-${ii}`)} ))} , @@ -212,7 +212,7 @@ export function MarkdownRenderer({ content }: { content: string }) { // 普通段落 blocks.push( -

    +

    {renderInline(trimmed, `p-${key}`)}

    , ) diff --git a/frontend/src/lib/reviewStore.ts b/frontend/src/lib/reviewStore.ts new file mode 100644 index 0000000..78473ef --- /dev/null +++ b/frontend/src/lib/reviewStore.ts @@ -0,0 +1,162 @@ +/** + * 复盘生成状态的全局单例 store —— 脱离 Review 组件生命周期。 + * + * 解决的问题:生成中切换到其他页面,Review 组件卸载会丢失 phase/content。 + * 本 store 把流式生成的状态提到模块级,组件卸载后流仍在后台继续跑, + * 回到页面订阅即可恢复显示。 + * + * 设计: + * - 模块级 state(phase/content/meta/focus),唯一的生成实例 + * - AbortController 存模块级 ref,组件卸载不中断流 + * - 订阅者列表(notify 机制),Review mount 时订阅、unmount 时退订 + */ +import { api } from '@/lib/api' + +export type ReviewPhase = 'idle' | 'loading' | 'streaming' | 'done' | 'error' + +export interface ReviewMeta { + as_of?: string + emotion_score?: number + emotion_label?: string + summary?: string +} + +export interface ReviewState { + phase: ReviewPhase + content: string + error: string + meta: ReviewMeta | null + focus: string +} + +const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null, focus: '' } + +// ===== 模块级单例状态(组件卸载不销毁)===== +let state: ReviewState = { ...INITIAL } +let abortCtrl: AbortController | null = null + +// ===== 订阅机制 ===== +type Listener = () => void +const listeners = new Set() + +function notify() { + for (const l of listeners) l() +} + +export function getReviewState(): ReviewState { + return state +} + +export function subscribeReview(listener: Listener): () => void { + listeners.add(listener) + return () => { listeners.delete(listener) } +} + +// 暴露给组件直接读取最新 meta(用于自动归档,避免闭包取旧值) +export function getReviewMeta(): ReviewMeta | null { + return state.meta +} + +/** 是否正在生成(loading 或 streaming) */ +export function isReviewGenerating(): boolean { + return state.phase === 'loading' || state.phase === 'streaming' +} + +/** + * 启动复盘生成。返回后流在后台独立运行,组件卸载不影响。 + * @param asOf 复盘日期 + * @param focus 用户追加的复盘关注点 + * @param onDone 完成回调(供调用方做自动归档) + */ +export async function startReviewGeneration( + asOf: string | undefined, + focus: string, + onDone?: (fullContent: string, meta: ReviewMeta | null) => void, +): Promise { + // 已在生成中,不重复启动 + if (isReviewGenerating()) return + + state = { phase: 'loading', content: '', error: '', meta: null, focus } + notify() + + abortCtrl = new AbortController() + let buf = '' + let failed = false + let doneMeta: ReviewMeta | null = null + + try { + for await (const evt of api.reviewStream(asOf, focus)) { + if (abortCtrl.signal.aborted) break + if (evt.type === 'meta') { + doneMeta = evt + state = { ...state, meta: evt } + notify() + } else if (evt.type === 'delta' && evt.content) { + buf += evt.content + state = { ...state, content: buf, phase: 'streaming' } + notify() + } else if (evt.type === 'error') { + failed = true + state = { ...state, error: evt.message ?? '复盘失败', phase: 'error' } + notify() + return + } else if (evt.type === 'done') { + state = { ...state, phase: 'done' } + notify() + } + } + // 流正常结束但无 done 事件,按 done 处理 + if (buf && !failed) { + state = { ...state, phase: 'done' } + notify() + // 自动归档 + if (buf && !failed) { + onDone?.(buf, doneMeta) + } + } + } catch (e: any) { + if (!abortCtrl.signal.aborted) { + state = { ...state, error: e?.message ?? '复盘失败', phase: 'error' } + notify() + } + } finally { + abortCtrl = null + } +} + +/** 中断当前生成(供"查看历史"等场景主动中断流)。 */ +export function abortReviewGeneration(): void { + abortCtrl?.abort() + abortCtrl = null +} + +/** 设置当前查看的历史报告(把 store 状态切到 done + 该报告内容)。 */ +export function setViewingReport(report: { + content: string + as_of?: string + emotion_score?: number | null + emotion_label?: string + summary?: string +}): void { + abortCtrl?.abort() + abortCtrl = null + state = { + phase: 'done', + content: report.content, + error: '', + meta: { + as_of: report.as_of, + emotion_score: report.emotion_score ?? undefined, + emotion_label: report.emotion_label, + summary: report.summary, + }, + focus: state.focus, + } + notify() +} + +/** 重置到 idle(清空当前显示)。 */ +export function resetReview(): void { + state = { ...INITIAL } + notify() +} diff --git a/frontend/src/lib/useReviewStore.ts b/frontend/src/lib/useReviewStore.ts new file mode 100644 index 0000000..3fc874f --- /dev/null +++ b/frontend/src/lib/useReviewStore.ts @@ -0,0 +1,14 @@ +/** + * 订阅 reviewStore 的 React hook。 + * + * mount 时订阅,store 变化触发 re-render;unmount 时退订(但 store 流继续跑)。 + * 用 useSyncExternalStore 保证与 React 18 并发模式兼容。 + */ +import { useSyncExternalStore } from 'react' +import { + getReviewState, subscribeReview, type ReviewState, +} from '@/lib/reviewStore' + +export function useReviewState(): ReviewState { + return useSyncExternalStore(subscribeReview, getReviewState, getReviewState) +} diff --git a/frontend/src/pages/Financials.tsx b/frontend/src/pages/Financials.tsx index a8552e0..c8302bd 100644 --- a/frontend/src/pages/Financials.tsx +++ b/frontend/src/pages/Financials.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass } from 'lucide-react' +import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { useCapabilities } from '@/lib/useSharedQueries' @@ -70,6 +70,25 @@ export function Financials() {

    财务数据接口仅 Expert 套餐可用。升级后此页自动显示财务数据面板。

    + {/* 当前财务数据源(TickFlow)需付费,后续将接入免费数据源;期间欢迎在 issues 推荐免费源 */} +
    +
    + + 关于数据源 +
    +

    + 当前财务数据源需付费,后续会接入免费数据源。如你常用某个免费财务数据源,欢迎在 Issues 中多多推荐哈 ~ +

    + + 前往 Issues 推荐 + + +
    diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx index 36c7674..14d5a3a 100644 --- a/frontend/src/pages/Review.tsx +++ b/frontend/src/pages/Review.tsx @@ -1,10 +1,10 @@ /** - * AI 大盘复盘页 —— 盘后复盘看板 + 流式 LLM 复盘报告 + 历史归档。 + * AI 大盘复盘页 —— 以流式 LLM 复盘报告为主体的盘后复盘工作台。 * - * 数据分工: - * - 顶部看板(指数/涨跌/连板/封板/情绪雷达)来自 GET /api/overview/market - * - 复盘报告(markdown)由 POST /api/market-recap/analyze 流式生成 - * 视觉语言对齐 Dashboard:A 股红涨绿跌、rounded-card 卡片、SectionTitle 层级。 + * 设计定位:极简专注型。不复刻 Dashboard 的看板(KPI/雷达/板块排名), + * 仅保留一行「市场摘要条」作为报告上下文参照;AI 报告 + 历史归档是页面主体。 + * - 摘要数据:GET /api/overview/market + * - 报告流式:POST /api/market-recap/analyze */ import { useCallback, useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' @@ -12,16 +12,21 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle, - BarChart3, Activity, Layers, ArrowUpRight, ArrowDownRight, Database, Wand2, + Database, Wand2, Copy, Download, } from 'lucide-react' import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { cn } from '@/lib/cn' -import { fmtPrice } from '@/lib/format' +import { fmtBigNum } from '@/lib/format' import { PageHeader } from '@/components/PageHeader' import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer' import { toast } from '@/components/Toast' +import { useReviewState } from '@/lib/useReviewStore' +import { + startReviewGeneration, resetReview, isReviewGenerating, + type ReviewPhase, +} from '@/lib/reviewStore' // ================================================================ // 涨跌幅格式化(注意单位差异) @@ -47,19 +52,27 @@ function scoreColor(v: number | null | undefined): string { return '#12B76A' } -type Phase = 'idle' | 'loading' | 'streaming' | 'done' | 'error' +// 归档时刻格式化:ISO → "MM-DD HH:mm"(用于历史列表显示复盘时间) +function fmtArchivedAt(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const mi = String(d.getMinutes()).padStart(2, '0') + return `${mm}-${dd} ${hh}:${mi}` +} + +// Phase 类型复用 store 的定义(单一来源) export function Review() { const qc = useQueryClient() // 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState) const asOf: string | undefined = undefined const [focus, setFocus] = useState('') - const [phase, setPhase] = useState('idle') - const [content, setContent] = useState('') - const [error, setError] = useState('') - const [meta, setMeta] = useState<{ as_of?: string; emotion_score?: number; emotion_label?: string; summary?: string } | null>(null) + // 生成状态走全局 store:切走页面流不中断,回来可恢复 + const { phase, content, error, meta } = useReviewState() const [viewing, setViewing] = useState(null) // 查看历史报告 - const abortRef = useRef(null) const reportEndRef = useRef(null) // 看板数据(与总览页同源) @@ -92,80 +105,70 @@ export function Review() { } }, [content, phase]) - // 主流程:生成复盘 - const generate = useCallback(async () => { - if (phase === 'loading' || phase === 'streaming') return - setViewing(null) - setPhase('loading') - setContent('') - setError('') - setMeta(null) - - const ctrl = new AbortController() - abortRef.current = ctrl - let buf = '' - let failed = false - try { - for await (const evt of api.reviewStream(asOf, focus)) { - if (ctrl.signal.aborted) break - if (evt.type === 'meta') { - setMeta(evt) - } else if (evt.type === 'delta' && evt.content) { - buf += evt.content - setContent(buf) - setPhase('streaming') - } else if (evt.type === 'error') { - failed = true - setError(evt.message ?? '复盘失败') - setPhase('error') - return - } else if (evt.type === 'done') { - setPhase('done') - } - } - // 流正常结束但无 done 事件,按 done 处理 - if (buf && !failed) setPhase('done') - } catch (e: any) { - if (!ctrl.signal.aborted) { - setError(e?.message ?? '复盘失败') - setPhase('error') - } - } finally { - abortRef.current = null - } - }, [asOf, focus, phase]) - - // 保存当前报告 - const saveCurrent = useCallback(async () => { - if (!content) return - const reportAsOf = meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10) + // 自动归档(生成完成后台静默保存)—— 通过回调注入 store,避免 store 直接依赖 qc/marketQuery + const onGenerationDone = useCallback(async (fullContent: string, doneMeta: { as_of?: string; summary?: string; emotion_score?: number; emotion_label?: string } | null) => { + const reportAsOf = doneMeta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10) try { await api.reviewReportSave({ as_of: reportAsOf, focus, - content, - summary: meta?.summary, - emotion_score: meta?.emotion_score ?? null, - emotion_label: meta?.emotion_label ?? '', + content: fullContent, + summary: doneMeta?.summary, + emotion_score: doneMeta?.emotion_score ?? null, + emotion_label: doneMeta?.emotion_label ?? '', }) qc.invalidateQueries({ queryKey: QK.reviewReports }) - toast('复盘已归档', 'success') - } catch { /* request() 已 toast */ } - }, [content, meta, asOf, focus, marketQuery.data, qc]) + } catch { /* 静默 */ } + }, [focus, asOf, marketQuery.data, qc]) - // 查看历史报告 + // 主流程:生成复盘(委托给全局 store,流在后台独立运行) + const generate = useCallback(() => { + if (isReviewGenerating()) return + setViewing(null) + resetReview() + startReviewGeneration(asOf, focus, (full, doneMeta) => { + onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ }) + }) + }, [asOf, focus, onGenerationDone]) + + // 复制全文到剪贴板(viewing 优先,与主区域显示一致) + const copyContent = useCallback(async () => { + const text = viewing?.content ?? content + if (!text) return + try { + await navigator.clipboard.writeText(text) + toast('已复制到剪贴板', 'success') + } catch { + toast('复制失败,请手动选择文本', 'error') + } + }, [content, viewing]) + + // 下载为 .md 文件(viewing 优先) + const downloadContent = useCallback(() => { + const text = viewing?.content ?? content + if (!text) return + const reportDate = viewing?.as_of ?? meta?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10) + const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `复盘_${reportDate}.md` + a.click() + URL.revokeObjectURL(url) + }, [content, viewing, meta, asOf]) + + // 查看历史报告(不中断后台生成:仅临时把 viewing 覆盖到主区域, + // 生成中的流仍在 store 里继续跑,点"生成中"项即可切回) const viewReport = useCallback((r: AiReviewReport) => { - abortRef.current?.abort() setViewing(r) - setContent(r.content) - setMeta({ as_of: r.as_of, emotion_score: r.emotion_score ?? undefined, emotion_label: r.emotion_label, summary: r.summary }) - setPhase('done') - setError('') }, []) const isGenerating = phase === 'loading' || phase === 'streaming' const displayDate = viewing?.as_of ?? meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? '最新' const data = marketQuery.data + // 主区域显示的内容:viewing(查看历史)优先于 store 的生成 content, + // 这样点历史报告不会覆盖后台生成中的流。 + const displayContent = viewing?.content ?? content return ( <> @@ -179,7 +182,7 @@ export function Review() { onClick={() => { marketQuery.refetch() }} disabled={marketQuery.isFetching} className="inline-flex items-center gap-1 rounded-btn border border-border bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground disabled:opacity-50" - title="刷新看板数据" + title="刷新市场数据" > 刷新 @@ -204,7 +207,7 @@ export function Review() { />
    -
    +
    {marketQuery.isLoading && !data ? (
    @@ -233,27 +236,8 @@ export function Review() {
    ) : ( <> - {/* ===== 指数行情条(对齐 Dashboard IndexTicker) ===== */} -
    - {data.indices.map(item => )} -
    - - {/* ===== KPI 网格 ===== */} -
    - {data.breadth.up}/{data.breadth.flat}/{data.breadth.down}} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} /> - {data.limit.limit_up}/{data.limit.limit_down}} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}% · 炸板 ${data.limit.broken ?? 0}`} /> - - - - -
    - - {/* ===== 情绪雷达 + 板块排名 双栏 ===== */} -
    - - - -
    + {/* ===== 市场摘要条(轻量上下文,非重复看板)===== */} + {/* ===== 关注点输入 ===== */}
    @@ -270,15 +254,16 @@ export function Review() { )}
    - {/* ===== 报告 + 历史 双栏 ===== */} + {/* ===== 报告 + 历史 双栏(报告为主体)===== */}
    @@ -286,7 +271,9 @@ export function Review() { reports={historyQuery.data?.reports ?? []} loading={historyQuery.isLoading} viewingId={viewing?.id ?? null} + generating={isGenerating} onView={viewReport} + onBackToGenerating={() => setViewing(null)} onDelete={(id) => deleteMut.mutate(id)} />
    @@ -299,184 +286,94 @@ export function Review() { } // ================================================================ -// 指数行情卡(对齐 Dashboard IndexTicker) +// 市场摘要条 —— 复盘页的轻量上下文(非重复看板) +// 仅一行:三大指数涨跌 · 情绪分 · 涨停结构 · 成交额 +// 详细数据请去 Dashboard 看,这里只给 AI 报告提供背景参照 // ================================================================ -function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) { - const pct = item.change_pct - const isUp = (pct ?? 0) >= 0 - return ( -
    -
    {item.name || item.symbol}
    -
    {fmtPctAlready(pct, 2, true)}
    -
    {item.symbol}
    -
    - {isUp ? : } - {fmtPrice(item.last_price)} -
    -
    - ) +// 指数简称映射:全称太长(上证指数/深证成指/创业板指/科创综指)摘要条放不下,统一缩成单字 +const INDEX_SHORT: Record = { + '上证指数': '上', '深证成指': '深', '创业板指': '创', '科创综指': '科', '科创50': '科', +} +function indexShort(name?: string | null, symbol?: string): string { + if (!name) return symbol ?? '—' + return INDEX_SHORT[name] ?? (name.replace(/指数|成指|A股|综指|50/g, '').slice(0, 2) || name.slice(0, 1)) } -// ================================================================ -// KPI 单元(对齐 Dashboard KpiCell) -// ================================================================ -function KpiCell({ label, value, sub, tone }: { - label: React.ReactNode - value: React.ReactNode - sub?: string - tone?: 'bull' | 'bear' | 'accent' -}) { - const isPlain = typeof value === 'string' || typeof value === 'number' - const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground' - return ( -
    -
    {label}
    -
    {value}
    - {sub &&
    {sub}
    } -
    - ) +// 批量替换文本中的指数全称为简称(用于历史列表 summary 显示, +// 兼容存量旧报告 —— 它们存盘时 summary 还是全称)。 +const _INDEX_FULL_RE = /上证指数|深证成指|创业板指|科创综指|科创50/g +function shortenIndexNames(text: string): string { + return text.replace(_INDEX_FULL_RE, (m) => INDEX_SHORT[m] ?? m) } -// ================================================================ -// 章节标题(对齐 Dashboard SectionTitle) -// ================================================================ -function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: React.ReactNode }) { - return ( -
    -
    - -

    {title}

    -
    - {hint && {hint}} -
    - ) +// 从 summary 的指数段(如「上-2.26%、深-3.44%、创-4.07%、科-2.02%」) +// 解析出 [{name, pctStr, pctNum}],供列表项按涨跌染色渲染。 +const _INDEX_PCT_RE = /([上深创科])([+-]?\d+\.\d+%)/g +function parseIndexPcts(indexSegment: string): { name: string; pctStr: string; pctNum: number }[] { + const out: { name: string; pctStr: string; pctNum: number }[] = [] + for (const m of indexSegment.matchAll(_INDEX_PCT_RE)) { + out.push({ name: m[1], pctStr: m[2], pctNum: parseFloat(m[2]) }) + } + return out } -// ================================================================ -// 情绪雷达章节(SVG 雷达图,对齐 Dashboard EmotionRadar) -// ================================================================ -function EmotionSection({ data }: { data: OverviewMarket }) { - const score = data.emotion.score - const color = scoreColor(score) - const radar = data.radar ?? [] - const size = 220 - const cx = size / 2 - const cy = size / 2 - const maxR = 68 - - const points = radar.map((r, i) => { - const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length - const radius = maxR * Math.max(0, Math.min(100, r.value)) / 100 - return { - ...r, - x: cx + Math.cos(angle) * radius, - y: cy + Math.sin(angle) * radius, - lx: cx + Math.cos(angle) * (maxR + 24), - ly: cy + Math.sin(angle) * (maxR + 24), - gx: cx + Math.cos(angle) * maxR, - gy: cy + Math.sin(angle) * maxR, - } - }) - const polygon = points.map(p => `${p.x},${p.y}`).join(' ') - const gridPolygons = [1, 0.66, 0.33].map((level, idx) => ({ - level, idx, - points: radar.map((_, i) => { - const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length - return `${cx + Math.cos(angle) * maxR * level},${cy + Math.sin(angle) * maxR * level}` - }).join(' '), - })) +function MarketSummaryBar({ data }: { data: OverviewMarket }) { + const score = data.emotion?.score ?? null + const emoColor = scoreColor(score) + const indices = (data.indices ?? []).slice(0, 4) return ( -
    - - {radar.length === 0 ? ( -
    暂无雷达数据
    - ) : ( -
    - - - - - - - - - - - - - {gridPolygons.map(g => ( - - ))} - {points.map(p => )} - - {points.map(p => )} - - {score} - {points.map(p => ( - {p.label} - ))} - +
    + {/* 情绪分(带色徽章)—— 复盘的核心定调 */} +
    + + {score ?? '—'} + +
    +
    {data.emotion?.label ?? '情绪'}
    +
    情绪温度
    - )} -
    - ) -} - -// ================================================================ -// 板块排名章节(领涨/领跌) -// ================================================================ -function SectorSection({ title, rank, tone }: { - title: string - rank: OverviewMarket['concept_rank'] | OverviewMarket['industry_rank'] - tone: 'concept' | 'industry' -}) { - const leading = rank?.leading ?? [] - const lagging = rank?.lagging ?? [] - const hasData = leading.length > 0 || lagging.length > 0 - return ( -
    - - {!hasData ? ( -
    暂无数据
    - ) : ( -
    - - -
    - )} -
    - ) -} - -function RankColumn({ rows, tone }: { rows: OverviewMarket['concept_rank']['leading']; tone: 'bull' | 'bear' }) { - return ( -
    -
    - {tone === 'bull' ? '领涨' : '领跌'}
    - {rows.slice(0, 5).map((r, idx) => ( -
    - {idx + 1} -
    -
    {r.name}
    -
    {r.count}只 · {r.leader?.name ?? '—'}
    + +
    + + {/* 四大指数(简称:上深创科)*/} +
    + {indices.map(idx => ( +
    + {indexShort(idx.name, idx.symbol)} + + {fmtPctAlready(idx.change_pct, 2, true)} +
    -
    - {fmtPctAlready((r.avg_pct ?? 0) * 100, 2, true)} -
    -
    - ))} - {rows.length === 0 &&
    } + ))} +
    + +
    + + {/* 涨跌结构 */} +
    + 涨跌 + {data.breadth?.up ?? 0} + / + {data.breadth?.down ?? 0} +
    + + {/* 涨停结构 */} +
    + 涨停 + {data.limit?.limit_up ?? 0} + 封板 {(data.limit?.seal_rate ?? 0).toFixed(0)}% +
    + + {/* 成交额 */} +
    + 成交 + {fmtBigNum(data.amount?.total)} +
    ) } @@ -485,14 +382,15 @@ function RankColumn({ rows, tone }: { rows: OverviewMarket['concept_rank']['lead // 报告面板(流式 + 错误 + 历史/完成态) // ================================================================ function ReportPanel({ - phase, content, error, isGenerating, viewing, onSave, onRegenerate, reportEndRef, + phase, content, error, isGenerating, viewing, onCopy, onDownload, onRegenerate, reportEndRef, }: { - phase: Phase + phase: ReviewPhase content: string error: string isGenerating: boolean viewing: AiReviewReport | null - onSave: () => void + onCopy: () => void + onDownload: () => void onRegenerate: () => void reportEndRef: React.RefObject }) { @@ -516,29 +414,49 @@ function ReportPanel({ if (phase === 'idle' && !content) { return ( -
    +
    -
    - +
    +
    +
    -
    AI 大盘复盘
    -

    - 点击右上角「生成复盘」,基于今日指数结构、涨跌家数、连板梯队、板块轮动与情绪雷达, - 生成可直接指导次日仓位与节奏的盘后复盘报告。 +

    AI 大盘复盘
    +

    + 一键生成今日盘后复盘报告 —— 从一句话定调到明日交易计划, + 结构化输出可直接指导次日仓位与节奏。

    -
    + {/* 报告七节预览 —— 空状态也有内容感,暗示报告结构 */} +
    + {[ + { icon: '🎯', label: '一句话定调' }, + { icon: '📊', label: '盘面总览' }, + { icon: '🔥', label: '板块主线' }, + { icon: '💰', label: '资金情绪' }, + { icon: '📰', label: '消息催化' }, + { icon: '🎯', label: '明日计划' }, + { icon: '⚠️', label: '风险提示' }, + ].map((s) => ( +
    + {s.icon} + {s.label} +
    + ))} +
    +
    - 七节结构化报告 · 一键归档 · 历史回看 + 点击右上角「生成复盘」开始
    ) } - const showCursor = isGenerating - const showSave = phase === 'done' && !!content && !viewing + // 仅当显示生成内容(非查看历史)且正在生成时,才显示流式光标 + const showCursor = isGenerating && !viewing + // 查看历史时(即使后台在生成)也能复制/下载该历史报告 + const showActions = !!content && (!isGenerating || !!viewing) const showViewingTag = !!viewing const isLoading = phase === 'loading' && !content @@ -555,13 +473,18 @@ function ReportPanel({ {showViewingTag ? `历史复盘 · ${viewing!.as_of}` : isGenerating ? 'AI 正在复盘…' : '复盘报告'}
    - {showSave && ( - + {showActions && ( +
    + + +
    )}
    -
    +
    {isLoading ? (
    @@ -570,8 +493,8 @@ function ReportPanel({
    -
    AI 正在分析今日盘面…
    -
    读取指数结构 · 涨跌家数 · 连板梯队 · 板块轮动 · 情绪雷达
    +
    AI 正在复盘今日盘面…
    +
    分析指数结构 · 连板梯队 · 板块轮动 · 资金情绪
    ) : (
    @@ -591,14 +514,17 @@ function ReportPanel({ // 历史面板 // ================================================================ function HistoryPanel({ - reports, loading, viewingId, onView, onDelete, + reports, loading, viewingId, generating, onView, onBackToGenerating, onDelete, }: { reports: AiReviewReport[] loading: boolean viewingId: string | null + generating: boolean onView: (r: AiReviewReport) => void + onBackToGenerating: () => void onDelete: (id: string) => void }) { + const empty = !generating && reports.length === 0 return (
    @@ -609,14 +535,32 @@ function HistoryPanel({
    {loading ? (
    - ) : reports.length === 0 ? ( + ) : empty ? (
    暂无历史复盘
    -
    生成后点「归档」即可保存
    +
    生成完成后自动归档
    ) : (
    + {/* 生成中占位项:列表顶部,点击回到正在生成的流式内容 */} + {generating && ( +
    +
    + +
    +
    +
    生成中…
    +
    AI 正在复盘今日盘面
    +
    +
    + )} {reports.map((r) => { const color = scoreColor(r.emotion_score) return ( @@ -637,13 +581,28 @@ function HistoryPanel({
    {r.emotion_label ?? '—'} - {r.as_of} + {r.as_of}
    -
    - {r.summary ?? r.content.slice(0, 40)} +
    + {r.summary + ? (() => { + const pcts = parseIndexPcts(shortenIndexNames(r.summary).split('|')[0]) + if (pcts.length === 0) { + return {r.content.slice(0, 40)} + } + return pcts.map((p) => ( + + {p.name} + {p.pctStr} + + )) + })() + : {r.content.slice(0, 40)}}
    + {r.created_at && ( +
    {fmtArchivedAt(r.created_at)}
    + )}
    -