重置项目
This commit is contained in:
+25
-25
@@ -1,15 +1,14 @@
|
||||
# 两阶段构建:前端 dist 拷进后端镜像,单容器运行
|
||||
# 可选:构建网络无法直连官方源时:
|
||||
# 1. 传入 --build-arg USE_CN_MIRROR=1 启用国内 npm/pypi 镜像
|
||||
# 2. 传入 --build-arg PYTHON_IMAGE/NODE_IMAGE 使用 Docker Hub 国内镜像站
|
||||
# 可选:构建网络无法直连官方源时,传入 --build-arg USE_CN_MIRROR=1 启用国内镜像
|
||||
ARG USE_CN_MIRROR=1
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
ARG PYPI_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PYTHON_IMAGE=python:3.11-slim
|
||||
ARG NODE_IMAGE=node:20-alpine
|
||||
# 备用 PyPI 源:主源同步延迟/故障时自动兜底(阿里云与清华互为补充)
|
||||
ARG PYPI_FALLBACK=https://mirrors.aliyun.com/pypi/simple
|
||||
ARG BACKEND_EXTRAS=
|
||||
|
||||
# === Stage 1: 前端构建 ===
|
||||
FROM ${NODE_IMAGE} AS frontend-builder
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
ARG USE_CN_MIRROR=1
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
WORKDIR /build
|
||||
@@ -26,14 +25,21 @@ COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
# === Stage 2: Python 运行时 ===
|
||||
FROM ${PYTHON_IMAGE} AS runtime
|
||||
FROM python:3.11-slim AS runtime
|
||||
ARG USE_CN_MIRROR=1
|
||||
ARG PYPI_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PYPI_FALLBACK=https://mirrors.aliyun.com/pypi/simple
|
||||
ARG BACKEND_EXTRAS=
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 uv(快)
|
||||
# 安装 uv(快) —— 国内镜像下三重兜底:主源 → 备用源 → 官方源,
|
||||
# 任一成功即可,避免单一镜像同步延迟/故障导致构建失败。
|
||||
# uv 发版极频繁,国内镜像同步存在时间窗口,不锁版本且无 fallback 时
|
||||
# 容易遇到 "from versions: none"(索引解析不到最新版)。
|
||||
RUN if [ "$USE_CN_MIRROR" = "1" ]; then \
|
||||
pip install --no-cache-dir uv -i "$PYPI_INDEX"; \
|
||||
pip install --no-cache-dir uv -i "$PYPI_INDEX" || \
|
||||
pip install --no-cache-dir uv -i "$PYPI_FALLBACK" || \
|
||||
pip install --no-cache-dir uv; \
|
||||
else \
|
||||
pip install --no-cache-dir uv; \
|
||||
fi
|
||||
@@ -41,8 +47,16 @@ RUN if [ "$USE_CN_MIRROR" = "1" ]; then \
|
||||
# Backend deps
|
||||
COPY README.md /README.md
|
||||
COPY backend/pyproject.toml backend/uv.lock* ./
|
||||
RUN if [ "$USE_CN_MIRROR" = "1" ]; then export UV_DEFAULT_INDEX="$PYPI_INDEX"; fi; \
|
||||
uv sync --frozen --no-dev || uv sync --no-dev
|
||||
# uv 原生支持同时挂多个 index(主源 + 备用源),会自动在两源中查找,
|
||||
# 比逐个重试更稳健 —— 任一源缺包时另一源补位。
|
||||
RUN if [ "$USE_CN_MIRROR" = "1" ]; then \
|
||||
export UV_DEFAULT_INDEX="$PYPI_INDEX" UV_EXTRA_INDEX_URL="$PYPI_FALLBACK"; \
|
||||
fi; \
|
||||
set -- --no-dev; \
|
||||
for extra in $BACKEND_EXTRAS; do \
|
||||
set -- "$@" --extra "$extra"; \
|
||||
done; \
|
||||
uv sync --frozen "$@" || uv sync "$@"
|
||||
|
||||
# Backend code
|
||||
# 注意:Docker 里 WORKDIR=/app, 而 config.py 的 _PROJECT_ROOT 是按开发布局
|
||||
@@ -60,17 +74,3 @@ COPY --from=frontend-builder /build/dist ./static
|
||||
ENV PYTHONPATH=/app
|
||||
EXPOSE 3018
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3018"]
|
||||
|
||||
# === Stage 3: 测试镜像 ===
|
||||
# 基于 runtime 追加 dev + backtest 依赖,用于运行 pytest。
|
||||
# 生产镜像保持 --no-dev,此 stage 仅用于 CI/本地测试。
|
||||
FROM runtime AS test
|
||||
ARG USE_CN_MIRROR=1
|
||||
ARG PYPI_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN if [ "$USE_CN_MIRROR" = "1" ]; then export UV_DEFAULT_INDEX="$PYPI_INDEX"; fi; \
|
||||
uv sync --frozen --extra backtest --extra dev || uv sync --extra backtest --extra dev
|
||||
|
||||
COPY backend/tests ./tests
|
||||
|
||||
CMD ["uv", "run", "pytest", "tests"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
+216
-242
@@ -4,32 +4,51 @@
|
||||
|
||||
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
|
||||
|
||||
**面向个人散户与量化爱好者而生**
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://www.python.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
|
||||
[](./Dockerfile)
|
||||
|
||||
🚀 **开箱即用**(单容器 / Free 模式无需 Key) · 能力驱动,适配 Free → Expert 全档位订阅 · 🔌 **自由接入第三方扩展数据**(Tushare、自有量化项目数据等)
|
||||
|
||||
**[核心功能](#-核心功能)** · **[快速开始](#-快速开始)** · **[架构](#%EF%B8%8F-架构)** · **[配置](#%EF%B8%8F-配置)** · **[路线图](#-路线图)**
|
||||
[](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 股分析、选股、监控工作台。
|
||||
**任意接入第三方数据**(Tushare 等),页面可视化自定义配置扩展数据表。
|
||||
**面向个人散户与量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具。
|
||||
|
||||
**项目所需配置**:
|
||||
---
|
||||
|
||||
| 配置项 | 说明 | 是否必填 |
|
||||
| :--- | :--- | :--- |
|
||||
| **数据源 API Key** | 数据源凭证,留空启用 Free 模式(无需注册即可体验) | 可选 |
|
||||
| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析(开发中)、行情分析(开发中),任意 OpenAI 兼容接口,留空关闭 | 可选 |
|
||||
## 📸 界面预览
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -37,30 +56,110 @@
|
||||
<td width="50%" align="center"><b>策略 Screener</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="./docs/screenshots/dashboard.png" alt="看板页面" title="看板页面"></td>
|
||||
<td width="50%"><img src="./docs/screenshots/screener.png" alt="策略页" title="策略页"></td>
|
||||
<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="./docs/screenshots/backtest.png" alt="回测页" title="回测页"></td>
|
||||
<td width="50%"><img src="./docs/screenshots/monitor.png" alt="监控中心" title="监控中心"></td>
|
||||
<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>
|
||||
<td width="50%" align="center"><b>概念分析 Concept</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="./docs/screenshots/limit-ladder.png" alt="连板梯队页" title="连板梯队页"></td>
|
||||
<td width="50%"><img src="./docs/screenshots/concept-analysis.png" alt="概念分析" title="概念分析"></td>
|
||||
<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">
|
||||
|
||||
> **明确不做**:不对标同花顺/通达信的全功能股票软件;不内置任何「AI 荐股 / 涨停预测」。
|
||||
### 📸 [查看更多界面截图 »](./screenshots/README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置依赖
|
||||
|
||||
| 工具 | 版本 | 安装 |
|
||||
| :--------------------------------- | :----- | :------------------------------------------------- |
|
||||
| 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` |
|
||||
|
||||
### 方式 A:Dev 模式(二次开发推荐)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
|
||||
./dev.sh # Windows: .\dev.ps1
|
||||
```
|
||||
|
||||
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
|
||||
|
||||
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
|
||||
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
||||
|
||||
### 方式 B:Docker(部署最省心)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
# 打开 http://localhost:3018
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
|
||||
|
||||
**老 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. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
|
||||
|
||||
---
|
||||
|
||||
@@ -68,225 +167,87 @@
|
||||
|
||||
### 🔍 选股引擎(Screener)
|
||||
|
||||
**20+ 个内置策略** —— 每个策略是一个独立 Python 文件(`backend/app/strategy/builtin/`),基于 Polars 表达式实现:
|
||||
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
|
||||
|
||||
| 类型 | 代表策略 |
|
||||
| :--- | :--- |
|
||||
| 趋势 | 趋势突破 · 均线多头 · 缩量回踩 |
|
||||
| 形态 | MA 金叉 · MACD 金叉放量 · 布林突破 |
|
||||
| 量价 | 量价齐升 · 高换手强势 · 强势高开 |
|
||||
| 涨停 | 连板股 · 断板反包 · 逼近涨停 · 涨停动量 |
|
||||
| 反转 | 超跌反弹 · 超卖反转 · 新低反转 |
|
||||
| 波动 | 低波动龙头 · 回踩 MA20 反弹 |
|
||||
| 类型 | 代表策略 |
|
||||
| :---------- | :------------------------------------------------------- |
|
||||
| 趋势 / 形态 | 趋势突破 · 均线多头 · 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/`,即刻可用 | 需先在 [配置](#%EF%B8%8F-配置) 中填入 AI Key |
|
||||
| **📝 代码自定义 / 策略迁移** | 参照 [策略开发指南](./docs/strategy-guide.md) 的文件结构模板,把你**已有的自有策略**改写为 Polars 文件放入 `data/strategies/custom/`(文件名/ID 建议 `custom_时间戳`),引擎自动发现加载——**轻松迁移你现成的量化项目策略**,无需从头重写 | 无 |
|
||||
| **🎛️ 自定义信号配置** | 不写代码,在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,即可定义自己的买卖信号 | 无 |
|
||||
|
||||
> 引擎按 `source` 标记来源:`builtin`(内置)/ `custom`(手写或迁移)/ `ai`(生成),三者统一进入策略商店管理。
|
||||
| 方式 | 说明 |
|
||||
| :---------------- | :---------------------------------------------------------------------------------------------------- |
|
||||
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
|
||||
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
|
||||
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
|
||||
|
||||
### 📊 指标流水线(Indicators)
|
||||
|
||||
原生 Polars 向量化计算,全 A 股一次扫表落盘为 enriched Parquet:
|
||||
原生 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`),回测与指标一致 |
|
||||
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
|
||||
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
|
||||
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
|
||||
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
|
||||
- **复权**:基于除权因子自动前复权,回测与指标口径一致
|
||||
|
||||
### 🧪 回测引擎(Backtest)
|
||||
|
||||
自研 Polars/NumPy 撮合引擎为主,兼容 vectorbt 作为可选依赖:
|
||||
|
||||
- **三种回测模式**:个股 · 策略组合 · 自由信号组合
|
||||
- **真实约束**:T+1 · 手续费 · 滑点(基点) · 止损 · 最大持仓天数
|
||||
- **组合管理**:最大持仓数 · 最大敞口 · 等权 / 自定义仓位
|
||||
- **SSE 流式进度**:长任务实时推送进度,支持刷新 / 切页后**重连恢复**(相同参数任务只启动一次)
|
||||
- **统计输出**:净值曲线 · 夏普 · 最大回撤 · 胜率 · 每笔交易明细
|
||||
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
|
||||
|
||||
### 📡 监控中心(Monitor)
|
||||
|
||||
**统一监控规则引擎** —— 一个页面管理所有类型的监控,实时推送 + 持久化触发记录:
|
||||
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
|
||||
|
||||
- **四类监控**:策略监控 · 个股信号监控(选信号即加) · 个股价格/涨跌监控 · 全市场异动监控
|
||||
- **灵活条件**:多条件 AND/OR 组合 + 冷却期去重(防刷屏) + 严重级别(info/warn/critical)
|
||||
- **多入口配置**:监控中心页面新建规则 · 个股详情页「加监控」· 策略卡片一键开启
|
||||
- **实时 SSE 推送**:命中规则后右下角弹窗通知(可配声效) + 持久化到 `alerts.jsonl`
|
||||
- **触发记录**:时间倒序展示,支持按来源过滤 · 单条删除 · 清空 · 点击查看个股日K
|
||||
- **菜单未读徽标**:离开监控中心后有新触发,菜单显示未读数;进入页面后清零
|
||||
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
|
||||
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
|
||||
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
||||
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
|
||||
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
|
||||
|
||||
### 🤖 AI 策略生成(可选)
|
||||
### 📈 个股分析(Beta)
|
||||
|
||||
- **自然语言 → 策略代码**:用一句话描述策略思路,LLM 读取 `docs/strategy-guide.md` 生成完整 Polars 策略文件
|
||||
- **沙箱约束**:生成代码经 `ast` 校验、限定 `import polars as pl`,避免逐行循环,优先向量化表达
|
||||
- **可插拔**:留空 AI 配置即跳过整个模块,不影响核心功能
|
||||
以「行情 + 关键价位」为主体的单标的决策页:
|
||||
|
||||
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
|
||||
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
|
||||
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
|
||||
|
||||
### 🧰 数据与扩展
|
||||
|
||||
- **多源数据**:日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 自选行情
|
||||
- **🔌 第三方数据接入(重点)** —— 内置数据源之外的数据也能用:
|
||||
- 支持 **Tushare** 等第三方数据源,通过 **HTTP 定时拉取**自动入库
|
||||
- 支持 **CSV / Excel 上传** · **JSON 写入**,自动 schema 发现与符号归一
|
||||
- **页面可视化配置**扩展数据表,无需改代码
|
||||
- 可接入**你自己的量化项目数据**,统一并入 DuckDB 查询面,与内置数据同台分析
|
||||
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched 表 + 跑监控
|
||||
- **令牌桶限流**:适配各档位 rpm / batch 上限,批量合并 + 增量拉取,同一份数据多面板复用
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
本项目**仅通过 Docker 部署**,无论是本地体验还是服务器部署都使用同一套镜像。
|
||||
|
||||
### 前置依赖
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- Docker Compose(已随 Docker Desktop 自带,Linux 需单独安装)
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 按需填写 Key(留空即 Free 模式,可直接体验)
|
||||
docker compose up --build
|
||||
# 打开 http://localhost:3018
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 运行后端全部测试(含回测引擎)
|
||||
docker compose run --rm test
|
||||
```
|
||||
|
||||
> 测试镜像已包含回测依赖,可直接运行 `backend/tests` 下的全部 pytest 用例。
|
||||
|
||||
---
|
||||
|
||||
## 🧭 第一次使用
|
||||
|
||||
1. 打开面板 → **设置 → 凭据与能力** → 点 **重新检测**,确认 Tier Label
|
||||
2. 点 **立即跑盘后管道** —— 拉日 K + 计算 enriched 表
|
||||
- **Free 用户**:只同步内置 DEMO_SYMBOLS(浦发 / 招商 / 茅台等 10 只)
|
||||
- **Starter+**:同步全 A 或根据数据源能力获取的 instruments 列表
|
||||
3. **自选**页:添加跟踪标的;点代码进 **K 线**页看蜡烛图 + 买卖点
|
||||
4. **选股**页:点任一内置策略卡片即时扫描;或用自定义信号组合条件
|
||||
5. **回测**页:选策略 / 信号 + 时间区间 → 跑回测 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
|
||||
6. **监控中心**页:配置监控规则(策略/个股信号/价格/市场异动),盘中 SSE 实时弹窗通知 + 持久化触发记录;或在个股详情页点「加监控」快速添加
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 层 | 选型 |
|
||||
| :--- | :--- |
|
||||
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
|
||||
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储)· PyArrow |
|
||||
| **回测** | 自研 Polars/NumPy 撮合引擎 · vectorbt(可选依赖) |
|
||||
| **数据源** | A 股数据源 SDK(`tickflow[all]`) |
|
||||
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
|
||||
| **前端** | React 18 · Vite · TypeScript · Tailwind CSS · Framer Motion · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
|
||||
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── api/ # FastAPI 路由(选股/回测/监控/数据/设置等)
|
||||
├── services/ # 业务服务(选股/行情/数据同步/告警存储等)
|
||||
├── strategy/ # 策略引擎(内置/自定义/AI生成/监控规则)
|
||||
├── indicators/ # Polars 指标流水线
|
||||
├── backtest/ # 自研回测引擎
|
||||
├── tickflow/ # 数据源 SDK 适配层
|
||||
└── jobs/ # 盘后定时管道任务
|
||||
|
||||
frontend/src/
|
||||
├── pages/ # 页面组件(Dashboard/Screener/Backtest/Monitor 等)
|
||||
├── components/ # 可复用组件(图表/表格/选股/监控等)
|
||||
└── lib/ # API 客户端/QueryKey/格式化工具等
|
||||
|
||||
data/ # 本地数据目录(Parquet 分区文件)
|
||||
├── kline_daily/ # 原始日 K
|
||||
├── kline_daily_enriched/ # 带指标日 K
|
||||
├── instruments/ # 标的维表
|
||||
├── financials/ # 财务数据
|
||||
├── ext_data/ # 用户扩展数据
|
||||
└── backtest_results/ # 回测结果
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
tickflow 数据源
|
||||
↓
|
||||
kline_sync / instrument_sync / index_sync / financial_sync
|
||||
↓
|
||||
Parquet 分区文件 (data/)
|
||||
↓
|
||||
DuckDB 内存视图
|
||||
↓
|
||||
Polars 内存缓存
|
||||
↓
|
||||
选股 / 回测 / 监控 / 行情服务
|
||||
↓
|
||||
FastAPI → React 前端
|
||||
```
|
||||
|
||||
### 档位能力体系
|
||||
|
||||
`tiers.yaml` 定义了 Free → Expert 五档能力,启动时自动探测真实可用能力:
|
||||
|
||||
| 档位 | 能力 |
|
||||
| :--- | :--- |
|
||||
| **none** | 无 Key,仅历史日 K(批量) |
|
||||
| **free** | 免费有效 Key,能力与 none 等价 |
|
||||
| **starter** | 实时行情、批量、标的池、除权因子 |
|
||||
| **pro** | 增加分钟 K、五档盘口 |
|
||||
| **expert** | 增加财务数据、WebSocket |
|
||||
|
||||
UI 会显示友好标签(如「≈ Pro」),未解锁的功能自动灰显。
|
||||
|
||||
### 安全
|
||||
|
||||
- `/api/*` 路径通过 `auth.py` 中间件校验访问令牌
|
||||
- 支持 `admin` / `user` 两种角色,管理员令牌可在 `.env` 中配置
|
||||
- AI 生成策略经 `ast` 安全校验,禁止 `open/exec/eval/os/sys/subprocess`,限定 `import polars as pl`
|
||||
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
|
||||
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
|
||||
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
|
||||
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置
|
||||
|
||||
所有配置通过项目根目录的 `.env` 文件读取(复制 `.env.example` 开始)。配置也可在面板 **设置** 页面内修改。
|
||||
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
|
||||
|
||||
### 数据源
|
||||
|
||||
当前默认接入内置数据源提供的订阅制 A 股数据。**留空 `TICKFLOW_API_KEY` 即启用 Free 模式,无需注册即可体验**。
|
||||
### 数据源:TickFlow
|
||||
|
||||
```ini
|
||||
TICKFLOW_API_KEY= # 留空 = Free 模式;填入 Key = 按订阅档位解锁
|
||||
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
|
||||
```
|
||||
|
||||
> 系统启动时会自动探测你的真实能力集,UI 显示「≈ Pro」等友好标签。
|
||||
留空即 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
|
||||
@@ -296,8 +257,6 @@ AI_MODEL=deepseek-chat
|
||||
AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
|
||||
```
|
||||
|
||||
> 切换 `AI_PROVIDER=ollama` 时无需 `AI_API_KEY`,适合本地部署大模型。
|
||||
|
||||
### 服务与数据
|
||||
|
||||
```ini
|
||||
@@ -305,50 +264,65 @@ HOST=0.0.0.0 # 监听地址
|
||||
PORT=3018 # 服务端口
|
||||
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
|
||||
DATA_DIR=./data # Parquet / DuckDB 数据存储目录
|
||||
ACCESS_UUID= # 访问控制 UUID(可选)
|
||||
ADMIN_TOKEN=admin # 管理员令牌
|
||||
```
|
||||
|
||||
### 访问密码
|
||||
|
||||
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
|
||||
|
||||
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** | 仓库骨架 / FastAPI 壳 / Vite + React SPA / Docker 一键起 | ✅ |
|
||||
| **1** | 能力探测 + Kline 同步 + K 线分析页 | ✅ |
|
||||
| **2** | Polars enriched 流水线 + Screener + 信号扫描 | ✅ |
|
||||
| **3** | 自研回测引擎 + T+1 + 手续费 + 止损 + max-hold | ✅ |
|
||||
| **4** | 监控引擎 + 告警规则 + Webhook + APScheduler 盘后定时 | ✅ |
|
||||
| **5** | 统一监控中心 + 四类监控规则 + 实时推送 + 持久化触发记录 + 声效通知 | ✅ |
|
||||
| **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-example.md](./docs/strategy-example.md) —— 策略示例
|
||||
- [docs/strategy-builder-step1.md](./docs/strategy-builder-step1.md) / [step2.md](./docs/strategy-builder-step2.md) —— 策略构建步骤
|
||||
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
|
||||
- [docs/](./docs) —— 策略构建步骤、示例
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎 Issue 和 PR。请通过 Docker 进行本地验证:
|
||||
|
||||
```bash
|
||||
# 启动应用
|
||||
docker compose up --build -d
|
||||
|
||||
# 运行测试
|
||||
docker compose run --rm test
|
||||
```
|
||||
|
||||
新增内置策略:在 `backend/app/strategy/builtin/` 参照现有策略文件,实现 `StrategyDef` 即可被引擎自动发现。
|
||||
欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源官方为准。
|
||||
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
|
||||
|
||||
## 社区
|
||||
|
||||
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v1.0.0
|
||||
v0.1.64
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Stock Panel backend."""
|
||||
"""TickFlow Stock Panel backend."""
|
||||
|
||||
import sys
|
||||
|
||||
__version__ = "0.1.44"
|
||||
__version__ = "0.1.70"
|
||||
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),数据源 SDK 内部输出含 emoji 的
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
|
||||
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
|
||||
# 进程加载最早阶段强制 UTF-8,根治此类编码崩溃。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
|
||||
@@ -9,8 +9,6 @@ from typing import Literal
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.ext_data import ExtConfigStore
|
||||
|
||||
router = APIRouter(prefix="/api/analysis-menus", tags=["analysis-menus"])
|
||||
|
||||
|
||||
@@ -113,44 +111,13 @@ def _save(request: Request, menu: AnalysisMenu) -> AnalysisMenu:
|
||||
|
||||
|
||||
def _default_menus(request: Request) -> list[AnalysisMenu]:
|
||||
ext_store = ExtConfigStore(_data_dir(request))
|
||||
menus: list[AnalysisMenu] = []
|
||||
for cfg in ext_store.load_all():
|
||||
fields = cfg.fields
|
||||
concept = next((f for f in fields if "概念" in f.name or "概念" in f.label or "concept" in f.name.lower()), None)
|
||||
if concept:
|
||||
detail_names = ["股票简称", "股票代码", concept.name, "人气排名", "资金流向", "PE", "PB"]
|
||||
detail_columns = []
|
||||
for name in detail_names:
|
||||
f = next((x for x in fields if x.name == name), None)
|
||||
if not f:
|
||||
continue
|
||||
is_num = f.dtype in ("int", "float")
|
||||
detail_columns.append(AnalysisColumn(
|
||||
field=f.name,
|
||||
label=f.label or f.name,
|
||||
type="number" if is_num else "string",
|
||||
sortable=is_num,
|
||||
precision=2 if f.dtype == "float" else None,
|
||||
))
|
||||
menus.append(AnalysisMenu(
|
||||
id="concept_analysis",
|
||||
label="概念分析",
|
||||
icon="tags",
|
||||
data_source=cfg.id,
|
||||
template="dimension_rank",
|
||||
dimension_field=concept.name,
|
||||
group_columns=[
|
||||
AnalysisColumn(field="__dimension", label="概念"),
|
||||
AnalysisColumn(field="__count", label="股票数", type="number", sortable=True),
|
||||
],
|
||||
detail_columns=detail_columns,
|
||||
default_sort=DefaultSort(field="人气排名", order="asc") if any(c.field == "人气排名" for c in detail_columns) else None,
|
||||
order=100,
|
||||
builtin=True,
|
||||
))
|
||||
break
|
||||
return menus
|
||||
"""自动生成的默认分析菜单。
|
||||
|
||||
历史上会扫描扩展数据配置,对含「概念」字段的表自动生成一个「概念分析」菜单。
|
||||
现已关闭自动生成 —— 内置的概念分析页(/concept-analysis)已覆盖该场景,
|
||||
自动菜单会造成导航重复。需要时用户可在「设置 → 扩展页面」手动创建。
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
+192
-59
@@ -1,80 +1,213 @@
|
||||
"""访问门控 API 与管理接口。"""
|
||||
"""访问认证 API。
|
||||
|
||||
端点:
|
||||
GET /api/auth/status — 是否已设密码、当前会话是否有效
|
||||
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
|
||||
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
|
||||
POST /api/auth/logout — 注销当前会话
|
||||
POST /api/auth/change-password — 改密码(需已登录)
|
||||
|
||||
安全:
|
||||
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
|
||||
否则黑客可比用户更早扫到域名, 抢先设密码, 反客为主。
|
||||
- login 限流: 同一来源 IP 连续失败 5 次, 锁 5 分钟(内存计数)。
|
||||
- 会话 token 通过 HttpOnly cookie 下发, 前端无需手动管理。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
||||
from app import auth
|
||||
from app import uuid_store
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services import auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
COOKIE_NAME = "tf_session"
|
||||
_COOKIE_MAX_AGE = 30 * 24 * 3600 # 与 SESSION_TTL 一致
|
||||
|
||||
# 限流: { ip: (fail_count, lock_until_ts) }
|
||||
_fail_counter: dict[str, tuple[int, float]] = defaultdict(lambda: (0, 0.0))
|
||||
_fail_lock = Lock()
|
||||
_MAX_FAILS = 5
|
||||
_LOCK_SECONDS = 300
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
def verify_credential(req: auth.VerifyIn) -> auth.VerifyOut:
|
||||
"""校验管理员令牌或普通 UUID,成功后返回访问令牌及角色。"""
|
||||
role = auth.verify_credential(req.credential)
|
||||
if role:
|
||||
token = auth.create_access_token(role)
|
||||
return auth.VerifyOut(valid=True, role=role.value, token=token)
|
||||
return auth.VerifyOut(valid=False, role=None, token=None)
|
||||
def _is_local_network(host: str | None) -> bool:
|
||||
"""是否本机或内网请求。
|
||||
|
||||
反向代理(Nginx)场景下 request.client.host 是代理本身(127.0.0.1),
|
||||
需信任 X-Forwarded-For 的最左(原始客户端)。本项目部署若经反代,
|
||||
请在反代配置正确的 X-Forwarded-For(标准做法)。
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
if host in ("127.0.0.1", "::1", "localhost"):
|
||||
return True
|
||||
# 内网网段: 10.x / 172.16-31.x / 192.168.x
|
||||
if host.startswith("10.") or host.startswith("192.168."):
|
||||
return True
|
||||
if host.startswith("172."):
|
||||
try:
|
||||
second = int(host.split(".")[1])
|
||||
if 16 <= second <= 31:
|
||||
return True
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""取真实客户端 IP(信任反代 X-Forwarded-For)。"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _check_login_rate_limit(ip: str) -> None:
|
||||
"""登录失败限流检查, 触发则抛 429。"""
|
||||
with _fail_lock:
|
||||
count, until = _fail_counter.get(ip, (0, 0.0))
|
||||
now = time.time()
|
||||
if until > now:
|
||||
wait = int(until - now)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"登录失败次数过多, 请 {wait} 秒后重试",
|
||||
)
|
||||
|
||||
|
||||
def _record_login_fail(ip: str) -> None:
|
||||
"""记录一次登录失败, 达阈值则锁定。"""
|
||||
with _fail_lock:
|
||||
count, until = _fail_counter.get(ip, (0, 0.0))
|
||||
count += 1
|
||||
if count >= _MAX_FAILS:
|
||||
until = time.time() + _LOCK_SECONDS
|
||||
logger.warning("auth login locked for %s after %d fails", ip, count)
|
||||
_fail_counter[ip] = (count, until)
|
||||
|
||||
|
||||
def _clear_login_fails(ip: str) -> None:
|
||||
"""登录成功后清除该 IP 的失败计数。"""
|
||||
with _fail_lock:
|
||||
_fail_counter.pop(ip, None)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 端点
|
||||
# ================================================================
|
||||
|
||||
class PasswordIn(BaseModel):
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
old_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def auth_status(request: Request) -> auth.AuthStatusOut:
|
||||
"""返回当前门控状态、当前请求是否通过校验及角色。"""
|
||||
enabled = auth.access_control_enabled()
|
||||
token = auth.get_access_token_from_request(request)
|
||||
role = auth.validate_access_token(token)
|
||||
return auth.AuthStatusOut(
|
||||
enabled=enabled,
|
||||
verified=role is not None,
|
||||
role=role.value if role else None,
|
||||
)
|
||||
def auth_status(request: Request) -> dict:
|
||||
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
return {
|
||||
"configured": auth.is_configured(),
|
||||
"authenticated": bool(token and auth.is_valid_session(token)),
|
||||
}
|
||||
|
||||
|
||||
# ===== 管理员 UUID 管理 =====
|
||||
@router.post("/setup")
|
||||
def setup_password(req: PasswordIn, request: Request) -> dict:
|
||||
"""首次设置访问密码。仅限本机/内网请求(防公网抢占)。
|
||||
|
||||
@admin_router.get("/uuids")
|
||||
def list_uuids(request: Request) -> list[auth.UuidRecordOut]:
|
||||
"""列出所有动态 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
records = uuid_store.list_uuids()
|
||||
return [
|
||||
auth.UuidRecordOut(
|
||||
uuid=r["uuid"],
|
||||
label=r.get("label", ""),
|
||||
enabled=r.get("enabled", True),
|
||||
created_at=r.get("created_at", 0),
|
||||
若已设置过密码, 返回 409(改密码走 /change-password)。
|
||||
"""
|
||||
# 关键: 限制只有服务器主人(本机/内网)能设密码
|
||||
client_ip = _client_ip(request)
|
||||
if not _is_local_network(client_ip):
|
||||
logger.warning("setup rejected from non-local ip: %s", client_ip)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作",
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
|
||||
if auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="密码已设置,如需修改请登录后使用改密码功能")
|
||||
|
||||
auth.set_password(req.password)
|
||||
logger.info("access password set up from %s", client_ip)
|
||||
return {"ok": True, "configured": True}
|
||||
|
||||
|
||||
@admin_router.post("/uuids")
|
||||
def create_uuid(req: auth.UuidCreateIn, request: Request) -> auth.UuidRecordOut:
|
||||
"""创建新的访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
record = uuid_store.create(req.label)
|
||||
return auth.UuidRecordOut(
|
||||
uuid=record["uuid"],
|
||||
label=record["label"],
|
||||
enabled=record["enabled"],
|
||||
created_at=record["created_at"],
|
||||
@router.post("/login")
|
||||
def login(req: LoginIn, request: Request, response: Response) -> dict:
|
||||
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
|
||||
ip = _client_ip(request)
|
||||
_check_login_rate_limit(ip)
|
||||
|
||||
if not auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||
|
||||
token = auth.verify_and_create_session(req.password)
|
||||
if not token:
|
||||
_record_login_fail(ip)
|
||||
raise HTTPException(status_code=401, detail="密码错误")
|
||||
|
||||
_clear_login_fails(ip)
|
||||
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
|
||||
)
|
||||
return {"ok": True, "authenticated": True}
|
||||
|
||||
|
||||
@admin_router.delete("/uuids/{uuid}")
|
||||
def delete_uuid(uuid: str, request: Request) -> dict:
|
||||
"""删除访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
ok = uuid_store.delete(uuid)
|
||||
return {"ok": ok}
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, response: Response) -> dict:
|
||||
"""注销当前会话。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if token:
|
||||
auth.revoke_session(token)
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@admin_router.put("/uuids/{uuid}/toggle")
|
||||
def toggle_uuid(uuid: str, request: Request, enabled: bool) -> dict:
|
||||
"""启用/禁用访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
ok = uuid_store.toggle(uuid, enabled)
|
||||
return {"ok": ok}
|
||||
@router.post("/change-password")
|
||||
def change_password(req: ChangePasswordIn, request: Request) -> dict:
|
||||
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not (token and auth.is_valid_session(token)):
|
||||
raise HTTPException(status_code=401, detail="请先登录")
|
||||
|
||||
if not auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||
|
||||
# 验证旧密码
|
||||
new_token = auth.verify_and_create_session(req.old_password)
|
||||
if not new_token:
|
||||
ip = _client_ip(request)
|
||||
_record_login_fail(ip)
|
||||
raise HTTPException(status_code=401, detail="旧密码错误")
|
||||
# 临时 token 用完即弃
|
||||
auth.revoke_session(new_token)
|
||||
|
||||
# 改密码(set_password 会清空所有会话)
|
||||
auth.set_password(req.new_password)
|
||||
return {"ok": True, "message": "密码已修改, 请重新登录"}
|
||||
|
||||
@@ -38,6 +38,9 @@ _table_cache: dict[str, dict | None] = {
|
||||
"index_daily": None,
|
||||
"index_enriched": None,
|
||||
"index_instruments": None,
|
||||
"etf_daily": None,
|
||||
"etf_enriched": None,
|
||||
"etf_instruments": None,
|
||||
"minute": None,
|
||||
"adj_factor": None,
|
||||
"instruments": None,
|
||||
@@ -262,6 +265,85 @@ def _safe_aggregate_index_instruments(repo) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _safe_aggregate_etf_instruments(repo) -> dict | None:
|
||||
"""ETF instruments 统计 — 优先独立 instruments_etf,兼容旧 instruments_index。"""
|
||||
queries = [
|
||||
"""SELECT count(*) AS rows,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count_if(name IS NOT NULL AND name != '') AS named
|
||||
FROM instruments_etf""",
|
||||
"""SELECT count(*) AS rows,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count_if(name IS NOT NULL AND name != '') AS named
|
||||
FROM instruments_index
|
||||
WHERE asset_type = 'etf'""",
|
||||
]
|
||||
for sql in queries:
|
||||
try:
|
||||
row = repo.execute_one(sql)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("aggregate etf instruments fallback failed: %s", e)
|
||||
continue
|
||||
if row and row[0]:
|
||||
return {
|
||||
"rows": int(row[0]),
|
||||
"symbols_covered": int(row[1] or 0),
|
||||
"latest_as_of": None,
|
||||
"named": int(row[2] or 0),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _safe_aggregate_etf_enriched(repo) -> dict | None:
|
||||
"""ETF enriched 统计 — 独立 kline_etf_enriched。"""
|
||||
fields = 0
|
||||
try:
|
||||
cols = repo.execute_all("DESCRIBE kline_etf_enriched")
|
||||
fields = len(cols)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
stats = _safe_aggregate(repo, "kline_etf_enriched")
|
||||
if not stats:
|
||||
return None
|
||||
return {**stats, "fields": fields}
|
||||
|
||||
|
||||
def _safe_aggregate_etf_daily(repo) -> dict | None:
|
||||
"""ETF 日K统计 — 优先独立 kline_etf_daily,兼容旧 index 存储。"""
|
||||
queries = [
|
||||
"""SELECT count(*) AS rows,
|
||||
min(date) AS earliest,
|
||||
max(date) AS latest,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count(DISTINCT date) AS trading_days
|
||||
FROM kline_etf_daily""",
|
||||
"""SELECT count(*) AS rows,
|
||||
min(date) AS earliest,
|
||||
max(date) AS latest,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count(DISTINCT date) AS trading_days
|
||||
FROM kline_index_daily
|
||||
WHERE symbol IN (
|
||||
SELECT DISTINCT symbol FROM instruments_index WHERE asset_type = 'etf'
|
||||
)""",
|
||||
]
|
||||
for sql in queries:
|
||||
try:
|
||||
row = repo.execute_one(sql)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("aggregate etf daily fallback failed: %s", e)
|
||||
continue
|
||||
if row and row[0]:
|
||||
return {
|
||||
"rows": int(row[0]),
|
||||
"earliest_date": str(row[1]) if row[1] else None,
|
||||
"latest_date": str(row[2]) if row[2] else None,
|
||||
"symbols_covered": int(row[3] or 0),
|
||||
"trading_days": int(row[4] or 0),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _safe_aggregate_adj_factor(repo) -> dict | None:
|
||||
"""adj_factor 视图统计,日期范围对齐日 K 覆盖区间。"""
|
||||
try:
|
||||
@@ -405,6 +487,10 @@ def _compute_storage(data_dir: Path) -> dict:
|
||||
"index_daily": data_dir / "kline_index_daily",
|
||||
"index_enriched": data_dir / "kline_index_enriched",
|
||||
"index_instruments": data_dir / "instruments_index",
|
||||
"etf_daily": data_dir / "kline_etf_daily",
|
||||
"etf_enriched": data_dir / "kline_etf_enriched",
|
||||
"etf_instruments": data_dir / "instruments_etf",
|
||||
"etf_adj_factor": data_dir / "adj_factor_etf",
|
||||
"minute": data_dir / "kline_minute",
|
||||
"adj_factor": data_dir / "adj_factor",
|
||||
"instruments": data_dir / "instruments",
|
||||
@@ -507,10 +593,13 @@ def status(request: Request) -> dict:
|
||||
return {
|
||||
"daily": _get_table_stats("daily", lambda: _safe_aggregate_daily(repo)),
|
||||
"enriched": _get_table_stats("enriched", lambda: _safe_aggregate_enriched(repo)),
|
||||
"index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)),
|
||||
"index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)),
|
||||
"index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)),
|
||||
"minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)),
|
||||
"index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)),
|
||||
"index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)),
|
||||
"index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)),
|
||||
"etf_daily": _get_table_stats("etf_daily", lambda: _safe_aggregate_etf_daily(repo)),
|
||||
"etf_enriched": _get_table_stats("etf_enriched", lambda: _safe_aggregate_etf_enriched(repo)),
|
||||
"etf_instruments": _get_table_stats("etf_instruments", lambda: _safe_aggregate_etf_instruments(repo)),
|
||||
"minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)),
|
||||
"adj_factor": _get_table_stats("adj_factor", lambda: _safe_aggregate_adj_factor(repo)),
|
||||
"instruments": _get_table_stats("instruments", lambda: _safe_aggregate_instruments(repo)),
|
||||
"financials": _get_table_stats("financials", lambda: _safe_aggregate_financials(repo)),
|
||||
@@ -537,8 +626,9 @@ def clear_data(request: Request):
|
||||
deleted = 0
|
||||
|
||||
for sub in (
|
||||
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", "kline_minute",
|
||||
"adj_factor", "instruments", "instruments_index", "pools", "financials",
|
||||
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
|
||||
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
|
||||
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
|
||||
"backtest_results", "screener_results", "ai_cache",
|
||||
):
|
||||
d = data_dir / sub
|
||||
@@ -596,10 +686,15 @@ def clear_data(request: Request):
|
||||
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
|
||||
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
|
||||
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
|
||||
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
|
||||
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
|
||||
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
|
||||
"kline_minute": f"{d}/kline_minute/**/*.parquet",
|
||||
"adj_factor": f"{d}/adj_factor/**/*.parquet",
|
||||
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
|
||||
"instruments": f"{d}/instruments/**/*.parquet",
|
||||
"instruments_index": f"{d}/instruments_index/**/*.parquet",
|
||||
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
|
||||
}.items():
|
||||
try:
|
||||
repo.db.execute(
|
||||
@@ -638,6 +733,17 @@ _TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
|
||||
"amount": "成交额",
|
||||
},
|
||||
"kline_index_enriched": ENRICHED_COLUMNS,
|
||||
"kline_etf_daily": {
|
||||
"symbol": "ETF代码",
|
||||
"date": "交易日期",
|
||||
"open": "开盘价",
|
||||
"high": "最高价",
|
||||
"low": "最低价",
|
||||
"close": "收盘价",
|
||||
"volume": "成交量",
|
||||
"amount": "成交额",
|
||||
},
|
||||
"kline_etf_enriched": ENRICHED_COLUMNS,
|
||||
"kline_minute": {
|
||||
"symbol": "股票代码",
|
||||
"datetime": "分钟时间戳",
|
||||
@@ -675,6 +781,13 @@ _TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
|
||||
"code": "指数编码(纯数字)",
|
||||
"asset_type": "资产类型(index)",
|
||||
},
|
||||
"instruments_etf": {
|
||||
"symbol": "ETF代码",
|
||||
"name": "ETF名称",
|
||||
"code": "ETF编码(纯数字)",
|
||||
"asset_type": "资产类型(etf)",
|
||||
"source": "数据源",
|
||||
},
|
||||
}
|
||||
|
||||
# view 名 → DuckDB 视图名
|
||||
@@ -684,6 +797,9 @@ _SCHEMA_VIEWS: dict[str, str] = {
|
||||
"index_daily": "kline_index_daily",
|
||||
"index_enriched": "kline_index_enriched",
|
||||
"index_instruments": "instruments_index",
|
||||
"etf_daily": "kline_etf_daily",
|
||||
"etf_enriched": "kline_etf_enriched",
|
||||
"etf_instruments": "instruments_etf",
|
||||
"minute": "kline_minute",
|
||||
"adj_factor": "adj_factor",
|
||||
"instruments": "instruments",
|
||||
@@ -730,7 +846,7 @@ def get_version(request: Request) -> dict:
|
||||
"""
|
||||
from app import __version__
|
||||
|
||||
# 1. 优先用 app.__version__ (开发期 bump_version.py 写入)
|
||||
# 1. 优先用 app.__version__ (唯一权威版本, 打包期由 PyInstaller 注入)
|
||||
if __version__:
|
||||
v = __version__.strip()
|
||||
return {"version": v if v.startswith("v") else f"v{v}"}
|
||||
|
||||
@@ -325,6 +325,27 @@ def list_configs(request: Request):
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.post("/presets/{config_id}/fetch")
|
||||
async def fetch_preset_data(request: Request, config_id: str):
|
||||
"""手动触发内置预设 (概念/行业) 的数据拉取。
|
||||
|
||||
注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。
|
||||
与通用 pull/run 不同: 走 ext_presets 的结构转换 (接口的 concepts/industries
|
||||
数组 → 拼接成字符串), 保证 schema 与现有数据一致。
|
||||
"""
|
||||
from app.services.ext_presets import fetch_preset
|
||||
|
||||
try:
|
||||
n = await fetch_preset(config_id, _data_dir(request))
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
||||
|
||||
_refresh_views(request)
|
||||
return {"status": "ok", "rows": n}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_config(request: Request, body: CreateExtReq):
|
||||
"""创建扩展数据配置。"""
|
||||
@@ -554,6 +575,13 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
||||
# 刷新调度器
|
||||
pull_scheduler.refresh(_data_dir(request))
|
||||
|
||||
# 关闭定时拉取时清理残留的 next_run, 避免前端展示一个永不执行的"下次"
|
||||
if not config.pull.enabled:
|
||||
cleared = store.get(config_id)
|
||||
if cleared and cleared.pull and cleared.pull.next_run:
|
||||
cleared.pull.next_run = None
|
||||
store.upsert(cleared)
|
||||
|
||||
return {"status": "ok", "pull": config.pull.to_dict()}
|
||||
|
||||
|
||||
@@ -609,8 +637,25 @@ async def run_pull(request: Request, config_id: str):
|
||||
try:
|
||||
n, d = await fetch_and_ingest(config, _data_dir(request))
|
||||
_refresh_views(request)
|
||||
# 写回执行状态, 让前端"上次执行"面板立即反映
|
||||
updated = store.get(config_id)
|
||||
if updated and updated.pull:
|
||||
from datetime import datetime, timezone
|
||||
updated.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
updated.pull.last_status = "success"
|
||||
updated.pull.last_message = f"{n} rows @ {d}"
|
||||
updated.pull.last_rows = n
|
||||
store.upsert(updated)
|
||||
return {"status": "ok", "rows": n, "date": d}
|
||||
except Exception as e:
|
||||
# 失败也写回状态, 记录错误信息
|
||||
failed = store.get(config_id)
|
||||
if failed and failed.pull:
|
||||
from datetime import datetime, timezone
|
||||
failed.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
failed.pull.last_status = "error"
|
||||
failed.pull.last_message = str(e)[:200]
|
||||
store.upsert(failed)
|
||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@ import logging
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.financial_sync import get_financial_df
|
||||
from app.services.financial_analyzer import analyze_financials_stream
|
||||
from app.services import ai_reports
|
||||
from app.tickflow.capabilities import Cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -109,7 +113,12 @@ def get_cash_flow(request: Request, symbol: str | None = None):
|
||||
|
||||
@router.post("/sync/{table}")
|
||||
def sync_table(request: Request, table: str):
|
||||
"""手动触发同步。table: metrics / income / balance_sheet / cash_flow / all"""
|
||||
"""手动触发同步(立即返回,后台异步执行)。
|
||||
|
||||
table: metrics / income / balance_sheet / cash_flow / all
|
||||
同步在后台线程执行,全量同步需数分钟。本接口立即返回 started 状态,
|
||||
前端通过轮询 GET /status 的 syncing 字段观察进度。
|
||||
"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
@@ -122,6 +131,87 @@ def sync_table(request: Request, table: str):
|
||||
return {"status": "error", "message": "FinancialScheduler not available"}
|
||||
|
||||
target = None if table == "all" else table
|
||||
result = fs.run_now(target)
|
||||
result = fs.trigger(target)
|
||||
|
||||
return {"status": "ok", "synced": result}
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 财务分析请求。"""
|
||||
symbol: str
|
||||
focus: str = "" # 可选:用户追加的分析关注点
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_financials(request: Request, req: AnalyzeRequest):
|
||||
"""AI 财务分析 — SSE 流式返回。
|
||||
|
||||
后端读取该标的 4 张财务表 → 注入 CFA 分析师级提示词 → 流式调用 LLM →
|
||||
逐 chunk 以 SSE 形式推给前端(JSON per line, 非 text/event-stream,
|
||||
以便前端用 ReadableStream 逐行解析,更简单可靠)。
|
||||
"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
if not req.symbol:
|
||||
raise HTTPException(400, "symbol 不能为空")
|
||||
|
||||
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):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# AI 报告 CRUD(历史报告持久化)
|
||||
# ================================================================
|
||||
|
||||
class SaveReportRequest(BaseModel):
|
||||
"""保存一条 AI 财务分析报告。"""
|
||||
symbol: str
|
||||
name: str = ""
|
||||
focus: str = ""
|
||||
content: str
|
||||
periods: int | None = None
|
||||
summary: str = ""
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。"""
|
||||
capset = request.app.state.capabilities
|
||||
if not capset.has(Cap.FINANCIAL):
|
||||
return {"reports": []}
|
||||
return {"reports": ai_reports.list_reports()}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条报告。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
report = ai_reports.save_report({
|
||||
"symbol": req.symbol,
|
||||
"name": req.name,
|
||||
"focus": req.focus,
|
||||
"content": req.content,
|
||||
"periods": req.periods,
|
||||
"summary": req.summary,
|
||||
})
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条报告。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
ok = ai_reports.delete_report(report_id)
|
||||
return {"ok": ok}
|
||||
|
||||
@@ -94,7 +94,7 @@ def get_index_daily(
|
||||
try:
|
||||
raw = kline_sync.sync_daily_batch([symbol], count=days + 150)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"数据源 fetch failed: {e}") from e
|
||||
raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
|
||||
if raw.is_empty():
|
||||
return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": [], "source": "none"}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def index_quotes(
|
||||
request: Request,
|
||||
symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"),
|
||||
):
|
||||
"""返回实时指数行情缓存,不触发数据源请求。"""
|
||||
"""返回实时指数行情缓存,不触发 TickFlow 请求。"""
|
||||
symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None
|
||||
qs = _get_quote_service(request)
|
||||
if not qs:
|
||||
@@ -136,6 +136,9 @@ async def quote_stream(request: Request):
|
||||
"depth": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
"review": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_review, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
}
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
@@ -160,6 +163,14 @@ async def quote_stream(request: Request):
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# 推送复盘进度 (定时复盘流式生成时) — 前端 reviewStore 直接消费
|
||||
# 事件已是 recap_market_stream 产出的 JSON 字符串, 逐条转发
|
||||
for evt_json in qs.pop_review_events():
|
||||
yield {
|
||||
"event": "review_progress",
|
||||
"data": evt_json,
|
||||
}
|
||||
|
||||
# 推送行情更新 (行情信号触发)
|
||||
if tasks["quote"] in done:
|
||||
try:
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.indicators.pipeline import compute_enriched_single
|
||||
from app.indicators.pipeline import compute_enriched, compute_enriched_single
|
||||
from app.services import kline_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -123,10 +123,19 @@ def get_daily(
|
||||
try:
|
||||
raw = kline_sync.sync_daily_batch([symbol], count=days + 30)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"数据源 fetch failed: {e}") from e
|
||||
raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
|
||||
if raw.is_empty():
|
||||
return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": []}
|
||||
enriched = compute_enriched_single(raw)
|
||||
# 拉除权因子做前复权 (Starter+ 有权限), 否则空 df → compute_enriched 退回未复权
|
||||
factors = pl.DataFrame()
|
||||
capset = getattr(request.app.state, "capabilities", None)
|
||||
try:
|
||||
from app.tickflow.capabilities import Cap
|
||||
if capset and capset.has(Cap.ADJ_FACTOR):
|
||||
factors = kline_sync.fetch_adj_factor_single(symbol)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
|
||||
enriched = compute_enriched(raw, factors=factors)
|
||||
rows = enriched.tail(days).to_dicts()
|
||||
# 即使 live 模式也尝试追加实时蜡烛
|
||||
rows = _maybe_inject_live_candle(request, symbol, rows)
|
||||
@@ -328,7 +337,7 @@ def get_minute(
|
||||
"""读取某只股票某天的分钟 K 线。
|
||||
|
||||
- 本地有完整数据(240条) → 直接返回
|
||||
- 本地无数据或不完整 → 从数据源实时拉取返回(不写入)
|
||||
- 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入)
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
stock_info = _get_stock_info(repo, symbol)
|
||||
@@ -337,7 +346,7 @@ def get_minute(
|
||||
if trade_date is None:
|
||||
trade_date = repo.latest_minute_date(symbol)
|
||||
if trade_date is None:
|
||||
# 本地无任何分钟K,尝试从数据源拉取当天
|
||||
# 本地无任何分钟K,尝试从 TickFlow 拉取当天
|
||||
trade_date = date.today()
|
||||
df = kline_sync.fetch_minute_single(symbol, trade_date)
|
||||
return {
|
||||
@@ -373,7 +382,7 @@ def get_minute(
|
||||
"date": str(trade_date), "rows": df.to_dicts(), "source": "local",
|
||||
}
|
||||
|
||||
# 本地不完整或无数据 → 从数据源实时拉取
|
||||
# 本地不完整或无数据 → 从 TickFlow 实时拉取
|
||||
live_df = kline_sync.fetch_minute_single(symbol, trade_date)
|
||||
return {
|
||||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""AI 大盘复盘 API — 流式复盘 + 报告持久化。
|
||||
|
||||
路由前缀: /api/market-recap
|
||||
|
||||
端点:
|
||||
POST /analyze AI 流式大盘复盘(NDJSON)
|
||||
GET /reports 历史复盘列表
|
||||
POST /reports 保存一条复盘报告
|
||||
DELETE /reports/{report_id} 删除一条复盘报告
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import market_recap_reports
|
||||
from app.services.market_recap import recap_market_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/market-recap", tags=["market-recap"])
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 大盘复盘请求。"""
|
||||
as_of: str | None = None # 可选:复盘日期(YYYY-MM-DD),缺省取最新有数据日
|
||||
focus: str = "" # 可选:用户追加的复盘关注点
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_market(request: Request, req: AnalyzeRequest):
|
||||
"""AI 大盘复盘 — NDJSON 流式返回。
|
||||
|
||||
装配市场总览(指数/涨跌/连板/封板/板块/情绪雷达)→ 复盘提示词 →
|
||||
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
|
||||
|
||||
协议:
|
||||
{"type":"meta","as_of","emotion_score","emotion_label","summary"}
|
||||
{"type":"delta","content":"..."}
|
||||
{"type":"error","message":"..."}
|
||||
{"type":"done"}
|
||||
"""
|
||||
from datetime import date as date_cls
|
||||
|
||||
repo = request.app.state.repo
|
||||
quote_service = getattr(request.app.state, "quote_service", None)
|
||||
depth_service = getattr(request.app.state, "depth_service", None)
|
||||
|
||||
as_of = None
|
||||
if req.as_of:
|
||||
try:
|
||||
as_of = date_cls.fromisoformat(req.as_of)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 报告 CRUD(历史复盘持久化)
|
||||
# ================================================================
|
||||
|
||||
class SaveReportRequest(BaseModel):
|
||||
"""保存一条 AI 大盘复盘报告。"""
|
||||
as_of: str
|
||||
focus: str = ""
|
||||
content: str
|
||||
summary: str = ""
|
||||
emotion_score: int | None = None
|
||||
emotion_label: str = ""
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史复盘(按时间降序,后端已裁剪到上限)。"""
|
||||
return {"reports": market_recap_reports.list_reports()}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条复盘报告。"""
|
||||
report = market_recap_reports.save_report({
|
||||
"as_of": req.as_of,
|
||||
"focus": req.focus,
|
||||
"content": req.content,
|
||||
"summary": req.summary,
|
||||
"emotion_score": req.emotion_score,
|
||||
"emotion_label": req.emotion_label,
|
||||
})
|
||||
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。
|
||||
# 内部 try/except 静默降级, 不影响归档返回值。
|
||||
from app.jobs.daily_pipeline import _maybe_push_review
|
||||
_maybe_push_review(req.content, {
|
||||
"as_of": req.as_of,
|
||||
"emotion_label": req.emotion_label,
|
||||
})
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条复盘报告。"""
|
||||
ok = market_recap_reports.delete_report(report_id)
|
||||
return {"ok": ok}
|
||||
@@ -47,9 +47,12 @@ class RuleModel(BaseModel):
|
||||
logic: str = "and" # and | or
|
||||
cooldown_seconds: int = 3600
|
||||
severity: str = "info" # info | warn | critical
|
||||
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 开发中)
|
||||
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定)
|
||||
webhook_enabled: bool = False
|
||||
message: str = ""
|
||||
# ladder 专属 (连板梯队封单监控)
|
||||
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
|
||||
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
|
||||
|
||||
|
||||
# ── 字段选项 ─────────────────────────────────────────────
|
||||
@@ -128,6 +131,16 @@ def list_rules(request: Request):
|
||||
@router.post("")
|
||||
def save_rule(req: RuleModel, request: Request):
|
||||
rule = monitor_rules.normalize(req.model_dump())
|
||||
# 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。
|
||||
# 无能力时拒绝创建, 避免规则存了却永远无法触发。
|
||||
if rule.get("type") == "ladder":
|
||||
from app.tickflow.capabilities import Cap
|
||||
capset = getattr(request.app.state, "capabilities", None)
|
||||
if capset is None or not capset.has(Cap.DEPTH5_BATCH):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置",
|
||||
)
|
||||
# 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动)
|
||||
existing = monitor_rules.load_one(_data_dir(request), rule["id"])
|
||||
if existing and existing.get("created_at"):
|
||||
@@ -233,3 +246,254 @@ def seed_demo_rules(request: Request):
|
||||
i += 1
|
||||
_sync_engine(request)
|
||||
return {"ok": True, "generated": len(created), "ids": created}
|
||||
|
||||
|
||||
# ── 封单监控模拟触发 (Dev 调试用) ─────────────────────
|
||||
@router.post("/test-ladder")
|
||||
def test_ladder(request: Request):
|
||||
"""模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。
|
||||
|
||||
用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame,
|
||||
跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。
|
||||
"""
|
||||
import polars as pl
|
||||
|
||||
repo = request.app.state.repo
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
engine = getattr(request.app.state, "monitor_engine", None)
|
||||
|
||||
if not depth_svc:
|
||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
||||
if not engine or not engine.has_rule_type("ladder"):
|
||||
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
|
||||
|
||||
# 最新交易日
|
||||
latest = repo.enriched_latest_date()
|
||||
if not latest:
|
||||
raise HTTPException(status_code=400, detail="无 enriched 数据")
|
||||
|
||||
# 取涨停+跌停封单 {symbol: vol}
|
||||
sealed: dict[str, int] = {}
|
||||
for is_down in (False, True):
|
||||
m = depth_svc.get_sealed_map(latest, is_down=is_down)
|
||||
for sym, info in m.items():
|
||||
vol = (info or {}).get("vol")
|
||||
if vol and vol > 0:
|
||||
sealed[sym] = vol
|
||||
|
||||
if not sealed:
|
||||
raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)")
|
||||
|
||||
# 取这些 symbol 的 close (算封单额用)
|
||||
enriched_today, _ = repo.get_enriched_latest()
|
||||
cols = ["symbol", "close", "change_pct"]
|
||||
avail = [c for c in cols if c in enriched_today.columns]
|
||||
mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys())))
|
||||
|
||||
# 注入 _sealed_vol
|
||||
sealed_df = pl.DataFrame({
|
||||
"symbol": list(sealed.keys()),
|
||||
"_sealed_vol": list(sealed.values()),
|
||||
})
|
||||
mock = mock.join(sealed_df, on="symbol", how="inner")
|
||||
|
||||
# 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire)
|
||||
ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)]
|
||||
all_events = []
|
||||
not_triggered = []
|
||||
|
||||
for rule in ladder_rules:
|
||||
syms = rule.get("symbols", [])
|
||||
sym = syms[0] if syms else None
|
||||
metric = rule.get("metric", "sealed_vol")
|
||||
thr = rule.get("threshold", 0)
|
||||
direction = rule.get("direction", "up")
|
||||
warn_label = "炸板预警" if direction == "up" else "翘板预警"
|
||||
|
||||
# 取该 symbol 的封单数据
|
||||
cur_vol = sealed.get(sym) if sym else None
|
||||
row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear()
|
||||
cur_close = row["close"][0] if len(row) and "close" in row.columns else None
|
||||
cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None
|
||||
cur_val = cur_amt if metric == "sealed_amount" else cur_vol
|
||||
|
||||
# 条件判断: 封单 > 0 且 比较值 <= 阈值
|
||||
if cur_val is not None and cur_val > 0 and cur_val <= thr:
|
||||
if metric == "sealed_amount":
|
||||
sv_text = f"{cur_val / 1e4:.0f}万元"
|
||||
th_text = f"{thr / 1e4:.0f}万元"
|
||||
else:
|
||||
sv_text = f"{cur_val:,.0f} 手"
|
||||
th_text = f"{thr:,.0f} 手"
|
||||
all_events.append({
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"symbol": sym,
|
||||
"name": sym,
|
||||
"type": warn_label,
|
||||
"message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}",
|
||||
"severity": rule.get("severity", "warn"),
|
||||
"sealed_value": cur_val,
|
||||
"sealed_metric": metric,
|
||||
"current_sealed_vol": cur_vol,
|
||||
"current_sealed_amount": cur_amt,
|
||||
})
|
||||
else:
|
||||
reason = "封单数据缺失" if cur_val is None else (
|
||||
f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0"
|
||||
)
|
||||
not_triggered.append({
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"symbol": sym,
|
||||
"metric": metric,
|
||||
"threshold": thr,
|
||||
"current_value": cur_val,
|
||||
"current_sealed_vol": cur_vol,
|
||||
"current_sealed_amount": cur_amt,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"as_of": str(latest),
|
||||
"sealed_count": len(sealed),
|
||||
"triggered": all_events,
|
||||
"not_triggered": not_triggered,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/trigger-ladder")
|
||||
def trigger_ladder(request: Request):
|
||||
"""真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。
|
||||
|
||||
与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE,
|
||||
让用户看到真实的预警通知。绕过 cooldown 强制触发。
|
||||
"""
|
||||
import time
|
||||
from app.services import alert_store
|
||||
|
||||
repo = request.app.state.repo
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
engine = getattr(request.app.state, "monitor_engine", None)
|
||||
quote_svc = getattr(request.app.state, "quote_service", None)
|
||||
|
||||
if not depth_svc:
|
||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
||||
if not engine or not engine.has_rule_type("ladder"):
|
||||
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
|
||||
|
||||
latest = repo.enriched_latest_date()
|
||||
if not latest:
|
||||
raise HTTPException(status_code=400, detail="无 enriched 数据")
|
||||
|
||||
# 取封单
|
||||
sealed: dict[str, int] = {}
|
||||
for is_down in (False, True):
|
||||
m = depth_svc.get_sealed_map(latest, is_down=is_down)
|
||||
for sym, info in m.items():
|
||||
vol = (info or {}).get("vol")
|
||||
if vol and vol > 0:
|
||||
sealed[sym] = vol
|
||||
if not sealed:
|
||||
raise HTTPException(status_code=400, detail="无封单数据")
|
||||
|
||||
# 构造真实 rule_events (与 _evaluate_ladder 产出格式一致)
|
||||
import polars as pl
|
||||
enriched_today, _ = repo.get_enriched_latest()
|
||||
cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns]
|
||||
mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys())))
|
||||
sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())})
|
||||
mock = mock.join(sealed_df, on="symbol", how="inner")
|
||||
|
||||
now = time.time()
|
||||
rule_events: list[dict] = []
|
||||
name_map = {}
|
||||
try:
|
||||
inst = repo.get_instruments()
|
||||
if not inst.is_empty() and "name" in inst.columns:
|
||||
name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
for rule in engine.rules.values():
|
||||
if rule.get("type") != "ladder" or not rule.get("enabled", True):
|
||||
continue
|
||||
sym = rule.get("symbols", [""])[0] if rule.get("symbols") else ""
|
||||
metric = rule.get("metric", "sealed_vol")
|
||||
thr = rule.get("threshold", 0)
|
||||
direction = rule.get("direction", "up")
|
||||
warn_label = "炸板预警" if direction == "up" else "翘板预警"
|
||||
|
||||
row = mock.filter(pl.col("symbol") == sym)
|
||||
if row.is_empty():
|
||||
continue
|
||||
cur_vol = row["_sealed_vol"][0]
|
||||
close_v = row["close"][0] if "close" in row.columns else None
|
||||
cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol
|
||||
if not cur_val or cur_val <= 0 or cur_val > thr:
|
||||
continue # 不满足条件, 跳过
|
||||
|
||||
if metric == "sealed_amount":
|
||||
sv_text = f"{cur_val / 1e4:.0f}万元"
|
||||
th_text = f"{thr / 1e4:.0f}万元"
|
||||
else:
|
||||
sv_text = f"{cur_val:,.0f} 手"
|
||||
th_text = f"{thr:,.0f} 手"
|
||||
|
||||
rule_events.append({
|
||||
"ts": int(now * 1000),
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"source": "ladder",
|
||||
"type": warn_label,
|
||||
"symbol": sym,
|
||||
"name": name_map.get(sym, sym),
|
||||
"message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}",
|
||||
"price": close_v,
|
||||
"change_pct": row["change_pct"][0] if "change_pct" in row.columns else None,
|
||||
"signals": [],
|
||||
"severity": rule.get("severity", "warn"),
|
||||
"conditions": [],
|
||||
"logic": "and",
|
||||
"sealed_value": cur_val,
|
||||
"sealed_metric": metric,
|
||||
})
|
||||
|
||||
if not rule_events:
|
||||
raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)")
|
||||
|
||||
# 1. 落盘到 alerts.jsonl
|
||||
try:
|
||||
alert_store.append_many(repo.store.data_dir, rule_events)
|
||||
except Exception as e: # noqa: BLE001
|
||||
pass # 落盘失败不阻断推送
|
||||
|
||||
# 2. SSE 推送 (入 pending_alerts 队列)
|
||||
if quote_svc:
|
||||
sse_alerts = [{
|
||||
"source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"],
|
||||
"strategy_id": None, "symbol": ev["symbol"], "name": ev["name"],
|
||||
"message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"],
|
||||
"signals": ev["signals"], "severity": ev["severity"],
|
||||
"conditions": ev["conditions"], "logic": ev["logic"],
|
||||
} for ev in rule_events]
|
||||
try:
|
||||
with quote_svc._lock:
|
||||
quote_svc._pending_alerts.extend(sse_alerts)
|
||||
quote_svc._alert_event.set()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 3. 飞书推送
|
||||
if quote_svc:
|
||||
try:
|
||||
quote_svc._maybe_send_webhook(rule_events, engine)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"triggered": len(rule_events),
|
||||
"events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events],
|
||||
}
|
||||
|
||||
@@ -343,216 +343,18 @@ def _pct_band_rows(values: list[float]) -> list[dict]:
|
||||
|
||||
|
||||
def _build_overview(request: Request, as_of: date | None = None) -> dict:
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
as_of = as_of or svc.latest_date()
|
||||
status = _quote_status(request)
|
||||
indices = _index_quotes(request, as_of)
|
||||
"""装配市场总览(委托给 services.market_overview_builder,保持行为一致)。
|
||||
|
||||
if not as_of:
|
||||
return {
|
||||
"as_of": None,
|
||||
"quote_status": status,
|
||||
"indices": indices,
|
||||
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
|
||||
"amount": {"total": 0, "avg": 0},
|
||||
"boards": [],
|
||||
"limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []},
|
||||
"distribution": [],
|
||||
"trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0},
|
||||
"activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1},
|
||||
"radar": [],
|
||||
"emotion": {"score": 50, "label": "暂无"},
|
||||
"top_gainers": [],
|
||||
"top_losers": [],
|
||||
"turnover_leaders": [],
|
||||
"active_leaders": [],
|
||||
"concept_rank": {"leading": [], "lagging": []},
|
||||
"industry_rank": {"leading": [], "lagging": []},
|
||||
}
|
||||
|
||||
df = svc._load_enriched_for_date(as_of)
|
||||
if df.is_empty():
|
||||
rows: list[dict] = []
|
||||
else:
|
||||
cols = [
|
||||
"symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume",
|
||||
"vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down",
|
||||
"ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low",
|
||||
]
|
||||
df = df.select([c for c in cols if c in df.columns])
|
||||
rows = df.to_dicts()
|
||||
|
||||
# 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径
|
||||
if rows and "volume" in rows[0]:
|
||||
rows = [r for r in rows
|
||||
if (_finite(r.get("volume")) or 0) > 0
|
||||
or (_finite(r.get("change_pct")) or 0) != 0]
|
||||
|
||||
total = len(rows)
|
||||
up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0)
|
||||
down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0)
|
||||
flat = max(0, total - up - down)
|
||||
up_pct = up / total * 100 if total else 0
|
||||
down_pct = down / total * 100 if total else 0
|
||||
|
||||
amounts = [_finite(r.get("amount")) or 0 for r in rows]
|
||||
total_amount = sum(amounts)
|
||||
avg_amount = total_amount / total if total else 0
|
||||
|
||||
pct_values = [_finite(r.get("change_pct")) for r in rows]
|
||||
pct_values = [v for v in pct_values if v is not None]
|
||||
avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0
|
||||
median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0
|
||||
strong_up = sum(1 for v in pct_values if v >= 0.03)
|
||||
strong_down = sum(1 for v in pct_values if v <= -0.03)
|
||||
|
||||
limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0)
|
||||
broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up")))
|
||||
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
|
||||
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
|
||||
|
||||
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
sealed_ready = False
|
||||
fake_up = 0
|
||||
fake_down = 0
|
||||
if depth_svc:
|
||||
up_map = depth_svc.get_sealed_map(as_of, is_down=False)
|
||||
down_map = depth_svc.get_sealed_map(as_of, is_down=True)
|
||||
sealed_ready = bool(up_map or down_map) and depth_svc.is_sealed_ready(as_of)
|
||||
if up_map:
|
||||
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
|
||||
if down_map:
|
||||
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
|
||||
if sealed_ready:
|
||||
limit_up = max(0, limit_up - fake_up)
|
||||
limit_down = max(0, limit_down - fake_down)
|
||||
|
||||
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
|
||||
|
||||
def above_ma_count(ma_key: str) -> int:
|
||||
return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0)))
|
||||
|
||||
above_ma5 = above_ma_count("ma5")
|
||||
above_ma20 = above_ma_count("ma20")
|
||||
above_ma60 = above_ma_count("ma60")
|
||||
new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0)))
|
||||
new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0)))
|
||||
|
||||
turnovers = [_finite(r.get("turnover_rate")) for r in rows]
|
||||
turnovers = [v for v in turnovers if v is not None]
|
||||
avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0
|
||||
high_turnover = sum(1 for v in turnovers if v >= 5)
|
||||
|
||||
boards_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = _board(str(r.get("symbol") or ""))
|
||||
item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0})
|
||||
item["count"] += 1
|
||||
change = _finite(r.get("change_pct")) or 0
|
||||
if change > 0:
|
||||
item["up"] += 1
|
||||
elif change < 0:
|
||||
item["down"] += 1
|
||||
item["amount"] += _finite(r.get("amount")) or 0
|
||||
boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True)
|
||||
for b in boards:
|
||||
count = b["count"] or 1
|
||||
b["up_pct"] = b["up"] / count * 100
|
||||
|
||||
tiers_map: dict[int, int] = {}
|
||||
for r in rows:
|
||||
n = int(_finite(r.get("consecutive_limit_ups")) or 0)
|
||||
if n > 0:
|
||||
tiers_map[n] = tiers_map.get(n, 0) + 1
|
||||
tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])]
|
||||
|
||||
index_changes = [_finite(r.get("change_pct")) for r in indices]
|
||||
index_changes = [v for v in index_changes if v is not None]
|
||||
avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0
|
||||
vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows]
|
||||
vol_ratios = [v for v in vol_ratios if v is not None]
|
||||
avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1
|
||||
high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5)
|
||||
|
||||
concept_rank = _dimension_rank(rows, request, "concept")
|
||||
industry_rank = _dimension_rank(rows, request, "industry", level=2)
|
||||
|
||||
strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0
|
||||
high_vol_pct = high_vol_ratio / total * 100 if total else 0
|
||||
strong_down_pct = strong_down / total * 100 if total else 0
|
||||
tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2)
|
||||
mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]]
|
||||
mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0)
|
||||
mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0
|
||||
mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50
|
||||
|
||||
radar = [
|
||||
{"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)},
|
||||
{"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)},
|
||||
{"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)},
|
||||
{"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)},
|
||||
{"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)},
|
||||
{"key": "mainline", "label": "主线", "value": mainline_score},
|
||||
]
|
||||
emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50
|
||||
if emotion_score >= 70:
|
||||
emotion_label = "强势"
|
||||
elif emotion_score >= 55:
|
||||
emotion_label = "偏暖"
|
||||
elif emotion_score >= 45:
|
||||
emotion_label = "震荡"
|
||||
elif emotion_score >= 30:
|
||||
emotion_label = "偏冷"
|
||||
else:
|
||||
emotion_label = "冰点"
|
||||
|
||||
return _json_safe({
|
||||
"as_of": str(as_of),
|
||||
"quote_status": status,
|
||||
"indices": indices,
|
||||
"breadth": {
|
||||
"total": total,
|
||||
"up": up,
|
||||
"down": down,
|
||||
"flat": flat,
|
||||
"up_pct": up_pct,
|
||||
"down_pct": down_pct,
|
||||
"avg_pct": avg_pct,
|
||||
"median_pct": median_pct,
|
||||
"strong_up": strong_up,
|
||||
"strong_down": strong_down,
|
||||
},
|
||||
"amount": {"total": total_amount, "avg": avg_amount},
|
||||
"boards": boards,
|
||||
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down},
|
||||
"distribution": _pct_band_rows(pct_values),
|
||||
"trend": {
|
||||
"above_ma5": above_ma5,
|
||||
"above_ma20": above_ma20,
|
||||
"above_ma60": above_ma60,
|
||||
"above_ma5_pct": above_ma5 / total * 100 if total else 0,
|
||||
"above_ma20_pct": above_ma20 / total * 100 if total else 0,
|
||||
"above_ma60_pct": above_ma60 / total * 100 if total else 0,
|
||||
"new_high": new_high,
|
||||
"new_low": new_low,
|
||||
},
|
||||
"activity": {
|
||||
"avg_turnover": avg_turnover,
|
||||
"high_turnover": high_turnover,
|
||||
"high_vol_ratio": high_vol_ratio,
|
||||
"vol_ratio": avg_vol_ratio,
|
||||
},
|
||||
"radar": radar,
|
||||
"emotion": {"score": emotion_score, "label": emotion_label},
|
||||
"top_gainers": _top_rows(rows, "change_pct", True),
|
||||
"top_losers": _top_rows(rows, "change_pct", False),
|
||||
"turnover_leaders": _top_rows(rows, "amount", True),
|
||||
"active_leaders": _top_rows(rows, "turnover_rate", True),
|
||||
"concept_rank": concept_rank,
|
||||
"industry_rank": industry_rank,
|
||||
})
|
||||
逻辑已抽离至 build_market_overview,以解耦对 Request 的依赖,
|
||||
使大盘复盘等无 Request 的调用方可复用同一装配逻辑。
|
||||
"""
|
||||
from app.services.market_overview_builder import build_market_overview
|
||||
return build_market_overview(
|
||||
repo=request.app.state.repo,
|
||||
quote_service=getattr(request.app.state, "quote_service", None),
|
||||
depth_service=getattr(request.app.state, "depth_service", None),
|
||||
as_of=as_of,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/market")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""涨幅轮动矩阵 API。
|
||||
|
||||
供「概念分析 → 涨幅RPS轮动」对话框调用。返回最近 N 个交易日的概念涨幅
|
||||
排名矩阵:每列(日期)各自把所有概念按当天涨幅从高到低排序。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import rps_rotation
|
||||
from app.services.concept_rotation_analyzer import analyze_rotation_stream
|
||||
|
||||
router = APIRouter(prefix="/api/rps", tags=["rps"])
|
||||
|
||||
|
||||
@router.get("/rotation")
|
||||
def get_rotation(
|
||||
request: Request,
|
||||
days: int = Query(12, ge=7, le=30, description="最近 N 个交易日(7-30)"),
|
||||
) -> dict:
|
||||
"""概念涨幅轮动矩阵。
|
||||
|
||||
Returns:
|
||||
dates: 日期字符串列表(最新在最前)
|
||||
columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序
|
||||
concept_count: 去重概念总数
|
||||
"""
|
||||
return rps_rotation.build_rps_rotation(request.app.state.repo, days)
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 概念轮动分析请求。"""
|
||||
days: int = 12 # 分析最近 N 个交易日
|
||||
focus: str = "" # 用户追加的关注点
|
||||
|
||||
|
||||
@router.post("/rotation-analyze")
|
||||
async def analyze_rotation(request: Request, req: AnalyzeRequest):
|
||||
"""AI 概念轮动分析 — NDJSON 流式返回。
|
||||
|
||||
装配轮动矩阵信号 + 大盘背景 → 分析提示词 → 流式调用 LLM →
|
||||
逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
|
||||
|
||||
协议:
|
||||
{"type":"meta","days","summary"}
|
||||
{"type":"delta","content":"..."}
|
||||
{"type":"error","message":"..."}
|
||||
{"type":"done"}
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
quote_service = getattr(request.app.state, "quote_service", None)
|
||||
depth_service = getattr(request.app.state, "depth_service", None)
|
||||
days = max(7, min(30, req.days))
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_rotation_stream(
|
||||
repo, days, req.focus, quote_service, depth_service,
|
||||
):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -278,11 +278,35 @@ def get_cached(
|
||||
request: Request,
|
||||
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
|
||||
):
|
||||
"""读取策略结果缓存。返回 None 表示无缓存。"""
|
||||
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。
|
||||
|
||||
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
|
||||
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
|
||||
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
|
||||
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
|
||||
"""
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
cached = strategy_cache.read_cache(data_dir)
|
||||
if cached is None:
|
||||
cached = {"as_of": None, "results": {}, "updated_at": None}
|
||||
|
||||
# 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果
|
||||
monitor_engine = getattr(request.app.state, "monitor_engine", None)
|
||||
if monitor_engine is not None:
|
||||
realtime_results = monitor_engine.latest_strategy_results()
|
||||
if realtime_results:
|
||||
results = dict(cached.get("results") or {})
|
||||
results.update(realtime_results)
|
||||
cached = dict(cached)
|
||||
cached["results"] = results
|
||||
# 有实时数据时, 以最新时间戳为准
|
||||
import time as _time
|
||||
cached["updated_at"] = int(_time.time() * 1000)
|
||||
|
||||
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
|
||||
if not cached.get("results") and cached.get("as_of") is None:
|
||||
return {"as_of": None, "results": {}, "updated_at": None}
|
||||
|
||||
ext_values = _load_ext_value_maps(request.app.state.repo, ext_columns)
|
||||
return _cache_payload_with_ext(cached, ext_values)
|
||||
|
||||
|
||||
@@ -7,11 +7,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import secrets_store
|
||||
from app.services.financial_sync import financial_scheduler
|
||||
from app.tickflow import client as tf_client
|
||||
from app.tickflow.policy import (
|
||||
detect_capabilities,
|
||||
@@ -30,25 +29,34 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
DEFAULT_PAID_ENDPOINT = "https://api.tickflow.org"
|
||||
|
||||
|
||||
def _sync_financial_scheduler_caps(app_state, capset) -> None:
|
||||
"""把重新探测出的能力同步给财务调度器。
|
||||
|
||||
app.state.capabilities 在此已更新, 但 FinancialScheduler 在启动时捕获的是旧引用,
|
||||
需显式刷新, 否则用户升级到 Expert 后点「全部同步」仍会因调度器读旧 capset 而被拒。
|
||||
"""
|
||||
fs = getattr(app_state, "financial_scheduler", None)
|
||||
if fs is None:
|
||||
return
|
||||
try:
|
||||
fs.update_capabilities(capset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logging.getLogger(__name__).warning("update financial_scheduler capabilities failed: %s", e)
|
||||
|
||||
|
||||
class TickflowKeyIn(BaseModel):
|
||||
api_key: str
|
||||
|
||||
|
||||
def _sync_financial_scheduler(request: Request, capset) -> None:
|
||||
"""Key 变更后同步财务调度器状态,无需重启服务。"""
|
||||
try:
|
||||
financial_scheduler.update(request.app.state.repo.store.data_dir, capset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("financial_scheduler update failed: %s", e)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_settings() -> dict:
|
||||
"""返回当前配置概况(Key 脱敏)。"""
|
||||
from app.config import settings
|
||||
from app.services import preferences
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command
|
||||
|
||||
key = secrets_store.get_tickflow_key()
|
||||
ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider)
|
||||
return {
|
||||
"mode": tf_client.current_mode(),
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
@@ -61,12 +69,14 @@ def get_settings() -> dict:
|
||||
# 首次使用引导
|
||||
"onboarding_completed": preferences.get_onboarding_completed(),
|
||||
# AI 配置
|
||||
"ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider),
|
||||
"ai_provider": ai_provider,
|
||||
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
|
||||
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
|
||||
"has_ai_key": bool(secrets_store.get_ai_key()),
|
||||
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
|
||||
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
|
||||
"ai_configured": ai_configured(ai_provider),
|
||||
"ai_model": current_ai_model(),
|
||||
"ai_codex_command": current_codex_command(),
|
||||
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +86,9 @@ class SwitchEndpointIn(BaseModel):
|
||||
|
||||
@router.post("/switch_endpoint")
|
||||
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
"""切换数据源端点并立即生效。
|
||||
"""切换 TickFlow 端点并立即生效。
|
||||
|
||||
端点切换仅对付费档(starter+,走付费 API 节点)有意义;
|
||||
端点切换仅对付费档(starter+,走 api.tickflow.org)有意义;
|
||||
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
|
||||
"""
|
||||
# none/free 档没有付费端点权限,禁止切换
|
||||
@@ -102,7 +112,7 @@ def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
|
||||
@router.post("/tickflow-key")
|
||||
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
"""保存数据源 API Key 并立即重新探测能力。
|
||||
"""保存 TickFlow API Key 并立即重新探测能力。
|
||||
|
||||
先探后存(关键改动,修复乱填 key 也会被持久化的问题):
|
||||
1. 临时用新 key 探测(付费端点),判定档位
|
||||
@@ -112,7 +122,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑)
|
||||
|
||||
端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用,
|
||||
故自动切到默认付费端点;free 档则清除自定义端点。
|
||||
故自动切到默认付费端点(api.tickflow.org);free 档则清除自定义端点。
|
||||
"""
|
||||
from app.tickflow.policy import (
|
||||
base_tier_name, is_invalid_key,
|
||||
@@ -129,7 +139,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
|
||||
# ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 =====
|
||||
if is_invalid_key() or base_tier_name() == "none":
|
||||
@@ -138,7 +148,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
tf_client.reset_clients()
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "invalid",
|
||||
@@ -195,7 +205,7 @@ def clear_tickflow_key(request: Request) -> dict:
|
||||
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -223,13 +233,15 @@ class AiSettingsIn(BaseModel):
|
||||
base_url: str = ""
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
daily_token_budget: int = 500_000
|
||||
codex_command: str = ""
|
||||
user_agent: str = ""
|
||||
|
||||
|
||||
@router.post("/ai")
|
||||
def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"""保存 AI 配置(全部持久化到 secrets.json)"""
|
||||
from app.config import settings
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider, current_codex_command, normalize_codex_command
|
||||
|
||||
updates: dict = {}
|
||||
if req.provider:
|
||||
@@ -245,15 +257,52 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
else:
|
||||
secrets_store.clear("ai_api_key")
|
||||
settings.ai_api_key = ""
|
||||
if req.model:
|
||||
if req.provider == "codex_cli" and not req.model:
|
||||
secrets_store.clear("ai_model")
|
||||
settings.ai_model = ""
|
||||
elif req.model:
|
||||
updates["ai_model"] = req.model
|
||||
settings.ai_model = req.model
|
||||
updates["ai_daily_token_budget"] = req.daily_token_budget
|
||||
settings.ai_daily_token_budget = req.daily_token_budget
|
||||
if req.provider == "codex_cli":
|
||||
try:
|
||||
codex_command = normalize_codex_command(req.codex_command)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
updates["ai_codex_command"] = codex_command
|
||||
settings.ai_codex_command = codex_command
|
||||
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
|
||||
updates["ai_user_agent"] = req.user_agent
|
||||
settings.ai_user_agent = req.user_agent
|
||||
|
||||
if updates:
|
||||
secrets_store.save(updates)
|
||||
|
||||
provider = current_ai_provider()
|
||||
return {
|
||||
"ok": True,
|
||||
"ai_provider": provider,
|
||||
"ai_model": current_ai_model(),
|
||||
"ai_codex_command": current_codex_command(),
|
||||
"ai_configured": ai_configured(provider),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/ai")
|
||||
def clear_ai_settings() -> dict:
|
||||
"""一键清空 AI 配置(provider / base_url / api_key / model)。
|
||||
|
||||
保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
|
||||
# 同步重置运行时内存(provider 回默认值,其余置空)
|
||||
settings.ai_provider = "openai_compat"
|
||||
settings.ai_base_url = ""
|
||||
settings.ai_api_key = ""
|
||||
settings.ai_model = ""
|
||||
settings.ai_codex_command = "codex"
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -280,6 +329,16 @@ def get_preferences() -> dict:
|
||||
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
||||
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
||||
"minute_sync_days": preferences.get_minute_sync_days(),
|
||||
"daily_data_provider": preferences.get_daily_data_provider(),
|
||||
"adj_factor_provider": preferences.get_adj_factor_provider(),
|
||||
"minute_data_provider": preferences.get_minute_data_provider(),
|
||||
"realtime_data_provider": preferences.get_realtime_data_provider(),
|
||||
"realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(),
|
||||
**preferences.get_realtime_quote_scope(),
|
||||
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
|
||||
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
|
||||
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
|
||||
"pipeline_index_symbols": preferences.get_pipeline_index_symbols(),
|
||||
"pipeline_schedule": preferences.get_pipeline_schedule(),
|
||||
"instruments_schedule": preferences.get_instruments_schedule(),
|
||||
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
||||
@@ -290,6 +349,9 @@ def get_preferences() -> dict:
|
||||
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
|
||||
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
|
||||
"system_notify_enabled": preferences.get_system_notify_enabled(),
|
||||
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
|
||||
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
|
||||
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
"nav_hidden": preferences.get_nav_hidden(),
|
||||
@@ -297,6 +359,8 @@ def get_preferences() -> dict:
|
||||
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
|
||||
"depth_polling_interval": preferences.get_depth_polling_interval(),
|
||||
"depth_finalize_time": preferences.get_depth_finalize_time(),
|
||||
"review_schedule": preferences.get_review_schedule(),
|
||||
"review_push_channels": preferences.get_review_push_channels(),
|
||||
}
|
||||
|
||||
|
||||
@@ -377,11 +441,19 @@ class RealtimeQuotesPrefs(BaseModel):
|
||||
realtime_quotes_enabled: bool
|
||||
|
||||
|
||||
class RealtimeQuoteScopePrefs(BaseModel):
|
||||
realtime_pull_stock: bool | None = None
|
||||
realtime_pull_etf: bool | None = None
|
||||
realtime_pull_index: bool | None = None
|
||||
realtime_index_mode: str | None = None
|
||||
realtime_index_symbols: list[str] | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-quotes")
|
||||
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
"""保存全局实时行情开关。
|
||||
|
||||
none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False,
|
||||
none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。
|
||||
前端据此把开关置灰 / 回弹。
|
||||
"""
|
||||
from app.services import preferences
|
||||
@@ -394,6 +466,9 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
if qs:
|
||||
qs.disable()
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
|
||||
if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols():
|
||||
preferences.save({"realtime_quotes_enabled": False})
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"}
|
||||
|
||||
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
|
||||
if qs:
|
||||
@@ -405,6 +480,26 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-quote-scope")
|
||||
def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict:
|
||||
"""保存盘中实时行情范围;独立于盘后管道范围。"""
|
||||
from app.services import preferences
|
||||
cfg = req.model_dump(exclude_none=True)
|
||||
return preferences.set_realtime_quote_scope(cfg)
|
||||
|
||||
|
||||
class RealtimeWatchlistPrefs(BaseModel):
|
||||
symbols: list[str] = []
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-watchlist")
|
||||
def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict:
|
||||
"""兼容旧入口;Free 实时标的由自选页前 5 个决定。"""
|
||||
from app.services import preferences
|
||||
symbols = preferences.set_realtime_watchlist_symbols(req.symbols)
|
||||
return {"realtime_watchlist_symbols": symbols}
|
||||
|
||||
|
||||
class IndicesNavPinnedPrefs(BaseModel):
|
||||
indices_nav_pinned: bool
|
||||
|
||||
@@ -457,6 +552,34 @@ def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Reques
|
||||
return result
|
||||
|
||||
|
||||
class PipelinePullTypesIn(BaseModel):
|
||||
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
|
||||
pipeline_pull_a_share: bool | None = None
|
||||
pipeline_pull_etf: bool | None = None
|
||||
pipeline_pull_index: bool | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/pipeline-pull-types")
|
||||
def update_pipeline_pull_types(req: PipelinePullTypesIn) -> dict:
|
||||
"""更新盘后管道拉取内容开关。"""
|
||||
from app.services import preferences
|
||||
cfg = req.model_dump(exclude_none=True)
|
||||
return preferences.set_pipeline_pull_types(cfg)
|
||||
|
||||
|
||||
class PipelineIndexSymbolsIn(BaseModel):
|
||||
"""指数自定义拉取代码(逗号/换行/空格分隔,空串表示全量)。"""
|
||||
symbols: str = ""
|
||||
|
||||
|
||||
@router.put("/preferences/pipeline-index-symbols")
|
||||
def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
|
||||
"""保存指数自定义拉取代码。"""
|
||||
from app.services import preferences
|
||||
symbols = preferences.set_pipeline_index_symbols(req.symbols)
|
||||
return {"pipeline_index_symbols": symbols}
|
||||
|
||||
|
||||
class QuoteIntervalIn(BaseModel):
|
||||
interval: float
|
||||
|
||||
@@ -477,6 +600,50 @@ def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
|
||||
return {"system_notify_enabled": saved}
|
||||
|
||||
|
||||
class FeishuWebhookPrefsIn(BaseModel):
|
||||
url: str
|
||||
secret: str = ""
|
||||
|
||||
|
||||
@router.put("/preferences/feishu-webhook")
|
||||
def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
|
||||
"""飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。
|
||||
|
||||
- url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。
|
||||
- secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。
|
||||
"""
|
||||
from app.services import preferences
|
||||
from app.services import webhook_adapter
|
||||
|
||||
url = (req.url or "").strip()
|
||||
if url and not webhook_adapter.is_valid_feishu_url(url):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Webhook 地址非法, 需为飞书自定义机器人地址 "
|
||||
"(https://open.feishu.cn/open-apis/bot/v2/hook/...)",
|
||||
)
|
||||
saved_url = preferences.set_feishu_webhook_url(url)
|
||||
saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip())
|
||||
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
|
||||
|
||||
|
||||
class WebhookEnabledDefaultIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/webhook-enabled-default")
|
||||
def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
|
||||
"""新建监控规则时是否默认勾选「飞书推送」。
|
||||
|
||||
数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。
|
||||
单条规则仍可在规则编辑页独立修改此项。
|
||||
"""
|
||||
from app.services import preferences
|
||||
|
||||
saved = preferences.set_webhook_enabled_default(req.enabled)
|
||||
return {"webhook_enabled_default": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/quote-interval")
|
||||
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
|
||||
"""更新行情轮询间隔。按档位自动 clamp。"""
|
||||
@@ -510,7 +677,7 @@ class TestEndpointIn(BaseModel):
|
||||
rounds: int | None = None
|
||||
|
||||
|
||||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取数据源官网 /endpoints.json
|
||||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取 tickflow.org/endpoints.json
|
||||
# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。
|
||||
ENDPOINTS_URL = "https://tickflow.org/endpoints.json"
|
||||
ENDPOINTS_TTL = 300.0 # 秒
|
||||
@@ -574,7 +741,7 @@ _endpoints_cache: dict = {"ts": 0.0, "data": None}
|
||||
|
||||
@router.get("/endpoints")
|
||||
def list_endpoints() -> dict:
|
||||
"""代理拉取数据源官网 /endpoints.json 并返回规范化端点列表。
|
||||
"""代理拉取 tickflow.org/endpoints.json 并返回规范化端点列表。
|
||||
|
||||
前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、
|
||||
5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。
|
||||
@@ -601,7 +768,7 @@ def list_endpoints() -> dict:
|
||||
data = {
|
||||
"version": parsed.get("version", 1),
|
||||
"description": parsed.get(
|
||||
"description", "API 端点配置"
|
||||
"description", "TickFlow API 端点配置"
|
||||
),
|
||||
"healthPath": parsed.get("healthPath", "/health"),
|
||||
"testRounds": parsed.get("testRounds", 5),
|
||||
@@ -614,7 +781,7 @@ def list_endpoints() -> dict:
|
||||
source = "fallback"
|
||||
data = {
|
||||
"version": 1,
|
||||
"description": "API 端点配置",
|
||||
"description": "TickFlow API 端点配置",
|
||||
"healthPath": "/health",
|
||||
"testRounds": 5,
|
||||
"endpoints": _FALLBACK_ENDPOINTS,
|
||||
@@ -652,7 +819,7 @@ async def _http_ping(url: str, timeout: float = 10.0) -> float | None:
|
||||
async def test_endpoint(req: TestEndpointIn) -> dict:
|
||||
"""测试端点网络延迟:对 /health 多轮探测取中位数。
|
||||
|
||||
参考官方 latency_test.py:
|
||||
参考 TickFlow 官方 latency_test.py:
|
||||
- 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时
|
||||
- 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s
|
||||
- 返回 median/min/max/success,前端显示中位数
|
||||
@@ -852,3 +1019,65 @@ def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> di
|
||||
|
||||
return sched
|
||||
|
||||
|
||||
class ReviewScheduleIn(BaseModel):
|
||||
enabled: bool
|
||||
hour: int
|
||||
minute: int
|
||||
|
||||
|
||||
@router.put("/preferences/review-schedule")
|
||||
def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict:
|
||||
"""保存定时复盘调度并立即更新 APScheduler job。
|
||||
|
||||
- enabled=True: 注册/更新 job(工作日定时生成复盘报告)
|
||||
- enabled=False: 移除 job(停止定时复盘)
|
||||
- 校验: 开启时若 AI Key 未配置则拒绝(复盘依赖 AI), 提示用户先配置。
|
||||
- 时间下限 15:00(A股收盘), 由 preferences 层强制。
|
||||
"""
|
||||
from app.services import preferences
|
||||
|
||||
if req.enabled:
|
||||
# 复盘必须有 AI Key, 否则每日报错刷日志
|
||||
from app import secrets_store
|
||||
if not secrets_store.get_ai_key():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="复盘依赖 AI,请先在「设置 → AI」配置 API Key 后再开启定时复盘",
|
||||
)
|
||||
|
||||
sched = preferences.set_review_schedule(req.enabled, req.hour, req.minute)
|
||||
|
||||
# 动态操作 APScheduler job
|
||||
from app.jobs.daily_pipeline import _register_review_job, REVIEW_JOB_ID
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
if scheduler:
|
||||
if sched["enabled"]:
|
||||
_register_review_job(scheduler, request.app.state.repo, sched["hour"], sched["minute"])
|
||||
logger.info("scheduled_review enabled @%02d:%02d mon-fri", sched["hour"], sched["minute"])
|
||||
else:
|
||||
try:
|
||||
scheduler.remove_job(REVIEW_JOB_ID)
|
||||
logger.info("scheduled_review disabled (job removed)")
|
||||
except Exception:
|
||||
pass # job 本就不存在(从未开过), 无需处理
|
||||
|
||||
return sched
|
||||
|
||||
|
||||
class ReviewPushIn(BaseModel):
|
||||
channels: list[str] # 多选: ['feishu'] 等; 空数组=不推送。微信等开发中
|
||||
|
||||
|
||||
@router.put("/preferences/review-push")
|
||||
def update_review_push(req: ReviewPushIn) -> dict:
|
||||
"""复盘推送渠道(多选) — 选定把复盘报告(手动生成 / 定时生成归档后)推送到哪些外部工具。
|
||||
|
||||
纯偏好, 与定时复盘 / 实时行情完全独立, 常驻可单独设置。空数组=不推送。
|
||||
实际推送由归档端点(POST /api/market-recap/reports)与定时任务(_run_scheduled_review)
|
||||
在归档后读取本列表逐个推送。白名单外的渠道会被过滤掉。
|
||||
"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_review_push_channels(req.channels)
|
||||
return {"review_push_channels": saved}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""个股分析 API — 关键价位 + AI 四维分析 + 报告持久化。
|
||||
|
||||
路由前缀: /api/stock-analysis
|
||||
|
||||
端点:
|
||||
GET /levels?symbol= 11 类关键价位(图表 markLine 数据源)
|
||||
POST /analyze AI 流式四维分析(NDJSON)
|
||||
GET /reports 历史报告列表
|
||||
POST /reports 保存一条报告
|
||||
DELETE /reports/{report_id} 删除一条报告
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.indicators.levels import compute_levels, summarize_levels
|
||||
from app.services import stock_reports
|
||||
from app.services.stock_analyzer import analyze_stock_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/stock-analysis", tags=["stock-analysis"])
|
||||
|
||||
|
||||
def _to_float_list(series: pl.Series) -> list:
|
||||
"""polars Series → JSON 安全的 float 列表(null/NaN → None)。"""
|
||||
out: list = []
|
||||
for v in series.to_list():
|
||||
if v is None:
|
||||
out.append(None)
|
||||
continue
|
||||
try:
|
||||
f = float(v)
|
||||
out.append(round(f, 2) if math.isfinite(f) else None)
|
||||
except (TypeError, ValueError):
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
|
||||
def _build_series(df: pl.DataFrame) -> dict:
|
||||
"""提取带状指标(布林带 / Keltner通道 / ATR止损)的每日时间序列。
|
||||
|
||||
这些指标的本质是"每日一条线",随 MA/ATR/σ 漂移,画成曲线才能体现通道形态。
|
||||
其余固定价位(枢轴/前高前低等)不在此,仍用水平 markLine。
|
||||
|
||||
返回结构(每个 value 都是按日期对齐的数组):
|
||||
{
|
||||
"boll": {"upper": [...], "lower": [...]},
|
||||
"keltner_s": {"upper": [...], "lower": [...]}, # 短期 MA20±2ATR
|
||||
"keltner_m": {"upper": [...], "lower": [...]}, # 中期 MA60±2.5ATR
|
||||
"keltner_l": {"upper": [...], "lower": [...]}, # 长期 MA120±3ATR
|
||||
"atr": {"stop_loss": [...], "take_profit": [...]}, # close∓2ATR
|
||||
}
|
||||
"""
|
||||
if df.is_empty() or "close" not in df.columns:
|
||||
return {}
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
close = df["close"]
|
||||
has_atr = "atr_14" in df.columns
|
||||
|
||||
# 布林带(上/下/中轨;中轨 = MA20,数据层已预计算)
|
||||
if "boll_upper" in df.columns and "boll_lower" in df.columns:
|
||||
out["boll"] = {
|
||||
"upper": _to_float_list(df["boll_upper"]),
|
||||
"lower": _to_float_list(df["boll_lower"]),
|
||||
"mid": _to_float_list(df["ma20"]) if "ma20" in df.columns else None,
|
||||
}
|
||||
|
||||
# Keltner 通道三档(需要 ATR)
|
||||
if has_atr:
|
||||
atr = df["atr_14"]
|
||||
# MA120 现场算(不在预计算列中)
|
||||
ma120 = df.select(pl.col("close").rolling_mean(120))["close"] if df.height >= 120 else None
|
||||
|
||||
def _channel(ma: pl.Series, n: float) -> dict:
|
||||
return {
|
||||
"upper": _to_float_list(ma + n * atr),
|
||||
"lower": _to_float_list(ma - n * atr),
|
||||
}
|
||||
|
||||
if "ma20" in df.columns:
|
||||
out["keltner_s"] = _channel(df["ma20"], 2.0)
|
||||
if "ma60" in df.columns:
|
||||
out["keltner_m"] = _channel(df["ma60"], 2.5)
|
||||
if ma120 is not None:
|
||||
out["keltner_l"] = _channel(ma120, 3.0)
|
||||
|
||||
# ATR 止损/止盈: close ± 2×ATR(跟随行情漂移的动态止损线)
|
||||
out["atr"] = {
|
||||
"stop_loss": _to_float_list(close - 2 * atr),
|
||||
"take_profit": _to_float_list(close + 2 * atr),
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/levels")
|
||||
def get_levels(
|
||||
request: Request,
|
||||
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
|
||||
days: int = Query(120, ge=30, le=500, description="计算样本天数"),
|
||||
):
|
||||
"""计算 11 类关键价位(成交密集区压力支撑 / 枢轴点 / 前高前低 /
|
||||
布林带 / Keltner短中长 / ATR止损 / 缺口 / 斐波那契 / 整数关口)。
|
||||
|
||||
返回 {levels: {sr, pivot, extreme, boll, keltner_s, keltner_m, keltner_l,
|
||||
atr_stop, gap, fib, round}, close, summary, dates, series}。
|
||||
前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine / 曲线。
|
||||
"""
|
||||
if not symbol:
|
||||
raise HTTPException(400, "symbol 不能为空")
|
||||
|
||||
repo = request.app.state.repo
|
||||
end = date.today()
|
||||
start = end - timedelta(days=days * 2)
|
||||
df = repo.get_daily(symbol, start, end)
|
||||
if df.is_empty():
|
||||
return {"levels": {"sr": [], "pivot": [], "extreme": [],
|
||||
"boll": [], "keltner_s": [], "keltner_m": [], "keltner_l": [],
|
||||
"atr_stop": [], "gap": [], "fib": [], "round": []},
|
||||
"close": None, "summary": "无数据", "symbol": symbol,
|
||||
"dates": [], "series": {}}
|
||||
|
||||
levels = compute_levels(df)
|
||||
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
|
||||
# 日期 + 带状曲线序列(供前端画 Keltner/ATR/布林带曲线)
|
||||
dates = df["date"].to_list()
|
||||
series = _build_series(df)
|
||||
return {
|
||||
"levels": levels,
|
||||
"close": close,
|
||||
"summary": summarize_levels(levels, close),
|
||||
"symbol": symbol,
|
||||
"dates": [str(d) for d in dates],
|
||||
"series": series,
|
||||
}
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 个股分析请求。"""
|
||||
symbol: str
|
||||
focus: str = "" # 可选:用户追加的分析关注点
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_stock(request: Request, req: AnalyzeRequest):
|
||||
"""AI 个股四维分析 — NDJSON 流式返回。
|
||||
|
||||
组合 K 线(技术指标)+ 财务表 + 关键价位 → 实战派提示词 →
|
||||
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
|
||||
"""
|
||||
if not req.symbol:
|
||||
raise HTTPException(400, "symbol 不能为空")
|
||||
|
||||
repo = request.app.state.repo
|
||||
data_dir = repo.store.data_dir
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 报告 CRUD(历史报告持久化)
|
||||
# ================================================================
|
||||
|
||||
class SaveReportRequest(BaseModel):
|
||||
"""保存一条 AI 个股分析报告。"""
|
||||
symbol: str
|
||||
name: str = ""
|
||||
focus: str = ""
|
||||
content: str
|
||||
summary: str = ""
|
||||
close: float | None = None
|
||||
levels: dict | None = None
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。"""
|
||||
return {"reports": stock_reports.list_reports()}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条报告。"""
|
||||
report = stock_reports.save_report({
|
||||
"symbol": req.symbol,
|
||||
"name": req.name,
|
||||
"focus": req.focus,
|
||||
"content": req.content,
|
||||
"summary": req.summary,
|
||||
"close": req.close,
|
||||
"levels": req.levels,
|
||||
})
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条报告。"""
|
||||
ok = stock_reports.delete_report(report_id)
|
||||
return {"ok": ok}
|
||||
@@ -84,6 +84,7 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
|
||||
"entry_signals": s.entry_signals,
|
||||
"exit_signals": s.exit_signals,
|
||||
"stop_loss": overrides.get("stop_loss", s.stop_loss) if overrides else s.stop_loss,
|
||||
"take_profit": getattr(s, "take_profit", None),
|
||||
"trailing_stop": getattr(s, "trailing_stop", None),
|
||||
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None),
|
||||
"trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None),
|
||||
@@ -285,12 +286,19 @@ class BuildRequest(BaseModel):
|
||||
|
||||
@router.get("/ai/status")
|
||||
def ai_status(request: Request):
|
||||
"""检查 AI 配置状态"""
|
||||
from app.config import settings
|
||||
"""Check whether the selected AI provider is configured."""
|
||||
from app import secrets_store
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider
|
||||
|
||||
has_key = bool(secrets_store.get_ai_key())
|
||||
has_model = bool(settings.ai_model)
|
||||
return {"configured": has_key and has_model, "has_key": has_key, "has_model": has_model}
|
||||
model = current_ai_model()
|
||||
provider = current_ai_provider()
|
||||
return {
|
||||
"configured": ai_configured(provider) and bool(model or provider == "codex_cli"),
|
||||
"has_key": has_key,
|
||||
"has_model": bool(model),
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{strategy_id}/source")
|
||||
@@ -314,24 +322,17 @@ def get_strategy_source(strategy_id: str, request: Request):
|
||||
|
||||
@router.post("/ai/test")
|
||||
async def ai_test(request: Request):
|
||||
"""测试 AI 连通性 — 发送简单请求验证 Key 和模型"""
|
||||
from app.config import settings
|
||||
from app import secrets_store
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
if not ai_key:
|
||||
return {"ok": False, "error": "未配置 API Key"}
|
||||
"""Send a small prompt through the selected AI provider."""
|
||||
from app.services.ai_provider import current_ai_model, current_ai_provider, generate_ai_text
|
||||
|
||||
try:
|
||||
client = AsyncOpenAI(api_key=ai_key, base_url=settings.ai_base_url)
|
||||
resp = await client.chat.completions.create(
|
||||
model=settings.ai_model,
|
||||
messages=[{"role": "user", "content": "回复 OK"}],
|
||||
max_tokens=5,
|
||||
text = await generate_ai_text(
|
||||
[{"role": "user", "content": "Reply exactly: OK"}],
|
||||
temperature=0,
|
||||
max_tokens=8,
|
||||
timeout=15,
|
||||
)
|
||||
return {"ok": True, "model": resp.model, "usage": {"prompt": resp.usage.prompt_tokens, "completion": resp.usage.completion_tokens} if resp.usage else None}
|
||||
return {"ok": True, "model": current_ai_model() or current_ai_provider(), "response": text[:80]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@@ -27,28 +27,48 @@ class BatchAddRequest(BaseModel):
|
||||
note: str = ""
|
||||
|
||||
|
||||
def _with_names(rows: list[dict], request: Request) -> list[dict]:
|
||||
if not rows:
|
||||
return rows
|
||||
try:
|
||||
df_i = request.app.state.repo.get_instruments()
|
||||
if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns:
|
||||
return rows
|
||||
name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows())
|
||||
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("attach watchlist names failed: %s", e)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_all():
|
||||
return {"symbols": watchlist.list_symbols()}
|
||||
def list_all(request: Request):
|
||||
return {"symbols": _with_names(watchlist.list_symbols(), request)}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def add_one(req: AddRequest):
|
||||
def add_one(req: AddRequest, request: Request):
|
||||
rows = watchlist.add(req.symbol, req.note)
|
||||
return {"symbols": rows}
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def add_batch(req: BatchAddRequest):
|
||||
def add_batch(req: BatchAddRequest, request: Request):
|
||||
for sym in req.symbols:
|
||||
watchlist.add(sym, req.note)
|
||||
return {"symbols": watchlist.list_symbols(), "added": len(req.symbols)}
|
||||
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)}
|
||||
|
||||
|
||||
@router.post("/{symbol}/top")
|
||||
def move_one_to_top(symbol: str, request: Request):
|
||||
rows = watchlist.move_to_top(symbol)
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("/{symbol}")
|
||||
def remove_one(symbol: str):
|
||||
def remove_one(symbol: str, request: Request):
|
||||
rows = watchlist.remove(symbol)
|
||||
return {"symbols": rows}
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"""访问门控 —— 支持管理员令牌 + 动态 UUID 两种凭证。
|
||||
|
||||
部署方式(优先级从高到低):
|
||||
1. ADMIN_TOKEN: 管理员初始令牌(如 admin7226132)。验证通过后进入管理页,
|
||||
可创建普通 UUID 供合伙人使用,管理员本身也可访问全部功能。
|
||||
2. 动态 UUID: 管理员通过 /admin/uuids 创建的访问 UUID,持久化在
|
||||
data/user_data/access_uuids.json。
|
||||
3. ACCESS_UUID: 遗留单共享 UUID,保持向后兼容。
|
||||
|
||||
以上全部留空则门控不启用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app import uuid_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthRole(str, Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
|
||||
|
||||
# 内存中的令牌缓存:{token: {"role": role, "expires_at": float}}
|
||||
_token_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# 令牌默认有效期:7 天
|
||||
TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
class VerifyIn(BaseModel):
|
||||
credential: str
|
||||
|
||||
|
||||
class VerifyOut(BaseModel):
|
||||
valid: bool
|
||||
role: str | None = None
|
||||
token: str | None = None
|
||||
|
||||
|
||||
class AuthStatusOut(BaseModel):
|
||||
enabled: bool
|
||||
verified: bool
|
||||
role: str | None = None
|
||||
|
||||
|
||||
class UuidRecordOut(BaseModel):
|
||||
uuid: str
|
||||
label: str
|
||||
enabled: bool
|
||||
created_at: int
|
||||
|
||||
|
||||
class UuidCreateIn(BaseModel):
|
||||
label: str = ""
|
||||
|
||||
|
||||
def access_control_enabled() -> bool:
|
||||
"""是否启用了任何访问门控。"""
|
||||
return bool(settings.admin_token) or bool(settings.access_uuid)
|
||||
|
||||
|
||||
def admin_mode_enabled() -> bool:
|
||||
"""是否启用了管理员令牌模式。"""
|
||||
return bool(settings.admin_token)
|
||||
|
||||
|
||||
def _constant_time_compare(a: str, b: str) -> bool:
|
||||
"""常量时间字符串比较,降低时序攻击风险。"""
|
||||
return hmac.compare_digest(a.encode(), b.encode())
|
||||
|
||||
|
||||
def verify_admin_token(credential: str | None) -> bool:
|
||||
"""校验是否为管理员令牌。"""
|
||||
if not credential or not settings.admin_token:
|
||||
return False
|
||||
return _constant_time_compare(credential.strip(), settings.admin_token.strip())
|
||||
|
||||
|
||||
def verify_uuid(credential: str | None) -> bool:
|
||||
"""校验输入是否为有效访问 UUID(遗留单 UUID 或动态 UUID)。"""
|
||||
if not credential:
|
||||
return False
|
||||
stripped = credential.strip()
|
||||
# 动态 UUID
|
||||
if uuid_store.exists(stripped):
|
||||
return True
|
||||
# 遗留单共享 UUID
|
||||
if settings.access_uuid and _constant_time_compare(stripped, settings.access_uuid.strip()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_credential(credential: str | None) -> AuthRole | None:
|
||||
"""校验任意凭证,返回对应角色;无效返回 None。"""
|
||||
if not credential:
|
||||
return None
|
||||
if verify_admin_token(credential):
|
||||
return AuthRole.ADMIN
|
||||
if verify_uuid(credential):
|
||||
return AuthRole.USER
|
||||
return None
|
||||
|
||||
|
||||
def create_access_token(role: AuthRole) -> str:
|
||||
"""生成一个随机访问令牌并缓存。"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
_token_cache[token] = {"role": role, "expires_at": time.time() + TOKEN_TTL_SECONDS}
|
||||
return token
|
||||
|
||||
|
||||
def validate_access_token(token: str | None) -> AuthRole | None:
|
||||
"""校验令牌是否有效,返回角色。"""
|
||||
if not token:
|
||||
return None
|
||||
cached = _token_cache.get(token)
|
||||
if cached is None:
|
||||
return None
|
||||
expires_at = cached.get("expires_at", 0)
|
||||
if expires_at > 0 and time.time() > expires_at:
|
||||
_token_cache.pop(token, None)
|
||||
return None
|
||||
return cached.get("role")
|
||||
|
||||
|
||||
def revoke_access_token(token: str | None) -> None:
|
||||
"""使指定令牌失效。"""
|
||||
if token:
|
||||
_token_cache.pop(token, None)
|
||||
|
||||
|
||||
def get_access_token_from_request(request: Request) -> str | None:
|
||||
"""从请求头或 query 参数中提取访问令牌。"""
|
||||
header = request.headers.get("X-Access-Token")
|
||||
if header:
|
||||
return header.strip()
|
||||
return request.query_params.get("access_token")
|
||||
|
||||
|
||||
def require_access(request: Request, allowed_roles: set[AuthRole] | None = None) -> AuthRole:
|
||||
"""FastAPI 依赖:未通过校验时抛出 401。
|
||||
|
||||
allowed_roles: 仅允许指定角色访问;None 表示 admin/user 均可。
|
||||
"""
|
||||
if not access_control_enabled():
|
||||
return AuthRole.ADMIN # 未启用门控时视为最高权限
|
||||
token = get_access_token_from_request(request)
|
||||
role = validate_access_token(token)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=401, detail="访问令牌无效或已过期")
|
||||
if allowed_roles is not None and role not in allowed_roles:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
return role
|
||||
|
||||
|
||||
def require_admin(request: Request) -> AuthRole:
|
||||
"""FastAPI 依赖:仅管理员可访问。"""
|
||||
return require_access(request, allowed_roles={AuthRole.ADMIN})
|
||||
|
||||
|
||||
def is_public_path(path: str) -> bool:
|
||||
"""判断请求路径是否属于白名单(无需校验)。"""
|
||||
public_prefixes = (
|
||||
"/health",
|
||||
"/api/auth/",
|
||||
"/assets/",
|
||||
"/index.html",
|
||||
"/favicon.ico",
|
||||
"/robots.txt",
|
||||
"/manifest.json",
|
||||
)
|
||||
lowered = path.lower()
|
||||
return lowered.startswith(public_prefixes) or lowered == "/"
|
||||
|
||||
|
||||
def is_admin_path(path: str) -> bool:
|
||||
"""判断是否为管理员接口路径。"""
|
||||
return path.lower().startswith("/api/admin/")
|
||||
@@ -37,6 +37,7 @@ class MatcherConfig:
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
stop_loss_pct: float | None = None
|
||||
take_profit_pct: float | None = None
|
||||
trailing_stop_pct: float | None = None
|
||||
trailing_take_profit_activate_pct: float | None = None
|
||||
trailing_take_profit_drawdown_pct: float | None = None
|
||||
@@ -65,7 +66,7 @@ class TradeRecord:
|
||||
exit_price: float
|
||||
pnl_pct: float
|
||||
duration: int
|
||||
exit_reason: str # "signal" | "stop_loss" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
|
||||
exit_reason: str # "signal" | "stop_loss" | "take_profit" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
|
||||
# 退出优先级 (高→低): pending_exit(历史挂单) > 风控(止损/移动止损/移动止盈) > signal(卖点) > max_hold(到期) > end
|
||||
name: str = ""
|
||||
shares: float = 0.0
|
||||
@@ -544,6 +545,7 @@ class BacktestEngine:
|
||||
return None, None
|
||||
open_price = float(open_prices[idx])
|
||||
low_price = float(low_prices[idx])
|
||||
high_price = float(high_prices[idx])
|
||||
peak_price = float(pos.get("max_high", entry_price))
|
||||
risk_lines: list[tuple[float, str]] = []
|
||||
|
||||
@@ -560,13 +562,24 @@ class BacktestEngine:
|
||||
risk_lines.append((entry_price * (1 + peak_profit - abs(float(drawdown_pct))), "trailing_take_profit"))
|
||||
|
||||
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
|
||||
if not risk_lines:
|
||||
return None, None
|
||||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||||
if _valid_price(open_price) and open_price <= stop_price:
|
||||
return reason, open_price
|
||||
if _valid_price(low_price) and low_price <= stop_price:
|
||||
return reason, stop_price
|
||||
# 止损/移损/回撤止盈: 价格跌破风控线触发 (取最高优先级线)
|
||||
if risk_lines:
|
||||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||||
if _valid_price(open_price) and open_price <= stop_price:
|
||||
return reason, open_price
|
||||
if _valid_price(low_price) and low_price <= stop_price:
|
||||
return reason, stop_price
|
||||
|
||||
# 固定止盈: 价格涨破止盈线触发
|
||||
tp_pct = getattr(config, "take_profit_pct", None)
|
||||
if tp_pct is not None:
|
||||
tp_line = entry_price * (1 + abs(float(tp_pct)))
|
||||
if _valid_price(tp_line):
|
||||
# 开盘即超过止盈线 → 以开盘价成交; 否则当日触及高点止盈
|
||||
if _valid_price(open_price) and open_price >= tp_line:
|
||||
return "take_profit", open_price
|
||||
if _valid_price(high_price) and high_price >= tp_line:
|
||||
return "take_profit", tp_line
|
||||
return None, None
|
||||
|
||||
def _try_close(pos: dict, idx: int, reason: str, signal_date: str, exit_price_override: float | None = None) -> bool:
|
||||
@@ -993,6 +1006,7 @@ class BacktestEngine:
|
||||
continue
|
||||
open_price = float(open_prices[idx])
|
||||
low_price = float(low_prices[idx])
|
||||
high_price = float(high_prices[idx])
|
||||
entry_price = float(pos["entry_price"])
|
||||
peak_price = float(pos.get("max_high", entry_price))
|
||||
risk_lines: list[tuple[float, str]] = []
|
||||
@@ -1011,17 +1025,28 @@ class BacktestEngine:
|
||||
take_profit_line = entry_price * (1 + peak_profit - abs(float(drawdown_pct)))
|
||||
risk_lines.append((take_profit_line, "trailing_take_profit"))
|
||||
|
||||
# 止损/移损/回撤止盈: 价格跌破风控线触发
|
||||
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
|
||||
if not risk_lines:
|
||||
continue
|
||||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||||
exit_price_override = None
|
||||
if _valid_price(open_price) and open_price <= stop_price:
|
||||
exit_price_override = open_price
|
||||
elif _valid_price(low_price) and low_price <= stop_price:
|
||||
exit_price_override = stop_price
|
||||
if exit_price_override is not None:
|
||||
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
|
||||
if risk_lines:
|
||||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||||
exit_price_override = None
|
||||
if _valid_price(open_price) and open_price <= stop_price:
|
||||
exit_price_override = open_price
|
||||
elif _valid_price(low_price) and low_price <= stop_price:
|
||||
exit_price_override = stop_price
|
||||
if exit_price_override is not None:
|
||||
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
|
||||
continue
|
||||
|
||||
# 固定止盈: 价格涨破止盈线触发
|
||||
tp_pct = getattr(config, "take_profit_pct", None)
|
||||
if tp_pct is not None:
|
||||
tp_line = entry_price * (1 + abs(float(tp_pct)))
|
||||
if _valid_price(tp_line):
|
||||
if _valid_price(open_price) and open_price >= tp_line:
|
||||
_try_sell(sym, idx, "take_profit", d_str, sold_today, open_price)
|
||||
elif _valid_price(high_price) and high_price >= tp_line:
|
||||
_try_sell(sym, idx, "take_profit", d_str, sold_today, tp_line)
|
||||
|
||||
def _process_entries(
|
||||
d_str: str,
|
||||
|
||||
@@ -103,6 +103,11 @@ class StrategyBacktestService:
|
||||
entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
|
||||
exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
|
||||
stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss)
|
||||
take_profit = self._normalize_pct(
|
||||
self._override_value(overrides, "take_profit", getattr(s, "take_profit", None)),
|
||||
0.01,
|
||||
5.0,
|
||||
)
|
||||
trailing_stop = self._normalize_pct(
|
||||
self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)),
|
||||
0.005,
|
||||
@@ -195,6 +200,7 @@ class StrategyBacktestService:
|
||||
fees_pct=config.fees_pct,
|
||||
slippage_bps=config.slippage_bps,
|
||||
stop_loss_pct=stop_loss,
|
||||
take_profit_pct=take_profit,
|
||||
trailing_stop_pct=trailing_stop,
|
||||
trailing_take_profit_activate_pct=trailing_take_profit_activate,
|
||||
trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown,
|
||||
@@ -246,6 +252,7 @@ class StrategyBacktestService:
|
||||
"entry_signals": entry_signals,
|
||||
"exit_signals": exit_signals,
|
||||
"stop_loss": stop_loss,
|
||||
"take_profit": take_profit,
|
||||
"trailing_stop": trailing_stop,
|
||||
"trailing_take_profit_activate": trailing_take_profit_activate,
|
||||
"trailing_take_profit_drawdown": trailing_take_profit_drawdown,
|
||||
|
||||
+95
-18
@@ -1,27 +1,93 @@
|
||||
"""全局配置 — 硬编码,个人工具不依赖 .env。"""
|
||||
"""全局配置 — 从环境变量 / .env 读取。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# ── 运行环境检测 ──────────────────────────────────────────
|
||||
# PyInstaller 打包后: __file__ 指向临时解压目录 _MEIPASS, 不能作为路径基准。
|
||||
# 此时:
|
||||
# - 只读资源 (tiers.yaml / 前端 dist) 放在 _MEIPASS 内
|
||||
# - 可写用户数据 (data_dir) 放在可执行文件旁的用户目录
|
||||
# 非 frozen 模式 (开发/Docker): 保持原有 __file__ 推导, 行为完全不变。
|
||||
_IS_FROZEN = getattr(sys, "frozen", False)
|
||||
|
||||
|
||||
def _user_data_root() -> Path:
|
||||
"""桌面版用户数据根目录。
|
||||
|
||||
定位策略 (按优先级):
|
||||
1. 环境变量 DATA_DIR (pydantic-settings 自动注入到 settings.data_dir, 不在此处理)
|
||||
2. 打包桌面版: exe 同级的 data/ 子目录 (<安装目录>/data/)
|
||||
—— 与程序同处一个总目录 (用户选择的安装目录), 视觉直观, 便于备份/迁移。
|
||||
3. 非 frozen (开发模式): 项目根 data/
|
||||
|
||||
为什么不用 platformdirs 默认 (%LOCALAPPDATA%) 作为主路径:
|
||||
- 落在 C 盘系统目录, 用户不易察觉, 占系统盘空间
|
||||
- 用户期望「数据跟随程序」(便于备份/迁移)
|
||||
为什么放 {app}/data (exe 旁的 data/) 而非 {app} 外的兄弟目录:
|
||||
- 用户体验: 用户选了安装目录, 自然期望「程序和数据都在这」, 单一总目录更直观。
|
||||
- 数据安全: Inno Setup 覆盖安装(升级)时只往 {app} 写新程序文件, 不会清空
|
||||
目录里不在安装清单上的运行时文件 (data/ 即此类), 故覆盖安装不丢数据。
|
||||
(注意: 卸载时需在 .iss 中豁免 data/, 见 packaging/tickflow.iss 的 [UninstallDelete]。)
|
||||
旧版本数据迁移: 见 DataStore._migrate_legacy_data_dir(), 老用户首次启动自动搬迁。
|
||||
"""
|
||||
# 打包桌面版: exe 同级的 data/ 子目录 (与程序同一总目录, 覆盖安装不丢数据)
|
||||
if _IS_FROZEN:
|
||||
exe_dir = Path(sys.executable).resolve().parent
|
||||
return exe_dir / "data"
|
||||
|
||||
# 开发模式: 项目根 data/
|
||||
return _PROJECT_ROOT / "data"
|
||||
|
||||
|
||||
def _resource_root() -> Path:
|
||||
"""只读资源根目录。
|
||||
|
||||
frozen: PyInstaller 解压目录 (_MEIPASS)
|
||||
非 frozen: 项目根目录 (源码树)
|
||||
"""
|
||||
if _IS_FROZEN:
|
||||
# sys._MEIPASS 是 PyInstaller 注入的解压根
|
||||
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent))
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
"""项目根目录 (非 frozen 用)。"""
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
_PROJECT_ROOT = _project_root()
|
||||
_RESOURCE_ROOT = _resource_root()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=None, # 不读取 .env
|
||||
env_file=str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# 数据源 API Key(个人工具直接写死;如分享代码请改为空字符串或从环境变量注入)
|
||||
tickflow_api_key: str = "tk_94a20304993f45b5b0e376b9767597cc"
|
||||
# TickFlow
|
||||
tickflow_api_key: str = Field(default="", description="留空启用 free 模式")
|
||||
|
||||
# AI(可选,留空即关闭)
|
||||
# AI
|
||||
ai_provider: str = "openai_compat"
|
||||
ai_base_url: str = "https://api.deepseek.com/v1"
|
||||
ai_base_url: str = "https://api.alysc.top"
|
||||
ai_api_key: str = ""
|
||||
ai_model: str = "deepseek-chat"
|
||||
ai_daily_token_budget: int = 500_000
|
||||
ai_model: str = "gpt-5.5"
|
||||
ai_codex_command: str = "codex"
|
||||
# 默认浏览器风格 UA,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
|
||||
# 用户可在 AI 设置页按需修改。
|
||||
ai_user_agent: str = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
@@ -29,16 +95,27 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
backtest_range_guard: bool = False
|
||||
|
||||
# 访问门控:留空则不启用,部署时通过 ACCESS_UUID 环境变量注入
|
||||
# 优先级:ADMIN_TOKEN > 动态 UUID > ACCESS_UUID
|
||||
access_uuid: str = ""
|
||||
# 管理员初始令牌,硬编码以便开箱即用;如需更安全可改为空字符串并从环境变量 ADMIN_TOKEN 注入
|
||||
admin_token: str = "admin7226132"
|
||||
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
|
||||
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
|
||||
auth_password: str = ""
|
||||
|
||||
# 路径 — 硬编码为 Docker 容器内路径,确保数据持久化
|
||||
data_dir: Path = Path("/app/data")
|
||||
tiers_yaml: Path = Path("/app/tiers.yaml")
|
||||
static_dir: Path = Path("/app/static")
|
||||
# Data — frozen: exe 同级 data/ 子目录; 非 frozen: 项目根 data/
|
||||
# (均可被环境变量 DATA_DIR 覆盖, pydantic-settings 自动注入)
|
||||
data_dir: Path = _user_data_root()
|
||||
|
||||
# tiers.yaml 路径 — frozen: 资源目录内; 非 frozen: 项目根目录
|
||||
tiers_yaml: Path = _RESOURCE_ROOT / "tiers.yaml" if _IS_FROZEN else _PROJECT_ROOT / "tiers.yaml"
|
||||
|
||||
# 静态文件(前端 dist) — frozen: 资源目录的 static/; 非 frozen: frontend/dist
|
||||
static_dir: Path = _RESOURCE_ROOT / "static" if _IS_FROZEN else (_PROJECT_ROOT / "frontend" / "dist")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _resolve_paths(self) -> Settings:
|
||||
"""确保 data_dir 是绝对路径(环境变量传入的相对路径基于项目根目录解析)。"""
|
||||
if not self.data_dir.is_absolute():
|
||||
# 相对路径基于项目根目录解析,而非 CWD
|
||||
self.data_dir = (_PROJECT_ROOT / self.data_dir).resolve()
|
||||
return self
|
||||
|
||||
@property
|
||||
def use_free_mode(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Market data provider abstraction.
|
||||
|
||||
Providers normalize external data sources into the internal parquet schema.
|
||||
"""
|
||||
from app.data_providers.base import AssetType, MarketDataProvider, ProviderCapabilities
|
||||
from app.data_providers.registry import get_provider
|
||||
|
||||
__all__ = ["AssetType", "MarketDataProvider", "ProviderCapabilities", "get_provider"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Provider contracts for external market data sources.
|
||||
|
||||
The first implementation wraps TickFlow. Other providers (Tushare/AkShare/etc.)
|
||||
should return the same normalized Polars schemas so storage, indicators and
|
||||
backtests stay data-source agnostic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol
|
||||
|
||||
import polars as pl
|
||||
|
||||
AssetType = Literal["stock", "index", "etf"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderCapabilities:
|
||||
instruments: bool = False
|
||||
daily: bool = False
|
||||
adj_factor: bool = False
|
||||
minute: bool = False
|
||||
realtime: bool = False
|
||||
financial: bool = False
|
||||
|
||||
|
||||
class MarketDataProvider(Protocol):
|
||||
name: str
|
||||
capabilities: ProviderCapabilities
|
||||
|
||||
def get_instruments(self, asset_type: AssetType) -> pl.DataFrame:
|
||||
"""Return normalized instruments: symbol/name/code/exchange/asset_type/source."""
|
||||
|
||||
def get_daily(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start_time: datetime | None,
|
||||
end_time: datetime | None,
|
||||
asset_type: AssetType,
|
||||
) -> pl.DataFrame:
|
||||
"""Return normalized daily K rows."""
|
||||
|
||||
def get_adj_factors(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start_time: datetime | None,
|
||||
end_time: datetime | None,
|
||||
asset_type: AssetType,
|
||||
) -> pl.DataFrame:
|
||||
"""Return normalized adjustment factors: symbol/trade_date/ex_factor."""
|
||||
|
||||
def get_minute(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start_time: datetime | None,
|
||||
end_time: datetime | None,
|
||||
asset_type: AssetType,
|
||||
freq: str = "1m",
|
||||
) -> pl.DataFrame:
|
||||
"""Return normalized minute K rows. Implementations may return empty."""
|
||||
|
||||
def get_realtime(
|
||||
self,
|
||||
universes: list[str] | None = None,
|
||||
symbols: list[str] | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""Return normalized realtime quotes. Implementations may return empty."""
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Normalize provider responses into internal Polars schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.indicators.pipeline import filter_halt_days
|
||||
|
||||
DAILY_COLS = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
|
||||
ADJ_FACTOR_COLS = ["symbol", "trade_date", "ex_factor"]
|
||||
INSTRUMENT_COLS = ["symbol", "name", "code", "exchange", "asset_type", "source"]
|
||||
|
||||
|
||||
def to_polars(data) -> pl.DataFrame:
|
||||
if data is None:
|
||||
return pl.DataFrame()
|
||||
if isinstance(data, pl.DataFrame):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
rows: list[dict] = []
|
||||
for sym, values in data.items():
|
||||
for item in values or []:
|
||||
row = dict(item or {})
|
||||
row.setdefault("symbol", sym)
|
||||
rows.append(row)
|
||||
return pl.DataFrame(rows) if rows else pl.DataFrame()
|
||||
if hasattr(data, "reset_index"):
|
||||
return pl.from_pandas(data.reset_index())
|
||||
try:
|
||||
return pl.DataFrame(data)
|
||||
except Exception: # noqa: BLE001
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
def normalize_daily(data, default_symbol: str | None = None, source: str = "tickflow") -> pl.DataFrame: # noqa: ARG001
|
||||
df = to_polars(data)
|
||||
if df.is_empty():
|
||||
return df
|
||||
rename_map = {
|
||||
"ts_code": "symbol",
|
||||
"trade_date": "date",
|
||||
"datetime": "date",
|
||||
"vol": "volume",
|
||||
"amt": "amount",
|
||||
}
|
||||
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
|
||||
if "symbol" not in df.columns and default_symbol:
|
||||
df = df.with_columns(pl.lit(default_symbol).alias("symbol"))
|
||||
if "date" in df.columns and df.schema["date"] != pl.Date:
|
||||
df = df.with_columns(pl.col("date").cast(pl.Date, strict=False))
|
||||
for col in ("open", "high", "low", "close", "volume", "amount"):
|
||||
if col in df.columns:
|
||||
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
|
||||
df = filter_halt_days(df)
|
||||
keep = [c for c in DAILY_COLS if c in df.columns]
|
||||
return df.select(keep) if keep else pl.DataFrame()
|
||||
|
||||
|
||||
def normalize_adj_factors(data, source: str = "tickflow") -> pl.DataFrame: # noqa: ARG001
|
||||
df = to_polars(data)
|
||||
if df.is_empty():
|
||||
return df
|
||||
rename_map = {
|
||||
"timestamp": "trade_date",
|
||||
"date": "trade_date",
|
||||
"adj_factor": "ex_factor",
|
||||
}
|
||||
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
|
||||
if "trade_date" in df.columns:
|
||||
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
|
||||
df = df.with_columns(
|
||||
pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
|
||||
)
|
||||
else:
|
||||
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
|
||||
if "ex_factor" in df.columns:
|
||||
df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False))
|
||||
keep = [c for c in ADJ_FACTOR_COLS if c in df.columns]
|
||||
return df.select(keep).drop_nulls() if len(keep) == len(ADJ_FACTOR_COLS) else pl.DataFrame()
|
||||
|
||||
|
||||
def normalize_instruments(rows: list[dict], asset_type: str, source: str = "tickflow") -> pl.DataFrame:
|
||||
if not rows:
|
||||
return pl.DataFrame()
|
||||
out: list[dict] = []
|
||||
for item in rows:
|
||||
symbol = item.get("symbol")
|
||||
if not symbol:
|
||||
continue
|
||||
out.append({
|
||||
"symbol": str(symbol),
|
||||
"name": item.get("name") or str(symbol),
|
||||
"code": item.get("code") or str(symbol).split(".")[0],
|
||||
"exchange": item.get("exchange"),
|
||||
"asset_type": asset_type,
|
||||
"source": source,
|
||||
})
|
||||
if not out:
|
||||
return pl.DataFrame()
|
||||
return pl.DataFrame(out).select(INSTRUMENT_COLS).unique(subset=["symbol"], keep="last").sort("symbol")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Provider registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.data_providers.tickflow_provider import TickFlowProvider
|
||||
|
||||
_PROVIDERS = {
|
||||
"tickflow": TickFlowProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str = "tickflow"):
|
||||
provider_cls = _PROVIDERS.get((name or "tickflow").lower())
|
||||
if provider_cls is None:
|
||||
raise ValueError(f"Unsupported data provider: {name}")
|
||||
return provider_cls()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Internal provider schema column lists."""
|
||||
from __future__ import annotations
|
||||
|
||||
DAILY_COLUMNS = [
|
||||
"symbol", "asset_type", "source", "date", "open", "high", "low", "close",
|
||||
"volume", "amount", "pre_close", "change_pct",
|
||||
]
|
||||
|
||||
ADJ_FACTOR_COLUMNS = ["symbol", "asset_type", "source", "trade_date", "ex_factor"]
|
||||
|
||||
INSTRUMENT_COLUMNS = [
|
||||
"symbol", "name", "exchange", "asset_type", "source", "list_date", "status",
|
||||
]
|
||||
|
||||
MINUTE_COLUMNS = [
|
||||
"symbol", "asset_type", "source", "datetime", "open", "high", "low", "close",
|
||||
"volume", "amount", "freq",
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""TickFlow provider implementation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.data_providers.base import AssetType, ProviderCapabilities
|
||||
from app.data_providers.normalizer import normalize_adj_factors, normalize_daily, normalize_instruments
|
||||
from app.tickflow.client import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXCHANGES = ["SH", "SZ", "BJ"]
|
||||
|
||||
|
||||
class TickFlowProvider:
|
||||
name = "tickflow"
|
||||
capabilities = ProviderCapabilities(
|
||||
instruments=True,
|
||||
daily=True,
|
||||
adj_factor=True,
|
||||
minute=True,
|
||||
realtime=True,
|
||||
financial=True,
|
||||
)
|
||||
|
||||
def get_instruments(self, asset_type: AssetType) -> pl.DataFrame:
|
||||
tf = get_client()
|
||||
instrument_type = "stock" if asset_type == "stock" else asset_type
|
||||
rows: list[dict] = []
|
||||
for ex in _EXCHANGES:
|
||||
try:
|
||||
items = tf.exchanges.get_instruments(ex, instrument_type=instrument_type)
|
||||
rows.extend([it for it in (items or []) if isinstance(it, dict)])
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("TickFlow instruments %s/%s failed: %s", ex, instrument_type, e)
|
||||
return normalize_instruments(rows, asset_type=asset_type, source=self.name)
|
||||
|
||||
def get_daily(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start_time: datetime | None,
|
||||
end_time: datetime | None,
|
||||
asset_type: AssetType, # noqa: ARG002
|
||||
) -> pl.DataFrame:
|
||||
if not symbols:
|
||||
return pl.DataFrame()
|
||||
tf = get_client()
|
||||
kwargs = {
|
||||
"period": "1d",
|
||||
"adjust": "none",
|
||||
"count": 10000 if start_time and end_time else 250,
|
||||
"as_dataframe": True,
|
||||
"show_progress": False,
|
||||
}
|
||||
if start_time and end_time:
|
||||
from app.services.kline_sync import _datetime_to_ms
|
||||
kwargs["start_time"] = _datetime_to_ms(start_time)
|
||||
kwargs["end_time"] = _datetime_to_ms(end_time)
|
||||
raw = tf.klines.batch(symbols, **kwargs)
|
||||
frames: list[pl.DataFrame] = []
|
||||
if isinstance(raw, dict):
|
||||
for sym, sub in raw.items():
|
||||
normalized = normalize_daily(sub, default_symbol=sym, source=self.name)
|
||||
if not normalized.is_empty():
|
||||
frames.append(normalized)
|
||||
else:
|
||||
normalized = normalize_daily(raw, source=self.name)
|
||||
if not normalized.is_empty():
|
||||
frames.append(normalized)
|
||||
return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
|
||||
|
||||
def get_adj_factors(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start_time: datetime | None,
|
||||
end_time: datetime | None,
|
||||
asset_type: AssetType, # noqa: ARG002
|
||||
) -> pl.DataFrame:
|
||||
if not symbols:
|
||||
return pl.DataFrame()
|
||||
tf = get_client()
|
||||
kwargs = {"as_dataframe": False}
|
||||
if start_time or end_time:
|
||||
from app.services.kline_sync import _datetime_to_ms
|
||||
if start_time:
|
||||
kwargs["start_time"] = _datetime_to_ms(start_time)
|
||||
if end_time:
|
||||
kwargs["end_time"] = _datetime_to_ms(end_time)
|
||||
raw = tf.klines.ex_factors(symbols, **kwargs)
|
||||
return normalize_adj_factors(raw, source=self.name)
|
||||
|
||||
def get_minute(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start_time: datetime | None,
|
||||
end_time: datetime | None,
|
||||
asset_type: AssetType, # noqa: ARG002
|
||||
freq: str = "1m", # noqa: ARG002
|
||||
) -> pl.DataFrame:
|
||||
# Existing minute sync remains in app.services.kline_sync for now.
|
||||
return pl.DataFrame()
|
||||
|
||||
def get_realtime(
|
||||
self,
|
||||
universes: list[str] | None = None,
|
||||
symbols: list[str] | None = None,
|
||||
) -> pl.DataFrame:
|
||||
tf = get_client()
|
||||
if universes and symbols:
|
||||
raise ValueError("TickFlow realtime accepts either universes or symbols, not both")
|
||||
if universes:
|
||||
resp = tf.quotes.get_by_universes(universes=universes)
|
||||
elif symbols:
|
||||
resp = tf.quotes.get(symbols=symbols)
|
||||
else:
|
||||
return pl.DataFrame()
|
||||
return pl.DataFrame(resp or [])
|
||||
@@ -0,0 +1,241 @@
|
||||
"""桌面客户端入口 — uvicorn 后台服务 + pywebview 桌面窗口。
|
||||
|
||||
运行方式:
|
||||
开发模式: python -m app.desktop (需 pip install pywebview)
|
||||
打包后: 双击可执行文件即可
|
||||
|
||||
职责:
|
||||
1. 单实例锁 — 已运行则聚焦已有窗口并退出
|
||||
2. 选可用端口 — 从 settings.port 起, 被占则递增
|
||||
3. 后台线程起 uvicorn (仅监听 127.0.0.1, 不暴露外网)
|
||||
4. 主线程起 pywebview 窗口渲染前端
|
||||
5. 窗口关闭 → 优雅停止 uvicorn → 进程退出
|
||||
|
||||
不含: 业务逻辑、配置持久化、监控告警 (全在 app.main 里)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_APP_NAME = "TickFlow 股票面板"
|
||||
_BASE_PORT = 3018
|
||||
_PORT_PROBE_RANGE = 50 # 从 3018 起最多试 50 个端口
|
||||
|
||||
|
||||
def _ensure_data_dir_writable() -> None:
|
||||
"""确保用户数据目录可写 (lifespan 会创建子目录, 这里只验证根目录)。
|
||||
|
||||
data_dir 在 frozen 模式下指向用户目录 (见 config.py), 非可写会导致
|
||||
DuckDB 视图 / parquet 落盘全失败。提前失败胜过启动后乱报错。
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
data_root = settings.data_dir
|
||||
try:
|
||||
data_root.mkdir(parents=True, exist_ok=True)
|
||||
probe = data_root / ".write_probe"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink(missing_ok=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("数据目录不可写, 桌面版无法运行: %s (%s)", data_root, e)
|
||||
raise
|
||||
|
||||
|
||||
def _acquire_single_instance() -> bool:
|
||||
"""单实例锁。已运行返回 False (本进程应退出), 否则 True。
|
||||
|
||||
用 data_dir/.desktop.lock 文件锁实现。跨进程, 文件存在即视为已运行
|
||||
(简单可靠; 不引入 msvcrt/fcntl 平台差异)。
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
lock_path = settings.data_dir / ".desktop.lock"
|
||||
if lock_path.exists():
|
||||
# 软检测: 写入进程 PID, 若该 PID 已不存在则视为残留锁, 允许接管
|
||||
try:
|
||||
pid_str = lock_path.read_text(encoding="utf-8").strip()
|
||||
pid = int(pid_str) if pid_str.isdigit() else None
|
||||
except Exception: # noqa: BLE001
|
||||
pid = None
|
||||
|
||||
if pid is not None and _pid_alive(pid):
|
||||
logger.warning("检测到已有实例运行 (PID %d), 本进程退出", pid)
|
||||
return False
|
||||
# 残留锁: 清理后继续
|
||||
logger.info("清理残留单实例锁 (PID %s 已不存在)", pid)
|
||||
|
||||
lock_path.write_text(str(_current_pid()), encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _release_single_instance() -> None:
|
||||
from app.config import settings
|
||||
|
||||
lock_path = settings.data_dir / ".desktop.lock"
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""检查指定 PID 的进程是否存活。"""
|
||||
import os
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows: 0 表示存在, 其它是异常
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, 0) # signal 0 = 探测存活, 不实际发信号
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _current_pid() -> int:
|
||||
import os
|
||||
|
||||
return os.getpid()
|
||||
|
||||
|
||||
def _find_free_port(start: int, count: int = _PORT_PROBE_RANGE) -> int:
|
||||
"""从 start 起找第一个可用端口。全部被占则返回 start (交给 uvicorn 报错)。"""
|
||||
for port in range(start, start + count):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
s.bind(("127.0.0.1", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
return start
|
||||
|
||||
|
||||
def _run_uvmicorn(port: int, ready_event: threading.Event) -> None:
|
||||
"""后台线程: 启动 uvicorn 服务。ready_event 在线程退出时置位 (通知主线程)。"""
|
||||
import uvicorn
|
||||
|
||||
# 延迟 import app, 确保配置层已就绪 (frozen 检测在 config.py 导入时完成)
|
||||
from app.main import app
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host="127.0.0.1", # 仅本机, 不暴露外网 (桌面版无需远程访问)
|
||||
port=port,
|
||||
log_level="info",
|
||||
access_log=False, # 桌面版不需要访问日志
|
||||
loop="auto",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
# 线程结束时通知主线程 (无论正常退出还是异常)
|
||||
def _signal_done(*exc):
|
||||
ready_event.set()
|
||||
server.config.callback_notify = None # 不用 notify 机制
|
||||
|
||||
try:
|
||||
server.run()
|
||||
finally:
|
||||
ready_event.set()
|
||||
|
||||
|
||||
def _wait_for_server(port: int, timeout: float = 60.0) -> bool:
|
||||
"""轮询 health 接口直到后端就绪或超时。
|
||||
|
||||
比 monkey-patch uvicorn 内部方法更健壮, 不依赖版本内部实现。
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"http://127.0.0.1:{port}/health"
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as r:
|
||||
if r.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, ConnectionError, OSError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def _open_window(url: str) -> None:
|
||||
"""主线程: 用 pywebview 打开桌面窗口。"""
|
||||
import webview # type: ignore[import-not-found]
|
||||
|
||||
window = webview.create_window(
|
||||
_APP_NAME,
|
||||
url,
|
||||
width=1440,
|
||||
height=900,
|
||||
min_size=(1024, 700),
|
||||
# 桌面版固定单窗口, 禁用外部浏览器跳转
|
||||
confirm_close=False,
|
||||
)
|
||||
# pywebview 会阻塞主线程直到窗口关闭
|
||||
webview.start(debug=False)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""桌面客户端主入口。返回进程退出码。"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
try:
|
||||
_ensure_data_dir_writable()
|
||||
except Exception:
|
||||
# 数据目录不可写是致命错误, 无法继续
|
||||
return 1
|
||||
|
||||
# 单实例: 已运行则退出
|
||||
if not _acquire_single_instance():
|
||||
return 0
|
||||
|
||||
try:
|
||||
port = _find_free_port(_BASE_PORT)
|
||||
logger.info("桌面版后端将监听 127.0.0.1:%d", port)
|
||||
|
||||
# 后台线程起 uvicorn
|
||||
ready = threading.Event()
|
||||
server_thread = threading.Thread(
|
||||
target=_run_uvmicorn, args=(port, ready), daemon=True,
|
||||
name="uvicorn",
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
# 轮询 health 接口等后端就绪 (含 lifespan 初始化, 最多 60s)
|
||||
if not _wait_for_server(port, timeout=60.0):
|
||||
logger.error("后端启动超时, 桌面版退出")
|
||||
_release_single_instance()
|
||||
return 1
|
||||
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
logger.info("打开桌面窗口: %s", url)
|
||||
_open_window(url)
|
||||
|
||||
# 窗口关闭后, 进程退出 (daemon 线程会被回收)
|
||||
logger.info("窗口已关闭, 桌面版退出")
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
_release_single_instance()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,600 @@
|
||||
"""关键价位计算 —— 独立模块,纯函数,无 IO / 无存储。
|
||||
|
||||
输入: 已经包含 OHLCV 的 polars 日 K DataFrame(内存中,通常来自 KlineRepository 缓存)。
|
||||
输出: 4 类结构化价位点,供:
|
||||
- 图表 markLine 渲染(压力位 / 支撑位 / 成交密集区 / 枢轴点 / 前高前低)
|
||||
- AI 个股分析提示词(价位上下文)
|
||||
|
||||
设计:
|
||||
- 纯函数 + polars 向量化,毫秒级,无需落盘。
|
||||
- 每个点位带 {value, label, type, side, strength?},前端直接画水平价格线。
|
||||
- NaN/Inf 全部过滤,空数据返回空列表,不抛异常。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 输出结构
|
||||
# ================================================================
|
||||
|
||||
class PriceLevel:
|
||||
"""单个价位点的数据结构(用 dict 表达,这里只作文档说明)。
|
||||
|
||||
{
|
||||
"value": 12.34, # 价格
|
||||
"label": "压力位 R1", # 显示标签
|
||||
"type": "pivot", # 类型分组(同类型用一个开关按钮控制显隐)
|
||||
"side": "resistance", # 方向:resistance(压力) / support(支撑) / neutral
|
||||
"strength": "medium", # 强度:strong / medium / weak(可选,影响线型)
|
||||
"rank": 1, # 档位(仅 pivot 有):0=P,1=R1/S1,2=R2/S2,3=R3/S3
|
||||
# 前端按"显示到第几档"过滤,非 pivot 点位无此字段
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# 价位分组 → 开关 key。前端按这个 type 显隐。
|
||||
LEVEL_TYPES = {
|
||||
"sr": "压力支撑", # 成交密集区(价量:Volume Profile POC + 高成交密集区)
|
||||
"pivot": "枢轴点", # 经典 Pivot P/R/S
|
||||
"extreme": "前高前低", # 60/250 日极值 + 近期 swing 高低点
|
||||
"boll": "布林带", # MA20 ± 2σ,标准差波动带(参考性,非真实支撑压力)
|
||||
"keltner_s": "Keltner短期", # MA20 ± 2×ATR
|
||||
"keltner_m": "Keltner中期", # MA60 ± 2.5×ATR
|
||||
"keltner_l": "Keltner长期", # MA120 ± 3×ATR(牛熊趋势边界)
|
||||
"atr_stop": "ATR止损", # close±nATR 动态止盈止损
|
||||
"gap": "缺口位", # 未回补跳空缺口
|
||||
"fib": "斐波那契", # 回撤位 0.236~0.786
|
||||
"round": "整数关口", # 心理整数位
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 1. 压力位 / 支撑位 —— 成交量分布 (Volume Profile)
|
||||
# ================================================================
|
||||
|
||||
def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]:
|
||||
"""成交量分布 (Volume Profile) —— 真正基于价+量的支撑/压力位。
|
||||
|
||||
把每个价位层按价格分桶,统计落在该桶的累计成交量,取高成交密集区作为关键
|
||||
价位带。与 BOLL/Keltner 等"波动通道"不同,成交密集区反映的是真实换手堆积,
|
||||
是经典意义的支撑/压力。
|
||||
|
||||
密集区 = 成交量高于均值的桶,按成交量降序取前 3 个作为关键价位带:
|
||||
- POC(控制点):成交量最大的桶,标记为 strong
|
||||
- 其他高成交区:高于均值,标记为 medium
|
||||
"""
|
||||
if df.is_empty() or "volume" not in df.columns or df.height < 20:
|
||||
return []
|
||||
|
||||
hi = float(df["high"].max())
|
||||
lo = float(df["low"].min())
|
||||
if not (hi > lo > 0):
|
||||
return []
|
||||
|
||||
# 每根 K 的价格区间中点 × 成交量 ≈ 该价位层贡献的成交量(简化模型)
|
||||
df2 = df.select([
|
||||
((pl.col("high") + pl.col("low")) / 2).alias("mid"),
|
||||
pl.col("volume").alias("vol"),
|
||||
]).drop_nulls()
|
||||
|
||||
# 桶边界:bins 个桶需要 bins-1 个内部 break,cut 据此切成 bins 段
|
||||
step = (hi - lo) / bins
|
||||
edges = [lo + i * step for i in range(bins + 1)] # 含首尾,共 bins+1 个边界值
|
||||
breaks = edges[1:-1] # 内部 break,bins-1 个
|
||||
bin_labels = [f"{i}" for i in range(bins)] # 桶序号 0..bins-1
|
||||
# 至少要有 1 个不同的内部 break
|
||||
if len(set(f"{b:.6f}" for b in breaks)) < 1:
|
||||
return []
|
||||
|
||||
df2 = df2.with_columns(
|
||||
pl.col("mid").cut(breaks, labels=bin_labels).alias("bin")
|
||||
)
|
||||
prof = df2.group_by("bin").agg(pl.col("vol").sum())
|
||||
if prof.is_empty():
|
||||
return []
|
||||
|
||||
# 把桶序号字符串还原为 int,以便回查 edges;并按序号排序保证可索引
|
||||
prof = prof.with_columns(pl.col("bin").cast(pl.Int64).alias("bi")).sort("bi")
|
||||
bin_ids = prof["bi"].to_list()
|
||||
vols = prof["vol"].to_list()
|
||||
mean_vol = sum(vols) / len(vols) if vols else 0
|
||||
|
||||
def bin_mid(bin_id: int) -> float:
|
||||
return (edges[bin_id] + edges[bin_id + 1]) / 2
|
||||
|
||||
close = float(df.tail(1)["close"][0])
|
||||
|
||||
out: list[dict] = []
|
||||
# POC:成交量最大的桶
|
||||
poc_pos = max(range(len(vols)), key=lambda i: vols[i])
|
||||
poc_mid = bin_mid(bin_ids[poc_pos])
|
||||
out.append({"value": round(poc_mid, 2), "label": "成交密集区(POC)",
|
||||
"type": "sr", "side": _side(poc_mid, close), "strength": "strong"})
|
||||
|
||||
# 其他高成交区(高于均值,排除 POC),按成交量降序取 2 个
|
||||
candidates = [(i, v) for i, v in enumerate(vols) if v > mean_vol and i != poc_pos]
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
for i, _v in candidates[:2]:
|
||||
mid = bin_mid(bin_ids[i])
|
||||
out.append({"value": round(mid, 2), "label": "成交密集区",
|
||||
"type": "sr", "side": _side(mid, close), "strength": "medium"})
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 2. 枢轴点 (Pivot Point) —— 经典公式,基于最近完整交易日
|
||||
# ================================================================
|
||||
|
||||
def _pivot_points(df: pl.DataFrame) -> list[dict]:
|
||||
"""经典 Pivot:P = (H+L+C)/3, R1/R2/R3, S1/S2/S3。
|
||||
|
||||
基准:最后 1 根 K(代表"上一交易日")。实务中常用前一日,这里取最后一根。
|
||||
"""
|
||||
if df.is_empty():
|
||||
return []
|
||||
last = df.tail(1)
|
||||
h = last["high"][0]
|
||||
l = last["low"][0]
|
||||
c = last["close"][0]
|
||||
if not _ok(h) or not _ok(l) or not _ok(c):
|
||||
return []
|
||||
|
||||
h, l, c = float(h), float(l), float(c)
|
||||
p = (h + l + c) / 3
|
||||
r1 = 2 * p - l
|
||||
s1 = 2 * p - h
|
||||
r2 = p + (h - l)
|
||||
s2 = p - (h - l)
|
||||
r3 = h + 2 * (p - l)
|
||||
s3 = l - 2 * (h - p)
|
||||
|
||||
def lv(v: float, label: str, side: str, strength: str, rank: int) -> dict:
|
||||
# rank:档位标记,前端据此按"显示到第几档"过滤
|
||||
# 0 = 枢轴位 P(始终显示)
|
||||
# 1 = R1/S1(第一档压力/支撑)
|
||||
# 2 = R2/S2(第二档)
|
||||
# 3 = R3/S3(第三档,极端,实际很少触及)
|
||||
return {"value": round(v, 2), "label": label, "type": "pivot",
|
||||
"side": side, "strength": strength, "rank": rank}
|
||||
|
||||
return [
|
||||
lv(p, "枢轴位 P", "neutral", "strong", 0),
|
||||
lv(r1, "压力位 R1", "resistance", "medium", 1),
|
||||
lv(r2, "压力位 R2", "resistance", "medium", 2),
|
||||
lv(r3, "压力位 R3", "resistance", "weak", 3),
|
||||
lv(s1, "支撑位 S1", "support", "medium", 1),
|
||||
lv(s2, "支撑位 S2", "support", "medium", 2),
|
||||
lv(s3, "支撑位 S3", "support", "weak", 3),
|
||||
]
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 3. 前高 / 前低 —— 60 / 120 / 250 日极值
|
||||
# ================================================================
|
||||
|
||||
def _extreme_levels(df: pl.DataFrame) -> list[dict]:
|
||||
"""关键前高 / 前低 —— 历史极值 + 近期 swing 高低点(收敛后)。
|
||||
|
||||
设计:把所有"前高前低"类点位集中在本组,与 sr(通道)区分:
|
||||
- 60 日极值:近一季度高低点(短期参照)
|
||||
- 250 日极值:年度高低点(牛熊分界参照);跳过 120 日(被 250 日包含,信息冗余)
|
||||
- swing 高低点:近期局部转折点,每侧只取距当前价最近的 2 个
|
||||
"""
|
||||
if df.is_empty():
|
||||
return []
|
||||
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
|
||||
out: list[dict] = []
|
||||
|
||||
# —— 历史极值(只取 60 / 250,避免中间档冗余)——
|
||||
for n in (60, 250):
|
||||
if df.height < n:
|
||||
continue
|
||||
sub = df.tail(n)
|
||||
hi = float(sub["high"].max())
|
||||
lo = float(sub["low"].min())
|
||||
if _ok(hi):
|
||||
out.append({"value": round(hi, 2), "label": f"{n}日新高",
|
||||
"type": "extreme", "side": "resistance", "strength": "strong"})
|
||||
if _ok(lo):
|
||||
out.append({"value": round(lo, 2), "label": f"{n}日新低",
|
||||
"type": "extreme", "side": "support", "strength": "strong"})
|
||||
|
||||
# —— 近期 swing 高低点(每侧只取距当前价最近的 2 个,避免点位爆炸)——
|
||||
win = 5
|
||||
if df.height > win * 2 and close:
|
||||
highs = df["high"].to_list()
|
||||
lows = df["low"].to_list()
|
||||
swing_highs: list[float] = []
|
||||
swing_lows: list[float] = []
|
||||
for i in range(win, len(highs) - win):
|
||||
if highs[i] == max(highs[i - win:i + win + 1]):
|
||||
swing_highs.append(float(highs[i]))
|
||||
if lows[i] == min(lows[i - win:i + win + 1]):
|
||||
swing_lows.append(float(lows[i]))
|
||||
|
||||
# 聚合 ±1% 相近价位,再按距当前价排序取最近 2 个
|
||||
agg_h = _aggregate_levels(swing_highs, 0.01)
|
||||
agg_h = [v for v in agg_h if v > close * 1.001]
|
||||
agg_h.sort(key=lambda v: abs(v - close))
|
||||
for v in agg_h[:2]:
|
||||
out.append({"value": round(v, 2), "label": "前高",
|
||||
"type": "extreme", "side": "resistance", "strength": "medium"})
|
||||
|
||||
agg_l = _aggregate_levels(swing_lows, 0.01)
|
||||
agg_l = [v for v in agg_l if v < close * 0.999]
|
||||
agg_l.sort(key=lambda v: abs(v - close))
|
||||
for v in agg_l[:2]:
|
||||
out.append({"value": round(v, 2), "label": "前低",
|
||||
"type": "extreme", "side": "support", "strength": "medium"})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 4. 波动通道 —— 布林带 + Keltner 三档,各自独立开关
|
||||
# ================================================================
|
||||
|
||||
def _ma_value(df: pl.DataFrame, ma_col: str | None, window: int) -> float | None:
|
||||
"""取某档均线值:优先用预计算列,缺失则现场 rolling_mean。"""
|
||||
last = df.tail(1)
|
||||
if ma_col and ma_col in df.columns:
|
||||
v = last[ma_col][0]
|
||||
return float(v) if _ok(v) else None
|
||||
if df.height >= window:
|
||||
v = df.select(pl.col("close").rolling_mean(window)).tail(1)["close"][0]
|
||||
return float(v) if _ok(v) else None
|
||||
return None
|
||||
|
||||
|
||||
def _keltner_band(
|
||||
df: pl.DataFrame, ma_col: str | None, window: int, n: float,
|
||||
label_short: str, type_key: str,
|
||||
) -> list[dict]:
|
||||
"""单档 Keltner 通道:均线 ± n×ATR。
|
||||
|
||||
ATR 自适应波动,通道宽度随行情自动收缩/扩张。type_key 决定归入哪一组
|
||||
(keltner_s / keltner_m / keltner_l),前端各自独立开关。
|
||||
"""
|
||||
if df.is_empty() or df.height < 20 or "atr_14" not in df.columns:
|
||||
return []
|
||||
last = df.tail(1)
|
||||
close = float(last["close"][0]) if "close" in df.columns else 0
|
||||
atr = float(last["atr_14"][0])
|
||||
if not close or not _ok(atr):
|
||||
return []
|
||||
|
||||
ma_val = _ma_value(df, ma_col, window)
|
||||
if ma_val is None:
|
||||
return []
|
||||
upper = ma_val + n * atr
|
||||
lower = ma_val - n * atr
|
||||
return [
|
||||
{"value": round(upper, 2), "label": f"{label_short}通道上轨",
|
||||
"type": type_key, "side": _side(upper, close), "strength": "medium"},
|
||||
{"value": round(lower, 2), "label": f"{label_short}通道下轨",
|
||||
"type": type_key, "side": _side(lower, close), "strength": "medium"},
|
||||
]
|
||||
|
||||
|
||||
def _boll_channel(df: pl.DataFrame) -> list[dict]:
|
||||
"""布林带上下轨(MA20 ± 2σ)。
|
||||
|
||||
基于标准差的波动带,反映价格相对均线的统计偏离;非真实支撑压力,
|
||||
仅作波动边界参考。数据直接取预计算列 boll_upper/boll_lower。
|
||||
"""
|
||||
if df.is_empty() or "boll_upper" not in df.columns or "boll_lower" not in df.columns:
|
||||
return []
|
||||
last = df.tail(1)
|
||||
close = float(last["close"][0]) if "close" in df.columns else 0
|
||||
if not close:
|
||||
return []
|
||||
bu = last["boll_upper"][0]
|
||||
bl = last["boll_lower"][0]
|
||||
if not _ok(bu) or not _ok(bl):
|
||||
return []
|
||||
bu, bl = float(bu), float(bl)
|
||||
out = [
|
||||
{"value": round(bu, 2), "label": "布林上轨",
|
||||
"type": "boll", "side": _side(bu, close), "strength": "medium"},
|
||||
{"value": round(bl, 2), "label": "布林下轨",
|
||||
"type": "boll", "side": _side(bl, close), "strength": "medium"},
|
||||
]
|
||||
# 布林中轨 = MA20(多空平衡线,价格在其上下分强弱);数据层已预计算 ma20
|
||||
if "ma20" in df.columns:
|
||||
mid = last["ma20"][0]
|
||||
if _ok(mid):
|
||||
mid = float(mid)
|
||||
out.append({"value": round(mid, 2), "label": "布林中轨",
|
||||
"type": "boll", "side": _side(mid, close), "strength": "medium"})
|
||||
return out
|
||||
|
||||
|
||||
def _keltner_short(df: pl.DataFrame) -> list[dict]:
|
||||
"""Keltner 短期:MA20 ± 2×ATR(近期波动带,约一个月)。"""
|
||||
return _keltner_band(df, "ma20", 20, 2.0, "短期", "keltner_s")
|
||||
|
||||
|
||||
def _keltner_mid(df: pl.DataFrame) -> list[dict]:
|
||||
"""Keltner 中期:MA60 ± 2.5×ATR(季度波动带)。"""
|
||||
return _keltner_band(df, "ma60", 60, 2.5, "中期", "keltner_m")
|
||||
|
||||
|
||||
def _keltner_long(df: pl.DataFrame) -> list[dict]:
|
||||
"""Keltner 长期:MA120 ± 3×ATR(半年波动带,牛熊趋势边界)。"""
|
||||
return _keltner_band(df, None, 120, 3.0, "长期", "keltner_l")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 5. ATR 止损位 —— close ± n × ATR,动态止盈止损
|
||||
# ================================================================
|
||||
|
||||
def _atr_stops(df: pl.DataFrame) -> list[dict]:
|
||||
"""基于 ATR 的动态止损/止盈位。
|
||||
|
||||
ATR 衡量平均真实波幅,close ± n×ATR 是交易者最常用的止损位算法:
|
||||
- 止损位:close - 2×ATR (跌破即趋势破坏)
|
||||
- 止盈位:close + 2×ATR (突破即顺势扩展)
|
||||
- 近端波动带:close ± 1.5×ATR (中短期风控参考)
|
||||
"""
|
||||
if df.is_empty() or "atr_14" not in df.columns:
|
||||
return []
|
||||
last = df.tail(1)
|
||||
close = float(last["close"][0])
|
||||
atr = float(last["atr_14"][0])
|
||||
if not _ok(close) or not _ok(atr):
|
||||
return []
|
||||
|
||||
def lv(v: float, label: str, side: str, strength: str) -> dict:
|
||||
return {"value": round(v, 2), "label": label, "type": "atr_stop",
|
||||
"side": side, "strength": strength}
|
||||
|
||||
return [
|
||||
lv(close + 2 * atr, "ATR 止盈(+2)", "resistance", "medium"),
|
||||
lv(close + 1.5 * atr, "ATR 上轨(+1.5)", "resistance", "weak"),
|
||||
lv(close - 1.5 * atr, "ATR 下轨(-1.5)", "support", "weak"),
|
||||
lv(close - 2 * atr, "ATR 止损(-2)", "support", "medium"),
|
||||
]
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 6. 缺口位 (Gap) —— 未回补的跳空缺口
|
||||
# ================================================================
|
||||
|
||||
def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]:
|
||||
"""近期未回补的向上/向下跳空缺口。
|
||||
|
||||
向上缺口:当日 low > 前日 high(开盘跳空高开,全天未回补)
|
||||
向下缺口:当日 high < 前日 low(开盘跳空低开,全天未回补)
|
||||
|
||||
缺口是天然的支撑/阻力位。只保留"未回补"的(后续价格未回到缺口区间内),
|
||||
并按价格聚合相近缺口(±0.5%),每方向只取距当前价最近的 2~3 个。
|
||||
"""
|
||||
if df.is_empty() or df.height < 5:
|
||||
return []
|
||||
sub = df.tail(lookback) if df.height > lookback else df
|
||||
close = float(df.tail(1)["close"][0])
|
||||
highs = sub["high"].to_list()
|
||||
lows = sub["low"].to_list()
|
||||
|
||||
up_gaps: list[tuple[float, float]] = [] # (缺口低点, 缺口高点)
|
||||
dn_gaps: list[tuple[float, float]] = []
|
||||
for i in range(1, len(highs)):
|
||||
if _ok(highs[i]) and _ok(lows[i]) and _ok(highs[i - 1]) and _ok(lows[i - 1]):
|
||||
if lows[i] > highs[i - 1]: # 向上缺口
|
||||
up_gaps.append((highs[i - 1], lows[i]))
|
||||
elif highs[i] < lows[i - 1]: # 向下缺口
|
||||
dn_gaps.append((highs[i], lows[i - 1]))
|
||||
|
||||
def _filter_unfilled(gaps: list[tuple[float, float]], is_up: bool) -> list[float]:
|
||||
"""过滤掉已被后续价格回补的缺口,取缺口价位中点。"""
|
||||
mids: list[float] = []
|
||||
for g_lo, g_hi in gaps:
|
||||
# 未回补判定:当前价不在缺口区间内
|
||||
if is_up and close >= g_hi: # 向上缺口:价格已超过缺口上沿 = 未回补(站在缺口上方)
|
||||
mids.append((g_lo + g_hi) / 2)
|
||||
elif not is_up and close <= g_lo: # 向下缺口:价格已低于缺口下沿 = 未回补
|
||||
mids.append((g_lo + g_hi) / 2)
|
||||
# 聚合相近缺口 + 按距当前价排序取最近 3 个
|
||||
agg = _aggregate_levels(mids, 0.005)
|
||||
agg.sort(key=lambda v: abs(v - close))
|
||||
return agg[:3]
|
||||
|
||||
out: list[dict] = []
|
||||
for mid in _filter_unfilled(up_gaps, True):
|
||||
out.append({"value": round(mid, 2), "label": "向上缺口",
|
||||
"type": "gap", "side": _side(mid, close), "strength": "medium"})
|
||||
for mid in _filter_unfilled(dn_gaps, False):
|
||||
out.append({"value": round(mid, 2), "label": "向下缺口",
|
||||
"type": "gap", "side": _side(mid, close), "strength": "medium"})
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 7. 斐波那契回撤 —— 基于近期波段的回撤位
|
||||
# ================================================================
|
||||
|
||||
def _fibonacci_levels(df: pl.DataFrame, window: int = 120) -> list[dict]:
|
||||
"""基于近期一段明确趋势的斐波那契回撤位。
|
||||
|
||||
取近 window 个交易日的最高/最低点:
|
||||
- 若高点出现在低点之后(上涨波段):从低到高,回撤 = high - range × ratio
|
||||
- 若低点出现在高点之后(下跌波段):从高到低,回撤 = low + range × ratio
|
||||
比率:0.236 / 0.382 / 0.5 / 0.618 / 0.786
|
||||
"""
|
||||
if df.is_empty() or df.height < 10:
|
||||
return []
|
||||
sub = df.tail(window) if df.height > window else df
|
||||
close = float(df.tail(1)["close"][0])
|
||||
|
||||
highs = sub["high"].to_list()
|
||||
lows = sub["low"].to_list()
|
||||
hi_pos = highs.index(max(highs))
|
||||
lo_pos = lows.index(min(lows))
|
||||
hi_val = float(highs[hi_pos])
|
||||
lo_val = float(lows[lo_pos])
|
||||
if not _ok(hi_val) or not _ok(lo_val) or hi_val <= lo_val:
|
||||
return []
|
||||
|
||||
ratios = [0.236, 0.382, 0.5, 0.618, 0.786]
|
||||
rng = hi_val - lo_val
|
||||
|
||||
out: list[dict] = []
|
||||
# 判断波段方向:高点在低点之后 = 上涨波段(从低回撤)
|
||||
up_trend = hi_pos > lo_pos
|
||||
for r in ratios:
|
||||
if up_trend:
|
||||
val = hi_val - rng * r # 从高点向下回撤
|
||||
else:
|
||||
val = lo_val + rng * r # 从低点向上回撤
|
||||
out.append({"value": round(val, 2), "label": f"Fib {int(r * 1000) / 10:.1f}%",
|
||||
"type": "fib", "side": _side(val, close), "strength": "medium"})
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 8. 整数关口 —— 心理支撑/阻力位
|
||||
# ================================================================
|
||||
|
||||
def _round_numbers(df: pl.DataFrame, pct: float = 0.10, max_count: int = 8) -> list[dict]:
|
||||
"""当前价附近的心理整数关口。
|
||||
|
||||
整数位(如 10/11/12元,或 60/65/70元)是天然的心理支撑/阻力,
|
||||
低价股尤其明显。按价格量级自适应步长:
|
||||
- 价格 < 10: 步长 0.5 (如 6.5, 7.0, 7.5)
|
||||
- 价格 < 20: 步长 1 (如 11, 12, 13)
|
||||
- 价格 < 100: 步长 5 (如 60, 65, 70)
|
||||
- 价格 < 500: 步长 10 (如 110, 120, 130)
|
||||
- 价格 >= 500: 步长 50 (如 1100, 1150, 1200)
|
||||
过滤掉距当前价 <1% 的(太近,无分析价值),最多 max_count 个。
|
||||
"""
|
||||
if df.is_empty():
|
||||
return []
|
||||
close = float(df.tail(1)["close"][0])
|
||||
if not _ok(close):
|
||||
return []
|
||||
|
||||
if close < 10:
|
||||
step = 0.5
|
||||
elif close < 20:
|
||||
step = 1.0
|
||||
elif close < 100:
|
||||
step = 5.0
|
||||
elif close < 500:
|
||||
step = 10.0
|
||||
else:
|
||||
step = 50.0
|
||||
|
||||
lo = close * (1 - pct)
|
||||
hi = close * (1 + pct)
|
||||
# 找区间 [lo, hi] 内所有 step 的整数倍(严格限定在区间内)
|
||||
start = (int(lo / step) + (1 if lo % step > 0 else 0)) * step
|
||||
candidates: list[float] = []
|
||||
v = start
|
||||
while v <= hi:
|
||||
if v > 0:
|
||||
candidates.append(round(v, 2))
|
||||
v += step
|
||||
|
||||
# 按距当前价从近到远排序,取前 max_count 个
|
||||
candidates.sort(key=lambda x: abs(x - close))
|
||||
out: list[dict] = []
|
||||
for v in candidates[:max_count]:
|
||||
# 过滤距当前价 <1% 的(太近,无分析价值)
|
||||
if abs(v - close) / close < 0.01:
|
||||
continue
|
||||
out.append({"value": round(v, 2), "label": f"整数关口 {v:g}",
|
||||
"type": "round", "side": _side(v, close), "strength": "weak"})
|
||||
return out
|
||||
|
||||
def compute_levels(df: pl.DataFrame) -> dict[str, list[dict]]:
|
||||
"""计算 11 类价位点,返回 {分组key: [点位...]}。
|
||||
|
||||
分组 key 与 LEVEL_TYPES 一致(sr / pivot / extreme / boll /
|
||||
keltner_s / keltner_m / keltner_l / atr_stop / gap / fib / round),
|
||||
前端按 key 渲染开关按钮,逐组显隐。
|
||||
"""
|
||||
if df.is_empty():
|
||||
return {k: [] for k in LEVEL_TYPES}
|
||||
|
||||
try:
|
||||
return {
|
||||
"sr": _support_resistance(df),
|
||||
"pivot": _pivot_points(df),
|
||||
"extreme": _extreme_levels(df),
|
||||
"boll": _boll_channel(df),
|
||||
"keltner_s": _keltner_short(df),
|
||||
"keltner_m": _keltner_mid(df),
|
||||
"keltner_l": _keltner_long(df),
|
||||
"atr_stop": _atr_stops(df),
|
||||
"gap": _gap_levels(df),
|
||||
"fib": _fibonacci_levels(df),
|
||||
"round": _round_numbers(df),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("compute_levels failed: %s", e)
|
||||
return {k: [] for k in LEVEL_TYPES}
|
||||
|
||||
|
||||
def summarize_levels(levels: dict[str, list[dict]], close: float | None) -> str:
|
||||
"""生成给 AI 提示词的价位摘要文本(紧凑,供上下文)。"""
|
||||
if not close:
|
||||
return "无价位数据"
|
||||
parts: list[str] = []
|
||||
# 当前价
|
||||
parts.append(f"当前价 {close:.2f}")
|
||||
# 每组取前 2 个最相关的(距当前价近的优先)
|
||||
for key, label in LEVEL_TYPES.items():
|
||||
pts = levels.get(key, [])
|
||||
if not pts:
|
||||
continue
|
||||
# 按距当前价排序,取前 2
|
||||
ranked = sorted(pts, key=lambda p: abs(p["value"] - close))[:2]
|
||||
desc = "、".join(
|
||||
f"{p['label']}={p['value']}" for p in ranked
|
||||
)
|
||||
parts.append(f"{label}: {desc}")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 内部工具
|
||||
# ================================================================
|
||||
|
||||
def _ok(v: Any) -> bool:
|
||||
"""数值有效(非空/非 NaN/非 Inf/正数)。"""
|
||||
try:
|
||||
f = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
import math
|
||||
return math.isfinite(f) and f > 0
|
||||
|
||||
|
||||
def _side(level: float, close: float) -> str:
|
||||
"""价位相对当前价的方向。"""
|
||||
if level > close * 1.001:
|
||||
return "resistance"
|
||||
if level < close * 0.999:
|
||||
return "support"
|
||||
return "neutral"
|
||||
|
||||
|
||||
def _aggregate_levels(values: list[float], tol: float) -> list[float]:
|
||||
"""把相近的价位聚合(±tol),返回去重后的代表值(保留最新)。"""
|
||||
if not values:
|
||||
return []
|
||||
values = sorted(values)
|
||||
out: list[float] = [values[0]]
|
||||
for v in values[1:]:
|
||||
if abs(v - out[-1]) / out[-1] <= tol:
|
||||
out[-1] = v # 聚合到最新(更近期)
|
||||
else:
|
||||
out.append(v)
|
||||
return out
|
||||
@@ -1381,8 +1381,9 @@ def compute_enriched_today(
|
||||
def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) -> pl.DataFrame:
|
||||
"""盘中增量版的涨跌停/换手率/炸板/连板计算。"""
|
||||
inst_cols = ["symbol"]
|
||||
if "float_shares" in instruments.columns:
|
||||
inst_cols.append("float_shares")
|
||||
for c in ["float_shares", "limit_up", "limit_down"]:
|
||||
if c in instruments.columns:
|
||||
inst_cols.append(c)
|
||||
inst_subset = instruments.select(inst_cols).unique(subset=["symbol"])
|
||||
if "name" in instruments.columns:
|
||||
st_flag = (
|
||||
@@ -1431,14 +1432,31 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
|
||||
limit_up_price = _limit_price(prev_raw, limit_pct, up=True)
|
||||
limit_down_price = _limit_price(prev_raw, limit_pct, up=False)
|
||||
|
||||
# 生效涨跌停价: 优先用维表权威值 (instruments.limit_up/down, 交易所级别精确价),
|
||||
# 维表缺失 (新股上市前 5 日: limit_up 为 null 或哨兵 100000) 回退自算理论价。
|
||||
# 哨兵阈值 10000 用于识别 "新股无涨跌停限制" 的占位值 (实际涨停价不可能上万)。
|
||||
_SENTINEL = 10000.0
|
||||
if "limit_up" in df.columns:
|
||||
effective_limit_up = pl.when(
|
||||
pl.col("limit_up").is_not_null() & (pl.col("limit_up") < _SENTINEL)
|
||||
).then(pl.col("limit_up")).otherwise(limit_up_price)
|
||||
else:
|
||||
effective_limit_up = limit_up_price
|
||||
if "limit_down" in df.columns:
|
||||
effective_limit_down = pl.when(
|
||||
pl.col("limit_down").is_not_null() & (pl.col("limit_down") < _SENTINEL)
|
||||
).then(pl.col("limit_down")).otherwise(limit_down_price)
|
||||
else:
|
||||
effective_limit_down = limit_down_price
|
||||
|
||||
is_limit_up = (
|
||||
pl.when((prev_raw > 0) & (pl.col("raw_close") > 0))
|
||||
.then((pl.col("raw_close") - limit_up_price).abs() < 0.005)
|
||||
.then(pl.col("raw_close") >= (effective_limit_up - 0.005))
|
||||
.otherwise(None).cast(pl.Boolean)
|
||||
)
|
||||
is_limit_down = (
|
||||
pl.when((prev_raw > 0) & (pl.col("raw_close") > 0))
|
||||
.then((pl.col("raw_close") - limit_down_price).abs() < 0.005)
|
||||
.then(pl.col("raw_close") <= (effective_limit_down + 0.005))
|
||||
.otherwise(None).cast(pl.Boolean)
|
||||
)
|
||||
|
||||
@@ -1449,7 +1467,7 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
|
||||
pl.when(prev_raw > 0)
|
||||
.then(
|
||||
(~is_limit_down.fill_null(True))
|
||||
& (pl.col("low") <= limit_down_price + 0.005)
|
||||
& (pl.col("low") <= effective_limit_down + 0.005)
|
||||
& (pl.col("close") > pl.col("open"))
|
||||
).otherwise(None).cast(pl.Boolean)
|
||||
.alias("signal_limit_down_recovery"),
|
||||
@@ -1457,7 +1475,7 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
|
||||
pl.when((prev_raw > 0) & (pl.col("raw_high") > 0))
|
||||
.then(
|
||||
(~is_limit_up.fill_null(True))
|
||||
& (pl.col("raw_high") >= limit_up_price - 0.005)
|
||||
& (pl.col("raw_high") >= effective_limit_up - 0.005)
|
||||
).otherwise(None).cast(pl.Boolean)
|
||||
.alias("signal_broken_limit_up"),
|
||||
])
|
||||
@@ -1482,7 +1500,7 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
|
||||
])
|
||||
|
||||
# 清理
|
||||
cleanup = ["_limit_pct", "_is_st"]
|
||||
cleanup = ["_limit_pct", "_is_st", "limit_up", "limit_down"]
|
||||
for c in df.columns:
|
||||
if c.endswith("_inst"):
|
||||
cleanup.append(c)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""盘后管道 + 盘前维表同步。
|
||||
|
||||
调度:
|
||||
09:10 盘前 — 同步标的维表 instruments (全量覆盖)
|
||||
09:10 盘前 — 同步个股维表 instruments (全量覆盖)
|
||||
15:30 盘后 — 日K同步 + 增量除权因子 + enriched 计算 + 刷新视图
|
||||
|
||||
盘后同步策略:
|
||||
@@ -20,7 +20,7 @@ from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.indicators.pipeline import run_pipeline
|
||||
from app.config import settings
|
||||
from app.services import index_sync, instrument_sync, kline_sync
|
||||
from app.services import index_sync, instrument_sync, kline_sync, preferences as _prefs
|
||||
from app.tickflow.capabilities import Cap, CapabilitySet
|
||||
from app.tickflow.pools import DEMO_SYMBOLS, get_pool
|
||||
from app.tickflow.repository import KlineRepository
|
||||
@@ -69,7 +69,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
||||
|
||||
|
||||
def run_instruments_sync(repo: KlineRepository) -> dict:
|
||||
"""盘前同步标的维表。"""
|
||||
"""盘前同步个股维表。"""
|
||||
rows = instrument_sync.sync_instruments(repo.store.data_dir)
|
||||
_refresh_instruments_view(repo)
|
||||
_invalidate("instruments")
|
||||
@@ -89,12 +89,12 @@ def run_now(
|
||||
emit = on_progress or _noop
|
||||
skipped: list[str] = []
|
||||
|
||||
# Step 0: 先同步标的维表, 再解析标的池 — 确保标的池基于最新 instruments
|
||||
emit("sync_instruments", 2, "同步标的维表…")
|
||||
# Step 0: 先同步个股维表, 再解析标的池 — 确保标的池基于最新 instruments
|
||||
emit("sync_instruments", 2, "同步个股维表…")
|
||||
inst_rows = instrument_sync.sync_instruments(repo.store.data_dir)
|
||||
if inst_rows > 0:
|
||||
_refresh_instruments_view(repo)
|
||||
emit("sync_instruments", 8, f"标的维表同步完成,{inst_rows} 只标的")
|
||||
emit("sync_instruments", 8, f"个股维表同步完成,{inst_rows} 只标的")
|
||||
_invalidate("instruments")
|
||||
|
||||
emit("resolve_universe", 9, "解析标的池…")
|
||||
@@ -102,27 +102,41 @@ def run_now(
|
||||
emit("resolve_universe", 10, f"标的池规模:{len(universe)} 只")
|
||||
|
||||
# Step 1: 日 K 同步
|
||||
# 今天有数据 → 实时行情接口拉一次覆写(1请求全市场)
|
||||
# 今天没数据 → batch K-line API 补齐
|
||||
# 付费档 + 今天有数据 → 实时行情接口拉一次覆写(1请求全市场)
|
||||
# 有历史数据 → batch K-line API 补齐缺口
|
||||
# 无任何数据 → batch K-line API 拉首次 1 年
|
||||
from datetime import date as _date, timedelta as _td, datetime as _dt
|
||||
latest_daily = repo.latest_daily_date()
|
||||
today = _date.today()
|
||||
today_exists = latest_daily and latest_daily >= today
|
||||
new_daily_days = 0
|
||||
# 日K范围拉取的起点(分支3补缺口/分支4首次); 实时增量/跳过时为 None。
|
||||
# 供 Step 1.5 除权因子回溯范围对齐: 范围拉取→用日K范围, 非范围→最近N天兜底。
|
||||
daily_range_start: _date | None = None
|
||||
|
||||
if today_exists:
|
||||
# 今天有数据(QuoteService 已落盘)→ 实时行情覆写,确保最新
|
||||
# A 股日K拉取开关(默认开);关闭时跳过日K同步,保留已有数据
|
||||
pull_a_share = _prefs.get_pipeline_pull_a_share()
|
||||
if not pull_a_share:
|
||||
emit("sync_daily", 45, "已跳过 A 股日K同步(拉取内容未勾选)")
|
||||
logger.info("sync_daily: skipped (pipeline_pull_a_share=False)")
|
||||
elif today_exists and capset.has(Cap.QUOTE_POOL):
|
||||
# 付费档:今天有数据(QuoteService 已落盘)→ 实时行情覆写,确保最新。
|
||||
# free/none 档无 quote.pool 能力,即便今天已有数据(如从 expert 降级),
|
||||
# 也降级到下方 batch 路径刷新,避免调用无权限的实时行情接口。
|
||||
emit("sync_daily", 12, f"获取日K [{today} ~ {today}] 实时行情…")
|
||||
written_daily = kline_sync.sync_daily_by_quotes(repo)
|
||||
new_daily_days = 1
|
||||
emit("sync_daily", 45, f"日K 完成,{written_daily} 只标的")
|
||||
logger.info("sync_daily: [%s ~ %s] live quotes, %d symbols", today, today, written_daily)
|
||||
elif latest_daily:
|
||||
# 有历史但今天没数据 → batch 补齐缺口
|
||||
# 有历史 → batch 补齐缺口。
|
||||
# 也覆盖"今天已有数据但无实时行情权限(free/none)"的降级场景:
|
||||
# 此时 start_date = latest_daily = today,batch 刷新当天日K。
|
||||
start_date = latest_daily
|
||||
daily_range_start = start_date
|
||||
emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
|
||||
logger.info("sync_daily: [%s ~ %s] gap fill", start_date, today)
|
||||
logger.info("sync_daily: [%s ~ %s] %s", start_date, today,
|
||||
"refresh today" if today_exists else "gap fill")
|
||||
|
||||
def _daily_chunk_progress(cur: int, tot: int) -> None:
|
||||
emit("sync_daily", 12 + int(33 * cur / tot),
|
||||
@@ -140,6 +154,7 @@ def run_now(
|
||||
else:
|
||||
# 首次:无任何数据 → batch 拉 1 年
|
||||
start_date = today - _td(days=365)
|
||||
daily_range_start = start_date
|
||||
emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
|
||||
logger.info("sync_daily: [%s ~ %s] initial fetch", start_date, today)
|
||||
|
||||
@@ -157,36 +172,22 @@ def run_now(
|
||||
logger.info("sync_daily: [%s ~ %s] done", start_date, today)
|
||||
_invalidate("daily")
|
||||
|
||||
# Step 1.5: 增量同步除权因子 — 从已有数据最新日期的下一天开始获取
|
||||
# Step 1.5: 同步除权因子 — 范围与日K拉取方式对齐
|
||||
# 日K范围拉取(补缺口/首次) → 除权用日K范围 [daily_range_start, now]
|
||||
# 首次会覆盖整个日K区间内的历史除权事件; 补缺口天然只增量(起点=latest_daily≈昨天)
|
||||
# 日K实时增量/跳过(分支2/分支1) → 除权兜底拉最近 30 天, 补可能遗漏的新除权
|
||||
# (这两类分支不拉历史日K, 除权不能用日K范围, 只能兜底最近几日)
|
||||
written_adj = 0
|
||||
affected_symbols: list[str] = []
|
||||
if capset.has(Cap.ADJ_FACTOR):
|
||||
from datetime import datetime, timedelta
|
||||
adj_end = datetime.now()
|
||||
# 从已有除权因子数据的最新日期开始获取,避免重复拉取
|
||||
adj_factor_path = repo.store.data_dir / "adj_factor" / "all.parquet"
|
||||
fallback_start = adj_end - timedelta(days=30)
|
||||
if adj_factor_path.exists():
|
||||
try:
|
||||
from datetime import date as date_cls
|
||||
max_date = pl.scan_parquet(adj_factor_path).select(
|
||||
pl.col("trade_date").max()
|
||||
).collect().item()
|
||||
if max_date is not None:
|
||||
# trade_date 可能是 date / datetime / string 类型
|
||||
if isinstance(max_date, str):
|
||||
td = date_cls.fromisoformat(max_date)
|
||||
elif isinstance(max_date, datetime):
|
||||
td = max_date.date()
|
||||
else:
|
||||
td = max_date
|
||||
adj_start = datetime.combine(td, datetime.min.time())
|
||||
else:
|
||||
adj_start = fallback_start
|
||||
except Exception:
|
||||
adj_start = fallback_start
|
||||
if daily_range_start is not None:
|
||||
adj_start = datetime.combine(daily_range_start, datetime.min.time())
|
||||
else:
|
||||
adj_start = fallback_start
|
||||
# 日K实时增量/跳过时, 除权兜底拉最近 N 天, 覆盖周末/长假/停机期间的新除权事件。
|
||||
# 15 天: 覆盖春节/国庆最长约10天长假 + 故障恢复缓冲; sync_adj_factor 内部 merge+unique 幂等, 多拉无副作用。
|
||||
adj_start = adj_end - timedelta(days=15)
|
||||
adj_start_str = adj_start.strftime("%Y-%m-%d")
|
||||
adj_end_str = adj_end.strftime("%Y-%m-%d")
|
||||
emit("sync_adj", 50, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…")
|
||||
@@ -286,33 +287,119 @@ def run_now(
|
||||
_refresh_single_view(repo, "kline_enriched")
|
||||
_invalidate("enriched")
|
||||
|
||||
# Step 2.3: 指数同步 — 独立 kline_index_* 存储,不进入股票选股/策略链路。
|
||||
# Step 2.3: 指数 / ETF 同步 — 物理分开存储;ETF 可复权,指数不复权。
|
||||
written_index_daily = 0
|
||||
written_etf_daily = 0
|
||||
index_count = 0
|
||||
if capset.has(Cap.KLINE_DAILY_BATCH):
|
||||
emit("sync_index", 88, "同步指数列表与日K…")
|
||||
etf_count = 0
|
||||
etf_adj_symbols = 0
|
||||
pull_index = _prefs.get_pipeline_pull_index()
|
||||
pull_etf = _prefs.get_pipeline_pull_etf()
|
||||
|
||||
if capset.has(Cap.KLINE_DAILY_BATCH) and (pull_index or pull_etf):
|
||||
_types = []
|
||||
if pull_index:
|
||||
_types.append("指数")
|
||||
if pull_etf:
|
||||
_types.append("ETF")
|
||||
emit("sync_index", 88, f"同步{'+'.join(_types)}日K…")
|
||||
# 子阶段进度分配: 88.0(开始) → 89.0(完成), 指数占前半, ETF 占后半
|
||||
try:
|
||||
index_count = index_sync.sync_index_instruments(repo)
|
||||
index_dir = repo.store.data_dir / "kline_index_enriched"
|
||||
index_dates = sorted(
|
||||
d.name[5:] for d in index_dir.glob("date=*")
|
||||
if d.is_dir() and d.name.startswith("date=")
|
||||
) if index_dir.exists() else []
|
||||
index_start = _date.fromisoformat(index_dates[-1]) if index_dates else today - _td(days=365)
|
||||
written_index_daily = index_sync.sync_and_persist_index_daily(
|
||||
repo,
|
||||
capset,
|
||||
start_date=_dt.combine(index_start, _dt.min.time()),
|
||||
end_date=_dt.combine(today, _dt.min.time()),
|
||||
)
|
||||
if pull_index:
|
||||
emit("sync_index", 88, "同步指数维表…")
|
||||
index_count = index_sync.sync_index_instruments(repo, pull_index=True, pull_etf=False)
|
||||
emit("sync_index", 88, f"指数维表完成,{index_count} 只")
|
||||
index_dir = repo.store.data_dir / "kline_index_enriched"
|
||||
index_dates = sorted(
|
||||
d.name[5:] for d in index_dir.glob("date=*")
|
||||
if d.is_dir() and d.name.startswith("date=")
|
||||
) if index_dir.exists() else []
|
||||
index_start = _date.fromisoformat(index_dates[-1]) if index_dates else today - _td(days=365)
|
||||
|
||||
def _index_chunk(cur: int, tot: int) -> None:
|
||||
emit("sync_index", 88, f"指数日K批次 {cur}/{tot}",
|
||||
stage_pct=int(100 * cur / tot) if tot else 100, skip_log=cur < tot)
|
||||
|
||||
written_index_daily = index_sync.sync_and_persist_index_daily(
|
||||
repo,
|
||||
capset,
|
||||
start_date=_dt.combine(index_start, _dt.min.time()),
|
||||
end_date=_dt.combine(today, _dt.min.time()),
|
||||
on_chunk_done=_index_chunk,
|
||||
)
|
||||
emit("sync_index", 88, f"指数日K完成,{written_index_daily} 行")
|
||||
_invalidate("index_instruments")
|
||||
_invalidate("index_daily")
|
||||
_invalidate("index_enriched")
|
||||
|
||||
if pull_etf:
|
||||
emit("sync_index", 88, "同步 ETF 维表…")
|
||||
etf_count = index_sync.sync_etf_instruments(repo)
|
||||
emit("sync_index", 88, f"ETF 维表完成,{etf_count} 只")
|
||||
etf_symbols: list[str] = []
|
||||
etf_inst = repo.get_etf_instruments()
|
||||
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
|
||||
etf_symbols = sorted(set(etf_inst["symbol"].to_list()))
|
||||
if etf_symbols and capset.has(Cap.ADJ_FACTOR):
|
||||
try:
|
||||
emit("sync_index", 88, "同步 ETF 除权因子…")
|
||||
from datetime import datetime, timedelta
|
||||
adj_end = datetime.now()
|
||||
adj_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet"
|
||||
fallback_start = adj_end - timedelta(days=30)
|
||||
adj_start = fallback_start
|
||||
if adj_path.exists():
|
||||
max_date = pl.scan_parquet(adj_path).select(pl.col("trade_date").max()).collect().item()
|
||||
if max_date is not None:
|
||||
if isinstance(max_date, str):
|
||||
adj_start = datetime.combine(_date.fromisoformat(max_date), datetime.min.time())
|
||||
elif isinstance(max_date, datetime):
|
||||
adj_start = datetime.combine(max_date.date(), datetime.min.time())
|
||||
else:
|
||||
adj_start = datetime.combine(max_date, datetime.min.time())
|
||||
_, affected_etfs = index_sync.sync_etf_adj_factor(
|
||||
etf_symbols,
|
||||
repo,
|
||||
capset,
|
||||
start_time=adj_start,
|
||||
end_time=adj_end,
|
||||
)
|
||||
etf_adj_symbols = len(affected_etfs)
|
||||
emit("sync_index", 88, f"ETF 除权因子完成,{etf_adj_symbols} 只")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ETF adj_factor skipped: %s", e)
|
||||
etf_dir = repo.store.data_dir / "kline_etf_enriched"
|
||||
etf_dates = sorted(
|
||||
d.name[5:] for d in etf_dir.glob("date=*")
|
||||
if d.is_dir() and d.name.startswith("date=")
|
||||
) if etf_dir.exists() else []
|
||||
etf_start = _date.fromisoformat(etf_dates[-1]) if etf_dates else today - _td(days=365)
|
||||
|
||||
def _etf_chunk(cur: int, tot: int) -> None:
|
||||
emit("sync_index", 88, f"ETF 日K批次 {cur}/{tot}",
|
||||
stage_pct=int(100 * cur / tot) if tot else 100, skip_log=cur < tot)
|
||||
|
||||
written_etf_daily = index_sync.sync_and_persist_etf_daily(
|
||||
repo,
|
||||
capset,
|
||||
start_date=_dt.combine(etf_start, _dt.min.time()),
|
||||
end_date=_dt.combine(today, _dt.min.time()),
|
||||
on_chunk_done=_etf_chunk,
|
||||
)
|
||||
emit("sync_index", 88, f"ETF 日K完成,{written_etf_daily} 行")
|
||||
_invalidate("etf_instruments")
|
||||
_invalidate("etf_daily")
|
||||
|
||||
repo.refresh_index_views()
|
||||
_invalidate("index_instruments")
|
||||
_invalidate("index_daily")
|
||||
_invalidate("index_enriched")
|
||||
emit("sync_index", 89, f"指数完成,{index_count} 只指数,{written_index_daily} 行日K")
|
||||
emit(
|
||||
"sync_index",
|
||||
89,
|
||||
f"同步完成,指数 {index_count} 只/{written_index_daily} 行, ETF {etf_count} 只/{written_etf_daily} 行"
|
||||
+ (f", ETF复权 {etf_adj_symbols} 只" if etf_adj_symbols else ""),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("sync_index failed: %s", e)
|
||||
emit("sync_index", 89, f"指数同步失败:{e}")
|
||||
logger.warning("sync_index/etf failed: %s", e)
|
||||
emit("sync_index", 89, f"指数/ETF同步失败:{e}")
|
||||
else:
|
||||
skipped.append("sync_index")
|
||||
|
||||
@@ -359,6 +446,9 @@ def run_now(
|
||||
"enriched_days": written_enriched,
|
||||
"index_count": index_count,
|
||||
"index_daily_rows": written_index_daily,
|
||||
"etf_count": etf_count,
|
||||
"etf_daily_rows": written_etf_daily,
|
||||
"etf_adj_factor_symbols": etf_adj_symbols,
|
||||
"minute_rows": written_minute,
|
||||
"skipped_stages": skipped,
|
||||
}
|
||||
@@ -372,10 +462,15 @@ def _refresh_views(repo: KlineRepository) -> None:
|
||||
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
|
||||
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
|
||||
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
|
||||
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
|
||||
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
|
||||
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
|
||||
"kline_minute": f"{d}/kline_minute/**/*.parquet",
|
||||
"adj_factor": f"{d}/adj_factor/**/*.parquet",
|
||||
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
|
||||
"instruments": f"{d}/instruments/**/*.parquet",
|
||||
"instruments_index": f"{d}/instruments_index/**/*.parquet",
|
||||
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
|
||||
}
|
||||
for name, path in views.items():
|
||||
try:
|
||||
@@ -385,6 +480,7 @@ def _refresh_views(repo: KlineRepository) -> None:
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("refresh view %s failed: %s", name, e)
|
||||
repo.store._register_unified_views()
|
||||
|
||||
|
||||
def _refresh_single_view(repo: KlineRepository, name: str) -> None:
|
||||
@@ -395,10 +491,15 @@ def _refresh_single_view(repo: KlineRepository, name: str) -> None:
|
||||
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
|
||||
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
|
||||
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
|
||||
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
|
||||
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
|
||||
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
|
||||
"kline_minute": f"{d}/kline_minute/**/*.parquet",
|
||||
"adj_factor": f"{d}/adj_factor/**/*.parquet",
|
||||
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
|
||||
"instruments": f"{d}/instruments/**/*.parquet",
|
||||
"instruments_index": f"{d}/instruments_index/**/*.parquet",
|
||||
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
|
||||
}
|
||||
path = paths.get(name)
|
||||
if not path:
|
||||
@@ -449,10 +550,201 @@ def _run_tracked(fn, job_label: str) -> None:
|
||||
job_store.fail(job_id, f"scheduled {job_label} failed")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 定时复盘 (AI 大盘复盘报告)
|
||||
# ================================================================
|
||||
|
||||
REVIEW_JOB_ID = "scheduled_review"
|
||||
|
||||
|
||||
async def _run_scheduled_review(repo) -> None:
|
||||
"""定时复盘 job: 流式生成复盘 → 实时推 SSE(开着页面可见) → 落盘归档 → 推飞书。
|
||||
|
||||
与手动「生成复盘」体验一致: 流式事件经 quote_service.push_review_event →
|
||||
/api/intraday/stream 的 review_progress 事件 → 前端 reviewStore, 用户开着复盘页
|
||||
即可看到报告边生成边显示, 切走再回来也能看到生成中/已生成。
|
||||
LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
|
||||
任何异常都吞掉只记日志, 绝不影响调度器主循环。
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
from app.services import market_recap_reports
|
||||
from app import secrets_store as ss
|
||||
|
||||
# AI Key 未配置时跳过(避免每日报错刷日志)
|
||||
if not ss.get_ai_key():
|
||||
logger.info("scheduled review skipped: AI key not configured")
|
||||
return
|
||||
|
||||
app_state = _get_app_state()
|
||||
quote_service = getattr(app_state, "quote_service", None) if app_state else None
|
||||
depth_service = getattr(app_state, "depth_service", None) if app_state else None
|
||||
|
||||
content, meta = await _stream_review_with_retry(repo, quote_service, depth_service)
|
||||
if not content:
|
||||
logger.warning("scheduled review produced no content (meta=%s)", meta)
|
||||
# 通知前端进入 error 态(若有页面在听)
|
||||
if quote_service:
|
||||
quote_service.push_review_event(json.dumps(
|
||||
{"type": "error", "message": "复盘生成失败,请稍后手动重试"},
|
||||
ensure_ascii=False))
|
||||
return
|
||||
|
||||
# 落盘: 与手动生成完全相同的归档格式
|
||||
market_recap_reports.save_report({
|
||||
"as_of": meta.get("as_of"),
|
||||
"focus": "",
|
||||
"content": content,
|
||||
"summary": meta.get("summary", ""),
|
||||
"emotion_score": meta.get("emotion_score"),
|
||||
"emotion_label": meta.get("emotion_label", ""),
|
||||
})
|
||||
logger.info("scheduled review saved: as_of=%s", meta.get("as_of"))
|
||||
|
||||
# 通知前端: 生成完成且已归档(archived=true 让前端只刷新列表, 不重复归档)
|
||||
if quote_service:
|
||||
quote_service.push_review_event(json.dumps(
|
||||
{"type": "done", "archived": True}, ensure_ascii=False))
|
||||
|
||||
# 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。
|
||||
# 失败静默降级, 不影响已归档的报告。
|
||||
_maybe_push_review(content, meta)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("scheduled review failed: %s", e)
|
||||
# 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming
|
||||
try:
|
||||
app_state = _get_app_state()
|
||||
qs = getattr(app_state, "quote_service", None) if app_state else None
|
||||
if qs:
|
||||
import json as _json
|
||||
qs.push_review_event(_json.dumps(
|
||||
{"type": "error", "message": "复盘生成异常,请稍后手动重试"},
|
||||
ensure_ascii=False))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple[str, dict]:
|
||||
"""流式生成复盘, 每个事件推 SSE + 累积内容。LLM 断流时最多重试 2 次。
|
||||
|
||||
返回 (content, meta)。重试时推一个 retry 事件让前端清空已累积内容重新开始。
|
||||
成功(收到 done/无 error)或耗尽重试后返回。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from app.services.market_recap import recap_market_stream
|
||||
|
||||
max_attempts = 3 # 初次 + 2 次重试
|
||||
last_meta: dict = {}
|
||||
content_parts: list[str] = []
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
content_parts = [] # 每次重试重新累积
|
||||
failed = False
|
||||
try:
|
||||
async for evt_json in recap_market_stream(repo, quote_service, depth_service):
|
||||
evt = json.loads(evt_json)
|
||||
t = evt.get("type")
|
||||
|
||||
# 推给前端(让开着页面的用户实时看到, 与手动一致)
|
||||
if quote_service:
|
||||
quote_service.push_review_event(evt_json)
|
||||
|
||||
if t == "meta":
|
||||
last_meta = evt
|
||||
elif t == "delta" and evt.get("content"):
|
||||
content_parts.append(evt["content"])
|
||||
elif t == "error":
|
||||
failed = True
|
||||
logger.warning("scheduled review stream error (attempt %d/%d): %s",
|
||||
attempt, max_attempts, evt.get("message"))
|
||||
break # 触发重试
|
||||
elif t == "done":
|
||||
# 正常完成
|
||||
return "".join(content_parts), last_meta
|
||||
# 流自然结束(无 done 事件)且有内容, 视为成功
|
||||
if content_parts and not failed:
|
||||
return "".join(content_parts), last_meta
|
||||
except Exception as e: # noqa: BLE001
|
||||
# LLM 断流等异常(httpx.RemoteProtocolError)落到这里
|
||||
failed = True
|
||||
logger.warning("scheduled review stream exception (attempt %d/%d): %s",
|
||||
attempt, max_attempts, e)
|
||||
|
||||
# 失败: 决定是否重试
|
||||
if attempt < max_attempts:
|
||||
logger.info("scheduled review retrying in 3s (attempt %d → %d)", attempt, attempt + 1)
|
||||
# 通知前端: 即将重试, 清空已累积内容重新开始
|
||||
if quote_service:
|
||||
quote_service.push_review_event(json.dumps(
|
||||
{"type": "retry", "attempt": attempt + 1}, ensure_ascii=False))
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 耗尽重试, 返回已累积内容(可能为空)和最后 meta
|
||||
return "".join(content_parts), last_meta
|
||||
|
||||
|
||||
def _maybe_push_review(content: str, meta: dict) -> None:
|
||||
"""复盘报告归档后, 按 review_push_channels 选定的外部工具逐个推送完整报告。
|
||||
|
||||
定时生成与手动生成共用本函数 (手动归档端点 POST /api/market-recap/reports 也会调用)。
|
||||
channels 为空则不推送; 'feishu' 复用监控中心的全局飞书 Webhook 通道。
|
||||
推送失败静默降级 (Webhook 是辅助通道), 不影响已归档的报告。
|
||||
"""
|
||||
try:
|
||||
from app.services import preferences, webhook_adapter
|
||||
|
||||
channels = preferences.get_review_push_channels()
|
||||
if not channels:
|
||||
return
|
||||
|
||||
emotion = f"{meta.get('emotion_label') or ''}".strip()
|
||||
as_of = meta.get("as_of") or ""
|
||||
subtitle = as_of + (f" · 情绪 {emotion}" if emotion else "")
|
||||
|
||||
for ch in channels:
|
||||
if ch == "feishu":
|
||||
url = preferences.get_feishu_webhook_url()
|
||||
if not url:
|
||||
logger.info("review push(feishu) skipped: webhook not configured")
|
||||
continue
|
||||
secret = preferences.get_feishu_webhook_secret()
|
||||
ok = webhook_adapter.send_feishu_card(
|
||||
url, "TickFlow · 每日复盘", subtitle, content, secret
|
||||
)
|
||||
logger.info("review push(feishu) %s", "sent" if ok else "failed")
|
||||
# 未来更多渠道在此追加分支
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("review push error: %s", e)
|
||||
|
||||
|
||||
def _register_review_job(scheduler, repo, hour: int, minute: int) -> None:
|
||||
"""注册/更新定时复盘 job(工作日 mon-fri, Asia/Shanghai)。
|
||||
|
||||
供 start_scheduler(启动时) 和 settings API(改时间时) 共用。
|
||||
用 replace_existing=True, 重复注册只更新 trigger。
|
||||
|
||||
注意: _run_scheduled_review 是协程函数, 必须把函数对象本身(配合 args)传给
|
||||
add_job, 而非用 lambda 包裹 —— 否则 APScheduler 会把 lambda 当同步函数在线程池
|
||||
执行, 仅得到一个未 await 的协程对象, 复盘实际不会运行。
|
||||
"""
|
||||
scheduler.add_job(
|
||||
_run_scheduled_review,
|
||||
args=[repo],
|
||||
trigger=CronTrigger(day_of_week="mon-fri",
|
||||
hour=hour, minute=minute,
|
||||
timezone="Asia/Shanghai"),
|
||||
id=REVIEW_JOB_ID,
|
||||
misfire_grace_time=7200, # 复盘非关键, 允许 2 小时内补跑
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOScheduler:
|
||||
"""启动调度器。
|
||||
|
||||
工作日 09:10 — 同步标的维表
|
||||
工作日 09:10 — 同步个股维表
|
||||
工作日 HH:MM — 盘后管道(时间由用户偏好决定,默认 15:30)
|
||||
"""
|
||||
from app.services import preferences
|
||||
@@ -464,9 +756,9 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
|
||||
# 盘前: 同步 instruments(时间由偏好决定)
|
||||
def _instruments_task(on_progress=None):
|
||||
emit = on_progress or _noop
|
||||
emit("sync_instruments", 0, "同步标的维表…")
|
||||
emit("sync_instruments", 0, "同步个股维表…")
|
||||
result = run_instruments_sync(repo)
|
||||
emit("done", 100, f"标的维表同步完成,{result.get('instruments_rows', 0)} 只标的")
|
||||
emit("done", 100, f"个股维表同步完成,{result.get('instruments_rows', 0)} 只标的")
|
||||
return result
|
||||
|
||||
scheduler.add_job(
|
||||
@@ -480,11 +772,16 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
|
||||
)
|
||||
|
||||
# 盘后: 日 K + enriched(时间由偏好决定)
|
||||
def _pipeline_then_refresh(on_progress=None):
|
||||
# 与手动触发 (/api/pipeline/run) 对齐: 管道落盘后重建 Polars 内存缓存,
|
||||
# 否则 live_agg 的昨日连板数等基准列会停留在旧交易日, 次日开盘连板梯队
|
||||
# 整体少算一档 (仅手动触发或重启才会刷缓存, cron 调度路径此前漏了这步)。
|
||||
result = run_now(repo, capset, on_progress=on_progress)
|
||||
repo.refresh_cache()
|
||||
return result
|
||||
|
||||
scheduler.add_job(
|
||||
lambda: _run_tracked(
|
||||
lambda on_progress=None: run_now(repo, capset, on_progress=on_progress),
|
||||
"daily_pipeline",
|
||||
),
|
||||
lambda: _run_tracked(_pipeline_then_refresh, "daily_pipeline"),
|
||||
trigger=CronTrigger(day_of_week="mon-fri",
|
||||
hour=sched["hour"], minute=sched["minute"],
|
||||
timezone="Asia/Shanghai"),
|
||||
@@ -511,6 +808,16 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。
|
||||
# 默认关闭 —— 仅当用户在复盘页开启时才注册 job。
|
||||
# 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。
|
||||
# quote_service / depth_service 通过 _get_app_state() 延迟取用。
|
||||
review_sched = preferences.get_review_schedule()
|
||||
if review_sched["enabled"]:
|
||||
_register_review_job(scheduler, repo, review_sched["hour"], review_sched["minute"])
|
||||
logger.info("scheduled_review enabled @%02d:%02d mon-fri",
|
||||
review_sched["hour"], review_sched["minute"])
|
||||
|
||||
scheduler.start()
|
||||
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri",
|
||||
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"],
|
||||
|
||||
+73
-32
@@ -11,8 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import __version__
|
||||
from app import auth as auth_module
|
||||
from app.api import analysis, auth, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, strategy, watchlist
|
||||
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
||||
from app.api.routes import router as core_router
|
||||
from app.config import settings
|
||||
from app.jobs import daily_pipeline
|
||||
@@ -31,10 +30,18 @@ logger = logging.getLogger(__name__)
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info(
|
||||
"Stock Panel v%s starting (mode=%s)",
|
||||
"TickFlow Stock Panel v%s starting (mode=%s)",
|
||||
__version__, tf_client.current_mode(),
|
||||
)
|
||||
|
||||
# 首次启动: 若配置了 AUTH_PASSWORD 环境变量且未设过密码, 用它初始化。
|
||||
# 公网部署免 SSH 端口转发; 已设过密码则不覆盖 (改密码走 UI)。
|
||||
try:
|
||||
from app.services import auth as auth_service
|
||||
auth_service.bootstrap_from_env()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("auth bootstrap failed: %s", e)
|
||||
|
||||
# 数据层
|
||||
store = DataStore()
|
||||
repo = KlineRepository(store)
|
||||
@@ -91,7 +98,16 @@ async def lifespan(app: FastAPI):
|
||||
pull_scheduler.refresh(store.data_dir)
|
||||
app.state.pull_scheduler = pull_scheduler
|
||||
|
||||
# 财务数据独立调度 (需 Expert 套餐)
|
||||
# 内置扩展表 (概念/行业): 只创建 config (含拉取配置), 不自动拉数据
|
||||
# 数据获取由用户在概念/行业页点「获取数据」手动触发 (POST /api/ext-data/presets/{id}/fetch)
|
||||
try:
|
||||
from app.services.ext_presets import ensure_builtin_presets
|
||||
await ensure_builtin_presets(store.data_dir)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
|
||||
|
||||
# 财务数据 (需 Expert 套餐): 仅初始化调度器供 /api/financials/sync/* 手动同步,
|
||||
# 不启动自动调度——用户在「财务分析」页点「同步」手动拉取。
|
||||
from app.services.financial_sync import financial_scheduler
|
||||
financial_scheduler.start(store.data_dir, capset)
|
||||
app.state.financial_scheduler = financial_scheduler
|
||||
@@ -122,6 +138,9 @@ async def lifespan(app: FastAPI):
|
||||
monitor_engine = MonitorRuleEngine()
|
||||
monitor_engine.set_strategy_engine(strategy_engine)
|
||||
monitor_engine.set_data_dir(store.data_dir)
|
||||
# 复用 ScreenerService 的历史窗口加载器 (三级缓存, 启动预计算命中 ~0ms),
|
||||
# 让声明 filter_history 的策略 (如反包) 也能在实时监控里跑选股 → 盘中触发通知。
|
||||
monitor_engine.set_history_loader(_screener_svc._load_enriched_history)
|
||||
|
||||
# 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页)
|
||||
try:
|
||||
@@ -162,9 +181,9 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Stock Panel",
|
||||
title="TickFlow Stock Panel",
|
||||
version=__version__,
|
||||
description="A 股选股 + 监控 + 回测面板",
|
||||
description="A 股选股 + 回测面板 — TickFlow 适配",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -180,37 +199,54 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# 访问门控中间件:仅对 /api/* 路径校验,静态资源和前端路由放行,
|
||||
# 由前端 AccessGuard 控制 UI 展示。
|
||||
# ================================================================
|
||||
# 访问认证中间件
|
||||
# ================================================================
|
||||
# 拦截所有 /api/ 请求, 三种状态:
|
||||
# 1. 未设密码 + 本机/内网 → 放行(让本机用户访问面板 + 调 /api/auth/setup 设密码)
|
||||
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
|
||||
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
|
||||
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
|
||||
_AUTH_WHITELIST_PREFIX = ("/api/auth/",)
|
||||
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def access_uuid_middleware(request: Request, call_next):
|
||||
if auth_module.access_control_enabled():
|
||||
path = request.url.path
|
||||
# 白名单直接放行
|
||||
if not auth_module.is_public_path(path):
|
||||
token = auth_module.get_access_token_from_request(request)
|
||||
role = auth_module.validate_access_token(token)
|
||||
# 管理员接口需 admin 角色
|
||||
if auth_module.is_admin_path(path):
|
||||
if role != auth_module.AuthRole.ADMIN:
|
||||
return JSONResponse(
|
||||
status_code=403 if role else 401,
|
||||
content={"detail": "需要管理员权限"},
|
||||
)
|
||||
# 其它 API 调用需任意有效角色
|
||||
elif path.startswith("/api/"):
|
||||
if role is None:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "访问令牌无效或已过期,请先验证"},
|
||||
)
|
||||
return await call_next(request)
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
# 仅 /api/ 走认证; 静态资源(前端页面/assets)放行, 由前端处理跳转
|
||||
if not path.startswith("/api/"):
|
||||
return await call_next(request)
|
||||
# 白名单放行(设密码/登录/探活本身不拦)
|
||||
if path.startswith(_AUTH_WHITELIST_PREFIX) or path in _AUTH_WHITELIST_EXACT:
|
||||
return await call_next(request)
|
||||
|
||||
from app.services import auth as auth_service
|
||||
# 情况 1+2: 未设密码
|
||||
if not auth_service.is_configured():
|
||||
# 本机/内网 → 放行(服务器主人可访问, 并去 /login 设密码)
|
||||
if auth_api._is_local_network(auth_api._client_ip(request)):
|
||||
return await call_next(request)
|
||||
# 公网 → 拒绝。不裸奔, 也不给公网设密码的机会(防抢占)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"detail": "面板尚未初始化访问密码,请通过 SSH/本机浏览器访问以设置密码",
|
||||
"code": "NOT_INITIALIZED",
|
||||
},
|
||||
)
|
||||
|
||||
# 情况 3: 已设密码, 检查会话
|
||||
token = request.cookies.get(auth_api.COOKIE_NAME)
|
||||
if token and auth_service.is_valid_session(token):
|
||||
return await call_next(request)
|
||||
# 未登录: 401(前端跳登录页)
|
||||
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
|
||||
|
||||
|
||||
# 路由
|
||||
app.include_router(core_router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(auth.admin_router)
|
||||
app.include_router(auth_api.router)
|
||||
app.include_router(kline.router)
|
||||
app.include_router(watchlist.router)
|
||||
app.include_router(screener.router)
|
||||
@@ -223,16 +259,21 @@ app.include_router(pipeline.router)
|
||||
app.include_router(data.router)
|
||||
app.include_router(ext_data.router)
|
||||
app.include_router(financials.router)
|
||||
app.include_router(stock_analysis.router)
|
||||
app.include_router(market_recap.router)
|
||||
app.include_router(settings_api.router)
|
||||
app.include_router(strategy.router)
|
||||
app.include_router(signals.router)
|
||||
app.include_router(monitor_rules.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(rps.router)
|
||||
|
||||
|
||||
# 能力门控异常 → 403(而非默认 500)
|
||||
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
|
||||
# 若不注册 handler 会冒泡成 500 Internal Server Error,对前端不友好且语义错误。
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.tickflow.capabilities import CapabilityDenied
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ def clear(*keys: str) -> dict:
|
||||
|
||||
|
||||
def get_tickflow_key() -> str:
|
||||
"""取当前数据源 Key:secrets.json 优先,否则 .env。"""
|
||||
"""取当前 TickFlow Key:secrets.json 优先,否则 .env。"""
|
||||
val = load().get("tickflow_api_key")
|
||||
if val:
|
||||
return val
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""AI provider adapter for OpenAI-compatible APIs and local Codex CLI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from app import secrets_store
|
||||
from app.config import settings
|
||||
|
||||
OPENAI_COMPAT_PROVIDER = "openai_compat"
|
||||
CODEX_CLI_PROVIDER = "codex_cli"
|
||||
CODEX_DEFAULT_COMMAND = "codex"
|
||||
CODEX_SERVICE_TIER_FALLBACK = "fast"
|
||||
CODEX_SUPPORTED_SERVICE_TIERS = {"fast", "flex"}
|
||||
|
||||
Message = dict[str, str]
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def current_ai_provider() -> str:
|
||||
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:
|
||||
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
|
||||
return secrets_store.get_ai_config("ai_model", settings.ai_model)
|
||||
|
||||
|
||||
def current_codex_command() -> str:
|
||||
return normalize_codex_command(
|
||||
secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command),
|
||||
strict=False,
|
||||
)
|
||||
|
||||
|
||||
def is_codex_cli_provider(provider: str | None = None) -> bool:
|
||||
return (provider or current_ai_provider()) == CODEX_CLI_PROVIDER
|
||||
|
||||
|
||||
def normalize_codex_model(model: str) -> str:
|
||||
value = model.strip()
|
||||
aliases = {
|
||||
"gpt5": "gpt-5",
|
||||
"gpt5.5": "gpt-5.5",
|
||||
}
|
||||
return aliases.get(value.lower(), value)
|
||||
|
||||
|
||||
def normalize_codex_command(command: str | None, *, strict: bool = True) -> str:
|
||||
value = (command or "").strip()
|
||||
if not value or value.lower() == CODEX_DEFAULT_COMMAND:
|
||||
return CODEX_DEFAULT_COMMAND
|
||||
if strict:
|
||||
raise ValueError("Codex CLI 仅支持使用默认 codex 命令自动解析, 不支持自定义可执行路径")
|
||||
return CODEX_DEFAULT_COMMAND
|
||||
|
||||
|
||||
def normalize_openai_base_url(url: str) -> str:
|
||||
"""Return the OpenAI-compatible base URL expected by the OpenAI SDK."""
|
||||
base = (url or "").strip().rstrip("/")
|
||||
if base.endswith("/chat/completions"):
|
||||
base = base[: -len("/chat/completions")].rstrip("/")
|
||||
if not base.endswith("/v1"):
|
||||
base = f"{base}/v1"
|
||||
return base
|
||||
|
||||
|
||||
def codex_cli_available() -> bool:
|
||||
try:
|
||||
_codex_base_command()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
def ai_configured(provider: str | None = None) -> bool:
|
||||
provider = provider or current_ai_provider()
|
||||
if is_codex_cli_provider(provider):
|
||||
return codex_cli_available()
|
||||
return bool(secrets_store.get_ai_key())
|
||||
|
||||
|
||||
async def generate_ai_text(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 3000,
|
||||
timeout: float = 180.0,
|
||||
) -> str:
|
||||
"""Return a complete AI response from the currently configured provider."""
|
||||
if is_codex_cli_provider():
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def stream_ai_text(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
temperature: float = 0.5,
|
||||
max_tokens: int = 4000,
|
||||
timeout: float = 180.0,
|
||||
) -> 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():
|
||||
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
|
||||
return
|
||||
|
||||
async for chunk in _stream_openai(
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _run_openai_once(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
timeout: float,
|
||||
) -> str:
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
if not ai_key:
|
||||
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
|
||||
|
||||
client = _openai_client(ai_key, timeout)
|
||||
resp = await client.chat.completions.create(
|
||||
model=current_ai_model(),
|
||||
messages=list(messages),
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
if not resp.choices:
|
||||
return ""
|
||||
return (resp.choices[0].message.content or "").strip()
|
||||
|
||||
|
||||
async def _stream_openai(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
timeout: float,
|
||||
) -> AsyncIterator[str]:
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
if not ai_key:
|
||||
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
|
||||
|
||||
client = _openai_client(ai_key, timeout)
|
||||
stream = await client.chat.completions.create(
|
||||
model=current_ai_model(),
|
||||
messages=list(messages),
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async for chunk in stream:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta and delta.content:
|
||||
yield delta.content
|
||||
|
||||
|
||||
def _openai_client(api_key: str, timeout: float):
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
|
||||
return AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=normalize_openai_base_url(secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)),
|
||||
timeout=timeout,
|
||||
max_retries=2,
|
||||
default_headers={"User-Agent": user_agent},
|
||||
)
|
||||
|
||||
|
||||
async def _run_codex_cli(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
max_tokens: int,
|
||||
timeout: float,
|
||||
) -> str:
|
||||
prompt = _codex_prompt(messages, max_tokens=max_tokens)
|
||||
with tempfile.TemporaryDirectory(prefix="tickflow-codex-run-") as run_dir:
|
||||
run_path = Path(run_dir)
|
||||
codex_home_path = run_path / "codex-home"
|
||||
workspace_path = run_path / "workspace"
|
||||
codex_home_path.mkdir()
|
||||
workspace_path.mkdir()
|
||||
output_path = codex_home_path / "last-message.txt"
|
||||
_prepare_codex_home(codex_home_path)
|
||||
|
||||
args = [
|
||||
*_codex_base_command(),
|
||||
"exec",
|
||||
"--ephemeral",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"--skip-git-repo-check",
|
||||
"--color",
|
||||
"never",
|
||||
"--output-last-message",
|
||||
str(output_path),
|
||||
]
|
||||
model = current_ai_model().strip()
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
args.extend(["--cd", str(workspace_path), "-"])
|
||||
|
||||
env = os.environ.copy()
|
||||
env.setdefault("NO_COLOR", "1")
|
||||
env["CODEX_HOME"] = str(codex_home_path)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(prompt.encode("utf-8")),
|
||||
timeout=timeout,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise RuntimeError("Codex CLI 调用超时, 请稍后重试或检查本机 Codex 登录状态") from exc
|
||||
|
||||
out = _clean_process_text(stdout)
|
||||
err = _clean_process_text(stderr)
|
||||
final_message = _read_output_file(output_path)
|
||||
if proc.returncode != 0:
|
||||
detail = err or out or f"exit code {proc.returncode}"
|
||||
raise RuntimeError(f"Codex CLI 调用失败: {detail[-1200:]}")
|
||||
result = final_message or out
|
||||
if not result:
|
||||
raise RuntimeError("Codex CLI 未返回内容")
|
||||
return result
|
||||
|
||||
|
||||
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
|
||||
parts = [
|
||||
"You are TickFlow Stock Panel's local AI provider.",
|
||||
"This is a text-generation task. The working directory is intentionally empty.",
|
||||
"Use only the user-provided prompt content below; do not inspect or modify local files.",
|
||||
"Return only the final requested content; do not include execution logs.",
|
||||
]
|
||||
if max_tokens > 0:
|
||||
parts.append(f"Keep the final answer within about {max_tokens} output tokens.")
|
||||
for message in messages:
|
||||
role = message.get("role", "user")
|
||||
content = message.get("content", "")
|
||||
parts.append(f"\n<{role}>\n{content}\n</{role}>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _codex_base_command() -> list[str]:
|
||||
command = current_codex_command()
|
||||
resolved = _resolve_command(command)
|
||||
if not resolved:
|
||||
raise RuntimeError(f"未找到 Codex CLI 命令: {command}")
|
||||
|
||||
if sys.platform == "win32" and resolved.lower().endswith(".ps1"):
|
||||
return ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved]
|
||||
return [resolved]
|
||||
|
||||
|
||||
def _resolve_command(command: str) -> str | None:
|
||||
if command.lower() != CODEX_DEFAULT_COMMAND:
|
||||
return None
|
||||
|
||||
if sys.platform == "win32":
|
||||
desktop_codex = _resolve_windows_desktop_codex()
|
||||
if desktop_codex:
|
||||
return desktop_codex
|
||||
|
||||
resolved = shutil.which(command)
|
||||
if sys.platform == "win32" and resolved:
|
||||
resolved_path = Path(resolved)
|
||||
if not resolved_path.suffix:
|
||||
cmd_path = resolved_path.with_suffix(".cmd")
|
||||
if cmd_path.exists():
|
||||
return str(cmd_path)
|
||||
if not resolved and sys.platform == "win32" and not command.lower().endswith(".cmd"):
|
||||
resolved = shutil.which(f"{command}.cmd")
|
||||
if not resolved and sys.platform == "win32":
|
||||
resolved = _resolve_windows_codex_command(command)
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_windows_codex_command(command: str) -> str | None:
|
||||
"""Find npm-installed Codex when the backend process has a minimal PATH."""
|
||||
raw = Path(command)
|
||||
if raw.parent != Path("."):
|
||||
return None
|
||||
|
||||
names = [command]
|
||||
if not raw.suffix:
|
||||
names = [f"{command}.cmd", f"{command}.exe", f"{command}.bat", f"{command}.ps1", command]
|
||||
|
||||
dirs: list[Path] = []
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata:
|
||||
dirs.append(Path(appdata) / "npm")
|
||||
dirs.append(Path.home() / "AppData" / "Roaming" / "npm")
|
||||
|
||||
for env_name in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
|
||||
value = os.environ.get(env_name)
|
||||
if value:
|
||||
dirs.append(Path(value) / "nodejs")
|
||||
|
||||
for directory in dirs:
|
||||
for name in names:
|
||||
candidate = directory / name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_windows_desktop_codex() -> str | None:
|
||||
"""Prefer the Codex Desktop bundled CLI over an older npm shim."""
|
||||
local_appdata = os.environ.get("LOCALAPPDATA")
|
||||
if not local_appdata:
|
||||
return None
|
||||
|
||||
root = Path(local_appdata) / "OpenAI" / "Codex" / "bin"
|
||||
if not root.exists():
|
||||
return None
|
||||
|
||||
candidates = list(root.glob("*/codex.exe"))
|
||||
direct = root / "codex.exe"
|
||||
if direct.exists():
|
||||
candidates.append(direct)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
newest = max(candidates, key=lambda p: p.stat().st_mtime)
|
||||
return str(newest)
|
||||
|
||||
|
||||
def _prepare_codex_home(target: Path) -> None:
|
||||
"""Create an isolated CODEX_HOME that reuses auth but not fragile config."""
|
||||
source = _codex_home()
|
||||
auth_file = source / "auth.json"
|
||||
if auth_file.exists():
|
||||
shutil.copy2(auth_file, target / "auth.json")
|
||||
_write_compatible_codex_config(target / "config.toml")
|
||||
|
||||
|
||||
def _codex_home() -> Path:
|
||||
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
|
||||
|
||||
|
||||
def _write_compatible_codex_config(path: Path) -> None:
|
||||
config = _read_codex_config()
|
||||
lines: list[str] = []
|
||||
|
||||
tier = str(config.get("service_tier") or "").strip()
|
||||
if tier not in CODEX_SUPPORTED_SERVICE_TIERS:
|
||||
tier = CODEX_SERVICE_TIER_FALLBACK
|
||||
lines.append(_toml_string("service_tier", tier))
|
||||
lines.append(_toml_string("approval_policy", "never"))
|
||||
lines.append(_toml_string("sandbox_mode", "read-only"))
|
||||
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _read_codex_config() -> dict:
|
||||
path = _codex_home() / "config.toml"
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
except tomllib.TOMLDecodeError:
|
||||
return _read_codex_config_lenient(path)
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
|
||||
def _read_codex_config_lenient(path: Path) -> dict:
|
||||
config: dict[str, str] = {}
|
||||
pattern = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*$')
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
config[match.group(1)] = match.group(2)
|
||||
except OSError:
|
||||
pass
|
||||
return config
|
||||
|
||||
|
||||
def _toml_string(key: str, value: str) -> str:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'{key} = "{escaped}"'
|
||||
|
||||
|
||||
def _clean_process_text(raw: bytes) -> str:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
return _ANSI_RE.sub("", text).strip()
|
||||
|
||||
|
||||
def _read_output_file(path: Path) -> str:
|
||||
if path.exists():
|
||||
return _ANSI_RE.sub("", path.read_text(encoding="utf-8", errors="replace")).strip()
|
||||
return ""
|
||||
@@ -0,0 +1,101 @@
|
||||
"""AI 财务分析报告持久化存储。
|
||||
|
||||
存储位置: data/user_data/ai_reports.json (数组,按 created_at 降序)
|
||||
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
|
||||
|
||||
每条报告结构:
|
||||
{
|
||||
"id": "rpt_xxx", # 唯一 id
|
||||
"symbol": "600519.SH",
|
||||
"name": "贵州茅台",
|
||||
"focus": "", # 用户追加的关心点(可为空)
|
||||
"content": "# ...markdown", # 报告正文
|
||||
"periods": 4, # 基于几期数据生成
|
||||
"summary": "metrics: 1期...", # 数据摘要
|
||||
"created_at": "2026-06-25T10:00:00"
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_REPORTS = 20
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "ai_reports.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def list_reports() -> list[dict]:
|
||||
"""返回全部报告(按 created_at 降序)。"""
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ai_reports.json malformed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _save_all(reports: list[dict]) -> None:
|
||||
"""全量写入(裁剪到 MAX_REPORTS)。"""
|
||||
# 保持降序
|
||||
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
if len(reports) > MAX_REPORTS:
|
||||
reports = reports[:MAX_REPORTS]
|
||||
_path().write_text(
|
||||
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_report(report: dict) -> dict:
|
||||
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。
|
||||
|
||||
自动补全 id 与 created_at(若缺),并裁剪到上限。
|
||||
"""
|
||||
reports = list_reports()
|
||||
if not report.get("id"):
|
||||
report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
|
||||
if not report.get("created_at"):
|
||||
report["created_at"] = _now_iso()
|
||||
reports.append(report)
|
||||
_save_all(reports)
|
||||
logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
|
||||
return report
|
||||
|
||||
|
||||
def delete_report(report_id: str) -> bool:
|
||||
"""删除指定报告。返回是否删除成功。"""
|
||||
reports = list_reports()
|
||||
before = len(reports)
|
||||
reports = [r for r in reports if r.get("id") != report_id]
|
||||
if len(reports) < before:
|
||||
_save_all(reports)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clear_reports() -> int:
|
||||
"""清空全部报告。返回删除数量。"""
|
||||
reports = list_reports()
|
||||
n = len(reports)
|
||||
if n > 0:
|
||||
_save_all([])
|
||||
return n
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。"""
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
@@ -0,0 +1,201 @@
|
||||
"""访问密码认证 — 单用户, 自托管场景。
|
||||
|
||||
设计:
|
||||
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。
|
||||
即使 auth.json 泄露, 也无法逆向出明文密码。
|
||||
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。
|
||||
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。
|
||||
|
||||
安全要点:
|
||||
- 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。
|
||||
- 登录限流: 错5次锁5分钟(见 auth router 内存计数)。
|
||||
- 单密码, 不做多用户(避免重构全项目数据层)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets as _secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# PBKDF2 参数(NIST 推荐, 单次校验 ~100ms, 兼顾安全与响应)
|
||||
_PBKDF2_ITER = 200_000
|
||||
_SALT_LEN = 16
|
||||
_TOKEN_BYTES = 32
|
||||
|
||||
# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率)
|
||||
SESSION_TTL = 30 * 24 * 3600
|
||||
|
||||
_lock = threading.Lock()
|
||||
# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。
|
||||
_sessions: dict[str, float] = {}
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "auth.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
p = _path()
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("auth.json malformed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
p = _path()
|
||||
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(p, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
|
||||
if salt is None:
|
||||
salt = os.urandom(_SALT_LEN)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER)
|
||||
return salt.hex(), dk.hex()
|
||||
|
||||
|
||||
def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
|
||||
"""恒定时间比较, 防时序攻击。"""
|
||||
try:
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(hash_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER)
|
||||
return _secrets.compare_digest(actual, expected)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 密码管理
|
||||
# ================================================================
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""是否已设置访问密码。"""
|
||||
d = _load()
|
||||
return bool(d.get("password_hash"))
|
||||
|
||||
|
||||
def set_password(password: str) -> None:
|
||||
"""设置/修改访问密码。清空所有现有会话(强制重新登录)。"""
|
||||
if len(password) < 6:
|
||||
raise ValueError("密码至少 6 位")
|
||||
salt_hex, hash_hex = _hash_password(password)
|
||||
with _lock:
|
||||
_sessions.clear() # 改密码 = 旧会话全部失效
|
||||
_save({
|
||||
"password_hash": hash_hex,
|
||||
"password_salt": salt_hex,
|
||||
"updated_at": int(time.time()),
|
||||
"sessions": {}, # 清空持久化会话
|
||||
})
|
||||
logger.info("access password set")
|
||||
|
||||
|
||||
def bootstrap_from_env() -> bool:
|
||||
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且尚未设过密码, 则用它设密码。
|
||||
|
||||
公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。
|
||||
明文密码只在内存/配置中, 经 set_password() 哈希后写入 auth.json (chmod 0600)。
|
||||
一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。
|
||||
|
||||
Returns:
|
||||
True 表示本次用环境变量初始化了密码; False 表示无需初始化。
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
pwd = (settings.auth_password or "").strip()
|
||||
if not pwd:
|
||||
return False
|
||||
if is_configured():
|
||||
# 已设过密码, 不覆盖 (避免环境变量反复重置用户在 UI 改的密码)
|
||||
return False
|
||||
try:
|
||||
set_password(pwd)
|
||||
logger.info("access password bootstrapped from AUTH_PASSWORD env (one-time)")
|
||||
return True
|
||||
except ValueError as e:
|
||||
# 密码不合规 (< 6 位), 记日志但不阻断启动
|
||||
logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def verify_and_create_session(password: str) -> str | None:
|
||||
"""验证密码, 成功则创建会话并返回 token, 失败返回 None。"""
|
||||
d = _load()
|
||||
if not d.get("password_hash"):
|
||||
return None
|
||||
if not _verify_password(password, d.get("password_salt", ""), d["password_hash"]):
|
||||
return None
|
||||
token = _secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
expire = time.time() + SESSION_TTL
|
||||
with _lock:
|
||||
_sessions[token] = expire
|
||||
_persist_sessions_locked()
|
||||
return token
|
||||
|
||||
|
||||
def revoke_session(token: str) -> None:
|
||||
"""注销会话(登出)。"""
|
||||
with _lock:
|
||||
_sessions.pop(token, None)
|
||||
_persist_sessions_locked()
|
||||
|
||||
|
||||
def is_valid_session(token: str) -> bool:
|
||||
"""检查会话是否有效(存在且未过期)。过期则清理。"""
|
||||
if not token:
|
||||
return False
|
||||
with _lock:
|
||||
expire = _sessions.get(token)
|
||||
if expire is None:
|
||||
return False
|
||||
if time.time() > expire:
|
||||
_sessions.pop(token, None)
|
||||
_persist_sessions_locked()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _persist_sessions_locked() -> None:
|
||||
"""把当前内存会话写回 auth.json(需持锁调用)。"""
|
||||
d = _load()
|
||||
d["sessions"] = {t: exp for t, exp in _sessions.items()}
|
||||
_save(d)
|
||||
|
||||
|
||||
def _restore_sessions() -> None:
|
||||
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
|
||||
with _lock:
|
||||
d = _load()
|
||||
now = time.time()
|
||||
saved = d.get("sessions") or {}
|
||||
for token, expire in saved.items():
|
||||
if isinstance(expire, (int, float)) and expire > now:
|
||||
_sessions[token] = expire
|
||||
if len(_sessions) != len(saved):
|
||||
# 有过期会话被清理, 落盘一次
|
||||
_persist_sessions_locked()
|
||||
|
||||
|
||||
# 模块加载时恢复会话
|
||||
try:
|
||||
_restore_sessions()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("restore sessions failed: %s", e)
|
||||
@@ -0,0 +1,357 @@
|
||||
"""AI 概念轮动分析 — 从概念涨幅排名矩阵提炼主线/新晋/退潮信号。
|
||||
|
||||
数据来源:
|
||||
- rps_rotation.build_rps_rotation: 概念涨幅排名矩阵 (N 日 × ~387 概念)
|
||||
- market_overview_builder.build_market_overview: 大盘背景 (指数/情绪/涨停)
|
||||
|
||||
架构 (复刻 market_recap):
|
||||
预计算轮动信号 → 拼装 prompt → stream_ai_text 流式调用 → NDJSON 协议输出
|
||||
协议事件: meta(摘要) / delta(文本片段) / error / done
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# System Prompt — 轮动策略师人格 + 固定章节模板
|
||||
# ================================================================
|
||||
|
||||
_SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的资深策略师,拥有 12 年一线实战经验,擅长从概念板块的**涨幅排名矩阵**中识别主力资金脉络,区分机构主导的持续性主线与游资驱动的脉冲式轮动,产出可直接指导题材跟踪与节奏把握的轮动分析。
|
||||
|
||||
## 输出规范
|
||||
|
||||
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
|
||||
|
||||
### 1. 🎯 主线研判(2-3 句)
|
||||
点名当前最核心的 1-2 条主线题材(连续多日霸榜的强势概念),用一句话概括其逻辑(政策/产业/业绩/事件驱动),并判断是**主升期/加速期/扩散期/见顶期**。结尾用【主线强度:强 / 中 / 弱】定性。
|
||||
|
||||
### 2. 🆕 新晋强势
|
||||
列出排名快速跃升的概念(从榜单中后段冲进前列的),逐个给出:
|
||||
- 概念名 + 近 N 日排名变化(如 `45→20→8`)
|
||||
- 涨幅加速度(连日递增 = 趋势加强)
|
||||
- 可能的驱动逻辑(从板块属性推断,不要编造具体消息)
|
||||
- 判断是**主力切入**还是**消息脉冲**
|
||||
|
||||
### 3. 📉 退潮预警
|
||||
列出从高位明显滑落的概念(连续排名下滑或涨幅骤降),逐个给出:
|
||||
- 概念名 + 排名下滑轨迹
|
||||
- 退潮性质(高位分歧/资金撤离/补跌)
|
||||
- 是否扩散风险
|
||||
|
||||
### 4. 🏛️ 机构主线 vs 🎰 游资轮动
|
||||
基于排名稳定性区分两类资金行为:
|
||||
- **机构主线**:排名标准差小、长期稳居前列的概念 → 持续性判断、是否可作底仓方向
|
||||
- **游资轮动**:排名剧烈波动、脉冲式冲高的概念 → 短线节奏提示、追高风险
|
||||
给出当前市场**整体轮动节奏**(快轮动/慢轮动/主线聚焦)的判断。
|
||||
|
||||
### 5. 🌐 结合大盘
|
||||
结合提供的大盘数据(指数涨跌/情绪/涨停数),判断:
|
||||
- 当前大盘环境对题材轮动是助力还是阻力
|
||||
- 情绪温度与轮动节奏的匹配度(如情绪冰点但题材活跃 = 抱团;情绪火热但轮动快 = 末段)
|
||||
|
||||
### 6. 🎯 操作建议
|
||||
- **跟踪方向**:主线延续 + 新晋确认的概念
|
||||
- **规避方向**:明确退潮 + 高位脉冲的概念
|
||||
- **节奏提示**:当前适合追高 / 低吸 / 观望,及切换信号(如"主线概念连续 2 日跌出前 10 则确认退潮")
|
||||
|
||||
### 7. ⚠️ 风险提示
|
||||
列出需要盯的风险(如主线断层、情绪与轮动背离、成交萎缩)。末尾附一行:
|
||||
"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
|
||||
|
||||
## 分析准则(务必遵守)
|
||||
|
||||
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤。不要写"我先看...""基于上述数据我认为"——直接给结论。
|
||||
1. **数据说话**:每个判断引用具体排名/涨幅数值,严禁空泛套话("强势"必须改成"连续 4 日稳居前 5,均涨 +4.2%")。
|
||||
2. **诚实中立**:数据不支持的结论就直言"信号不足,暂无法判断",不要硬凑。
|
||||
3. **区分资金性质**:这是本分析的核心价值——机构 vs 游资的判断必须基于排名稳定性(标准差),不要凭感觉。
|
||||
4. **不重复数字**:正文负责解读信号含义,不要照抄罗列已提供的全部原始数据。
|
||||
5. **简明实战**:总字数 1000-1800 字,重在可执行。
|
||||
6. **客观推断**:若无明确消息,从量价异动推断可能逻辑并给结论,不要标注"[推断]"或编造具体新闻。
|
||||
|
||||
现在请基于下方概念轮动数据进行分析。"""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 预计算: 把排名矩阵转成结构化轮动信号
|
||||
# ================================================================
|
||||
|
||||
# 每类信号最多取多少个概念喂给 AI (控制 token)
|
||||
_TOP_N = 8
|
||||
|
||||
|
||||
def _compute_rotation_signals(dates: list[str], columns: dict) -> dict:
|
||||
"""从概念涨幅排名矩阵计算轮动信号。
|
||||
|
||||
Args:
|
||||
dates: 日期列表 (最新在最前, 与 columns key 一致)
|
||||
columns: {日期: [[概念, 涨幅], ...]} 每列各自降序
|
||||
|
||||
Returns:
|
||||
{
|
||||
"persistent_leaders": [...], # 连续多日稳居前列 (主线)
|
||||
"rising": [...], # 排名快速跃升 (新晋)
|
||||
"fading": [...], # 从高位滑落 (退潮)
|
||||
"institutional": [...], # 排名稳定 (机构特征)
|
||||
"hot_money": [...], # 排名波动大 (游资特征)
|
||||
}
|
||||
每项含: concept, ranks (按 dates 顺序), pcts, avg_rank, rank_std
|
||||
ranks 时间方向: ranks[0] = 最早日, ranks[-1] = 最新日 (已反转, 左老右新)
|
||||
"""
|
||||
if not dates or not columns:
|
||||
return {}
|
||||
|
||||
# 按时间正序 (左老右新) 处理
|
||||
dates_asc = list(reversed(dates))
|
||||
|
||||
# 收集每个概念在各日期的 (排名, 涨幅)。排名 = 该日在列中的索引 + 1。
|
||||
concept_data: dict[str, list[tuple[int, float]]] = {}
|
||||
for d in dates_asc:
|
||||
col = columns.get(d) or []
|
||||
for idx, (name, pct) in enumerate(col):
|
||||
concept_data.setdefault(name, []).append((idx + 1, pct))
|
||||
|
||||
n_dates = len(dates_asc)
|
||||
|
||||
def _stats(ranks_pcts: list[tuple[int, float]]) -> dict:
|
||||
ranks = [r for r, _ in ranks_pcts]
|
||||
pcts = [p for _, p in ranks_pcts]
|
||||
avg = sum(ranks) / len(ranks) if ranks else 0
|
||||
var = sum((r - avg) ** 2 for r in ranks) / len(ranks) if ranks else 0
|
||||
return {
|
||||
"ranks": ranks,
|
||||
"pcts": [round(p, 4) for p in pcts],
|
||||
"avg_rank": round(avg, 1),
|
||||
"rank_std": round(math.sqrt(var), 1),
|
||||
}
|
||||
|
||||
persistent: list[dict] = []
|
||||
rising: list[dict] = []
|
||||
fading: list[dict] = []
|
||||
institutional: list[dict] = []
|
||||
hot_money: list[dict] = []
|
||||
|
||||
for concept, rp in concept_data.items():
|
||||
# 缺失日补 (大排名, 0 涨幅) 保持时间轴对齐
|
||||
if len(rp) < n_dates:
|
||||
rp = rp + [(999, 0.0)] * (n_dates - len(rp))
|
||||
s = _stats(rp)
|
||||
s["concept"] = concept
|
||||
|
||||
ranks = s["ranks"]
|
||||
latest_rank = ranks[-1]
|
||||
earliest_rank = ranks[0]
|
||||
# 最近 3 日 (不足则全部) 均排名, 判断近期强度
|
||||
recent = ranks[-min(3, len(ranks)):]
|
||||
recent_avg = sum(recent) / len(recent)
|
||||
|
||||
# 主线: 近期稳居前 10
|
||||
if recent_avg <= 10 and latest_rank <= 10:
|
||||
persistent.append(s)
|
||||
|
||||
# 新晋: 早期排名靠后(>30), 最新冲进前 20, 跃升幅度大
|
||||
jump = earliest_rank - latest_rank
|
||||
if earliest_rank > 30 and latest_rank <= 20 and jump >= 20:
|
||||
rising.append(s)
|
||||
|
||||
# 退潮: 早期排名靠前(<=10), 最新滑落到 30 外
|
||||
drop = latest_rank - earliest_rank
|
||||
if earliest_rank <= 10 and latest_rank > 30 and drop >= 20:
|
||||
fading.append(s)
|
||||
|
||||
# 机构: 排名标准差小且平均排名靠前 (稳定强势)
|
||||
if s["rank_std"] <= 5 and s["avg_rank"] <= 20:
|
||||
institutional.append(s)
|
||||
|
||||
# 游资: 排名标准差大 (波动剧烈)
|
||||
if s["rank_std"] >= 20:
|
||||
hot_money.append(s)
|
||||
|
||||
# 排序: 主线按近期排名升序; 新晋按跃升幅度降序; 退潮按跌幅降序
|
||||
persistent.sort(key=lambda x: x["avg_rank"])
|
||||
rising.sort(key=lambda x: x["ranks"][0] - x["ranks"][-1], reverse=True)
|
||||
fading.sort(key=lambda x: x["ranks"][-1] - x["ranks"][0], reverse=True)
|
||||
institutional.sort(key=lambda x: (x["rank_std"], x["avg_rank"]))
|
||||
hot_money.sort(key=lambda x: x["rank_std"], reverse=True)
|
||||
|
||||
return {
|
||||
"persistent_leaders": persistent[:_TOP_N],
|
||||
"rising": rising[:_TOP_N],
|
||||
"fading": fading[:_TOP_N],
|
||||
"institutional": institutional[:_TOP_N],
|
||||
"hot_money": hot_money[:_TOP_N],
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Prompt 构建
|
||||
# ================================================================
|
||||
|
||||
def _fmt_pct(v) -> str:
|
||||
if v is None:
|
||||
return "—"
|
||||
return f"{v*100:+.2f}%"
|
||||
|
||||
|
||||
def _build_market_block(overview: dict) -> str:
|
||||
"""大盘背景精简块 (复用 market_overview 已算好的字段)。"""
|
||||
indices = overview.get("indices") or []
|
||||
emo = overview.get("emotion") or {}
|
||||
lim = overview.get("limit") or {}
|
||||
amt = overview.get("amount") or {}
|
||||
|
||||
idx_lines = []
|
||||
for idx in indices[:4]:
|
||||
name = idx.get("name") or idx.get("symbol") or "?"
|
||||
chg = idx.get("change_pct")
|
||||
idx_lines.append(f"{name} {_fmt_pct(chg)}")
|
||||
idx_str = " / ".join(idx_lines) or "指数缺失"
|
||||
|
||||
total_amount = (amt.get("total") or 0) / 1e8 # 元 → 亿
|
||||
|
||||
return (
|
||||
f"- 指数: {idx_str}\n"
|
||||
f"- 情绪: {emo.get('score', 50)} ({emo.get('label', '—')})\n"
|
||||
f"- 涨停/炸板/跌停: {lim.get('limit_up', 0)} / {lim.get('broken', 0)} / {lim.get('limit_down', 0)}"
|
||||
f" (最高连板 {lim.get('max_boards', 0)})\n"
|
||||
f"- 两市成交额: {total_amount:.0f} 亿元"
|
||||
)
|
||||
|
||||
|
||||
def _build_signal_block(title: str, items: list[dict]) -> str:
|
||||
"""轮动信号块: 把预计算的概念信号转成紧凑文本。"""
|
||||
if not items:
|
||||
return f"### {title}\n(本类无明显信号)"
|
||||
lines = [f"### {title}"]
|
||||
for it in items:
|
||||
ranks_str = "→".join(str(r) if r < 999 else "—" for r in it["ranks"])
|
||||
avg_pct = sum(it["pcts"]) / len(it["pcts"]) if it["pcts"] else 0
|
||||
lines.append(
|
||||
f"- {it['concept']}: 排名 {ranks_str} | 均排名 {it['avg_rank']} "
|
||||
f"| 排名波动σ {it['rank_std']} | 区间均涨 {_fmt_pct(avg_pct)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_user_prompt(signals: dict, overview: dict, days: int, dates: list[str], focus: str) -> str:
|
||||
"""组装 user 消息: 大盘背景 + 轮动信号 + focus。"""
|
||||
dates_asc = list(reversed(dates))
|
||||
date_range = f"{dates_asc[0]} ~ {dates_asc[-1]}" if dates_asc else "—"
|
||||
|
||||
parts = [
|
||||
f"# 概念涨幅轮动数据 (最近 {days} 个交易日: {date_range})",
|
||||
"",
|
||||
"## 大盘背景",
|
||||
_build_market_block(overview),
|
||||
"",
|
||||
"## 轮动信号 (排名时间方向: 左→右 = 旧→新, 排名越小越强)",
|
||||
"",
|
||||
_build_signal_block("🎯 主线 (连续霸榜)", signals.get("persistent_leaders", [])),
|
||||
"",
|
||||
_build_signal_block("🆕 新晋强势 (排名跃升)", signals.get("rising", [])),
|
||||
"",
|
||||
_build_signal_block("📉 退潮预警 (高位滑落)", signals.get("fading", [])),
|
||||
"",
|
||||
_build_signal_block("🏛️ 机构特征 (排名稳定)", signals.get("institutional", [])),
|
||||
"",
|
||||
_build_signal_block("🎰 游资特征 (排名波动大)", signals.get("hot_money", [])),
|
||||
]
|
||||
|
||||
if focus.strip():
|
||||
parts.extend(["", f"## 用户关注点\n{focus.strip()}"])
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _build_summary(signals: dict) -> str:
|
||||
"""meta 事件的摘要 (前端可立即展示)。"""
|
||||
leaders = signals.get("persistent_leaders", [])
|
||||
rising = signals.get("rising", [])
|
||||
fading = signals.get("fading", [])
|
||||
leader_names = "、".join(it["concept"] for it in leaders[:3]) or "暂无明确主线"
|
||||
return f"主线: {leader_names} | 新晋 {len(rising)} | 退潮 {len(fading)}"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 流式主入口
|
||||
# ================================================================
|
||||
|
||||
async def analyze_rotation_stream(
|
||||
repo,
|
||||
days: int = 12,
|
||||
focus: str = "",
|
||||
quote_service=None,
|
||||
depth_service=None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
|
||||
|
||||
Args:
|
||||
repo: KlineRepository (必填)。
|
||||
days: 分析最近 N 个交易日 (7-30)。
|
||||
focus: 用户追加的关注点。
|
||||
quote_service / depth_service: 可选, 大盘背景装配依赖。
|
||||
"""
|
||||
from app.services.rps_rotation import build_rps_rotation
|
||||
from app.services.market_overview_builder import build_market_overview
|
||||
|
||||
# 1. 取轮动矩阵
|
||||
rotation = build_rps_rotation(repo, days)
|
||||
dates = rotation.get("dates") or []
|
||||
columns = rotation.get("columns") or {}
|
||||
|
||||
if not dates or not columns:
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"message": "暂无概念轮动数据,请先在「概念分析」页获取概念数据源",
|
||||
}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
# 2. 预计算轮动信号
|
||||
signals = _compute_rotation_signals(dates, columns)
|
||||
|
||||
# 3. 大盘背景 (失败不阻断, 降级为空)
|
||||
try:
|
||||
overview = build_market_overview(repo, quote_service, depth_service)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
|
||||
overview = {}
|
||||
|
||||
# 4. meta 事件
|
||||
yield json.dumps({
|
||||
"type": "meta",
|
||||
"days": days,
|
||||
"summary": _build_summary(signals),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 5. 构建 prompt + 流式调用 LLM
|
||||
try:
|
||||
from app.services.ai_provider import stream_ai_text, ai_configured
|
||||
|
||||
if not ai_configured():
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
|
||||
}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
user_prompt = _build_user_prompt(signals, overview, days, dates, focus)
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4000,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("AI concept rotation analyze failed: %s", e)
|
||||
yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False)
|
||||
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
@@ -316,7 +316,18 @@ class DepthService:
|
||||
"status": e.get("status"),
|
||||
"fetched_at": e.get("fetched_ts"),
|
||||
})
|
||||
df = pl.DataFrame(rows)
|
||||
# 显式 schema: sealed_up/sealed_down 是 bool 与 None 混合, 不指定 schema
|
||||
# polars 会按首行推断类型, 后续遇到不一致 (bool vs null) 报
|
||||
# "could not append value: false of type: bool to the builder"。
|
||||
df = pl.DataFrame(rows, schema={
|
||||
"symbol": pl.Utf8,
|
||||
"sealed_up": pl.Boolean,
|
||||
"sealed_down": pl.Boolean,
|
||||
"ask1_vol": pl.Int64,
|
||||
"bid1_vol": pl.Int64,
|
||||
"status": pl.Utf8,
|
||||
"fetched_at": pl.Float64,
|
||||
})
|
||||
ds = today.isoformat()
|
||||
out = self._repo.store.data_dir / "depth5" / f"date={ds}" / "part.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -38,6 +38,7 @@ class PullConfig:
|
||||
"url", "method", "headers", "body", "response_path",
|
||||
"field_map", "schedule_minutes", "enabled",
|
||||
"last_run", "last_status", "last_message", "last_rows",
|
||||
"next_run",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -54,6 +55,7 @@ class PullConfig:
|
||||
last_status: str | None = None,
|
||||
last_message: str | None = None,
|
||||
last_rows: int | None = None,
|
||||
next_run: str | None = None,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.method = method # GET | POST
|
||||
@@ -67,6 +69,7 @@ class PullConfig:
|
||||
self.last_status = last_status # "success" | "error"
|
||||
self.last_message = last_message
|
||||
self.last_rows = last_rows
|
||||
self.next_run = next_run # 下次预计运行 (ISO, 调度器写入)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -82,6 +85,7 @@ class PullConfig:
|
||||
"last_status": self.last_status,
|
||||
"last_message": self.last_message,
|
||||
"last_rows": self.last_rows,
|
||||
"next_run": self.next_run,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -101,6 +105,7 @@ class PullConfig:
|
||||
last_status=d.get("last_status"),
|
||||
last_message=d.get("last_message"),
|
||||
last_rows=d.get("last_rows"),
|
||||
next_run=d.get("next_run"),
|
||||
)
|
||||
|
||||
|
||||
@@ -407,8 +412,10 @@ def write_ext_parquet(
|
||||
existing = pl.read_parquet(out_path)
|
||||
key = "symbol" if "symbol" in df.columns else df.columns[0]
|
||||
df = pl.concat([existing, df]).unique(subset=[key], keep="last")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
# schema 不一致 (列不同) 时 concat 失败 → 直接用新 df 覆盖。
|
||||
# 记日志而非静默吞掉, 便于排查"数据结构错乱"类问题。
|
||||
logger.warning("扩展表 %s 合并去重失败, 将覆盖写入: %s", config.id, e)
|
||||
else:
|
||||
# 时序: timeseries/ 下按日期分区
|
||||
out_dir = cfg_dir / "timeseries" / f"date={snap}"
|
||||
@@ -421,8 +428,8 @@ def write_ext_parquet(
|
||||
existing = pl.read_parquet(out_path)
|
||||
key = "symbol" if "symbol" in df.columns else df.columns[0]
|
||||
df = pl.concat([existing, df]).unique(subset=[key], keep="last")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("扩展表 %s 合并去重失败, 将覆盖写入: %s", config.id, e)
|
||||
|
||||
df = cast_df_to_schema(df, config.fields)
|
||||
df.write_parquet(out_path)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""内置扩展数据预设 — 概念/行业首次启动自动拉取。
|
||||
|
||||
设计原则:
|
||||
- 扩展数据通用逻辑零改动 (ExtConfig / fetch_and_ingest / API / 前端均不动)
|
||||
- 仅在本模块做「接口结构 → 本地 schema」的转换
|
||||
- 「已存在则跳过」: 绝不覆盖用户已有数据, 老用户零影响
|
||||
- 拉取失败只记 warning, 不阻断启动 (保持「没数据也能跑」)
|
||||
|
||||
种子数据来源: https://files.688798.xyz/ths/{concepts,industries}.json
|
||||
作者更新数据只需改接口上的 JSON, 用户下次拉取自动同步, 无需发版。
|
||||
|
||||
接入点: app.main.lifespan → ensure_builtin_presets(store.data_dir)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.ext_data import (
|
||||
ExtConfig,
|
||||
ExtConfigStore,
|
||||
ExtField,
|
||||
PullConfig,
|
||||
rows_to_parquet,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 种子数据源 (作者维护, 改这里即对所有用户生效)
|
||||
_THS_BASE = "https://files.688798.xyz/ths"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 预设定义: 字段结构 + 拉取配方
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _concept_preset() -> ExtConfig:
|
||||
"""扩展概念 (ext_gn_ths)。
|
||||
|
||||
接口结构: [{symbol, name, concepts: [概念1, 概念2, ...]}]
|
||||
本地 schema: 股票代码 / 股票简称 / 所属概念(分号拼接) / symbol / code
|
||||
"""
|
||||
return ExtConfig(
|
||||
id="ext_gn_ths",
|
||||
label="扩展概念",
|
||||
mode="snapshot",
|
||||
fields=[
|
||||
ExtField("symbol", "string", "标的代码"),
|
||||
ExtField("code", "string", "代码"),
|
||||
ExtField("股票代码", "string", "股票代码"),
|
||||
ExtField("股票简称", "string", "股票简称"),
|
||||
ExtField("所属概念", "string", "所属概念"),
|
||||
],
|
||||
description="同花顺概念分类 (首次启动自动拉取, 可在扩展数据页手动更新)",
|
||||
symbol_map={"type": "mapped", "col": "股票代码"},
|
||||
code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
|
||||
pull=PullConfig(
|
||||
url=f"{_THS_BASE}/concepts.json",
|
||||
method="GET",
|
||||
schedule_minutes=1440,
|
||||
enabled=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _industry_preset() -> ExtConfig:
|
||||
"""扩展行业 (ext_hy_ths)。
|
||||
|
||||
接口结构: [{symbol, name, industries: [一级行业, 二级行业, 三级行业]}]
|
||||
本地 schema: 股票代码 / 股票简称 / 所属同花顺行业(横杠拼接) / symbol / code
|
||||
"""
|
||||
return ExtConfig(
|
||||
id="ext_hy_ths",
|
||||
label="扩展行业",
|
||||
mode="snapshot",
|
||||
fields=[
|
||||
ExtField("symbol", "string", "标的代码"),
|
||||
ExtField("code", "string", "代码"),
|
||||
ExtField("股票代码", "string", "股票代码"),
|
||||
ExtField("股票简称", "string", "股票简称"),
|
||||
ExtField("所属同花顺行业", "string", "所属同花顺行业"),
|
||||
],
|
||||
description="同花顺行业分类 (首次启动自动拉取, 可在扩展数据页手动更新)",
|
||||
symbol_map={"type": "mapped", "col": "股票代码"},
|
||||
code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
|
||||
pull=PullConfig(
|
||||
url=f"{_THS_BASE}/industries.json",
|
||||
method="GET",
|
||||
schedule_minutes=1440,
|
||||
enabled=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _presets() -> list[ExtConfig]:
|
||||
return [_concept_preset(), _industry_preset()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口结构 → 本地 schema 转换 (仅预设使用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _symbol_to_code(symbol: str) -> str:
|
||||
"""symbol (000001.SZ) → code (000001)。"""
|
||||
return symbol.split(".", 1)[0] if "." in symbol else symbol
|
||||
|
||||
|
||||
def _flatten_concept_rows(raw_rows: list[dict]) -> list[dict]:
|
||||
"""概念: concepts 数组 → 分号拼接成「所属概念」字符串。
|
||||
|
||||
[{symbol, name, concepts:[...]}] → [{股票代码, 股票简称, 所属概念, symbol, code}]
|
||||
注: code 由 symbol 派生 (000001.SZ → 000001), 因 rows_to_parquet 不执行 code_map。
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for r in raw_rows:
|
||||
sym = (r.get("symbol") or "").strip()
|
||||
if not sym:
|
||||
continue
|
||||
concepts = r.get("concepts") or []
|
||||
out.append({
|
||||
"股票代码": sym,
|
||||
"股票简称": r.get("name") or "",
|
||||
"所属概念": ";".join(str(c) for c in concepts if c),
|
||||
"symbol": sym,
|
||||
"code": _symbol_to_code(sym),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _flatten_industry_rows(raw_rows: list[dict]) -> list[dict]:
|
||||
"""行业: industries 数组 → 横杠拼接成「所属同花顺行业」字符串。
|
||||
|
||||
[{symbol, name, industries:[...]}] → [{股票代码, 股票简称, 所属同花顺行业, symbol, code}]
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for r in raw_rows:
|
||||
sym = (r.get("symbol") or "").strip()
|
||||
if not sym:
|
||||
continue
|
||||
inds = r.get("industries") or []
|
||||
out.append({
|
||||
"股票代码": sym,
|
||||
"股票简称": r.get("name") or "",
|
||||
"所属同花顺行业": "-".join(str(i) for i in inds if i),
|
||||
"symbol": sym,
|
||||
"code": _symbol_to_code(sym),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 拉取执行 (复用 httpx, 不依赖 fetch_and_ingest 的 PullConfig 路径)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fetch_json(url: str) -> list[dict]:
|
||||
"""请求 JSON 接口, 返回行数组。超时 30s, 失败抛异常由调用方兜底。"""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"接口返回不是数组: {type(data)}")
|
||||
return data
|
||||
|
||||
|
||||
async def _seed_one(config: ExtConfig, flatten, data_dir: Path) -> int:
|
||||
"""拉取 + 转换 + 写入单个预设。返回写入行数。"""
|
||||
from datetime import date
|
||||
|
||||
raw = await _fetch_json(config.pull.url)
|
||||
rows = flatten(raw)
|
||||
if not rows:
|
||||
raise ValueError(f"接口返回 0 行: {config.pull.url}")
|
||||
n = rows_to_parquet(rows, config, data_dir, snapshot_date=date.today())
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 对外入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_preset(config_id: str) -> ExtConfig | None:
|
||||
"""按 id 取预设定义 (供 API 层校验 id 合法性)。"""
|
||||
for c in _presets():
|
||||
if c.id == config_id:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_builtin_presets(data_dir: Path) -> None:
|
||||
"""启动时: 为缺失的预设创建 config.json (含 pull 配置), 但【不拉取数据】。
|
||||
|
||||
设计: 数据获取改为用户在概念/行业页手动点「获取数据」触发, 避免启动时
|
||||
网络请求阻塞, 也避免「自动拉取」与「用户自主控制」的预期冲突。
|
||||
|
||||
安全保证:
|
||||
- 已存在则完全跳过 (绝不覆盖用户数据)
|
||||
- 只写 config.json, 失败只记 warning 不阻断启动
|
||||
"""
|
||||
store = ExtConfigStore(data_dir)
|
||||
|
||||
for config in _presets():
|
||||
existing = store.get(config.id)
|
||||
if existing is not None:
|
||||
# 用户已有此表 (老用户 / 自己重建过) → 一律不动
|
||||
continue
|
||||
try:
|
||||
store.upsert(config)
|
||||
logger.info("内置扩展表 %s 配置已就绪 (待用户手动获取数据)", config.id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("内置扩展表 %s 配置写入失败 (不影响启动): %s", config.id, e)
|
||||
|
||||
|
||||
async def fetch_preset(config_id: str, data_dir: Path) -> int:
|
||||
"""手动触发某个预设的数据拉取 (供 API 调用)。
|
||||
|
||||
Raises:
|
||||
ValueError: config_id 不是内置预设
|
||||
Exception: 网络请求/解析/写入失败 (由 API 层转 HTTP 错误)
|
||||
"""
|
||||
config = get_preset(config_id)
|
||||
if config is None:
|
||||
raise ValueError(f"未知的内置预设: {config_id}")
|
||||
|
||||
flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows
|
||||
|
||||
# 确保 config.json 存在 (用户可能从未启动过 ensure_builtin_presets)
|
||||
store = ExtConfigStore(data_dir)
|
||||
if store.get(config_id) is None:
|
||||
store.upsert(config)
|
||||
|
||||
n = await _seed_one(config, flatten, data_dir)
|
||||
logger.info("内置扩展表 %s 手动拉取成功: %d 行", config_id, n)
|
||||
return n
|
||||
@@ -75,6 +75,19 @@ def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]:
|
||||
# 拉取执行
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _apply_preset_flatten(config_id: str, rows: list[dict]) -> list[dict]:
|
||||
"""对内置预设 (概念/行业) 应用结构转换, 与 fetch_preset 保持一致。
|
||||
|
||||
延迟导入避免与 ext_presets 形成循环依赖。
|
||||
非预设 id 原样返回。
|
||||
"""
|
||||
if config_id not in ("ext_gn_ths", "ext_hy_ths"):
|
||||
return rows
|
||||
from app.services.ext_presets import _flatten_concept_rows, _flatten_industry_rows
|
||||
flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows
|
||||
return flatten(rows)
|
||||
|
||||
|
||||
async def fetch_and_ingest(
|
||||
config: ExtConfig,
|
||||
data_dir,
|
||||
@@ -111,6 +124,12 @@ async def fetch_and_ingest(
|
||||
if not rows:
|
||||
raise ValueError("提取到的行数为 0")
|
||||
|
||||
# 内置预设 (概念/行业): 应用结构转换, 让产出 schema 与分析页一致。
|
||||
# 否则 raw 接口列 (concepts/industries 数组、name) 会直接覆盖正确的 part.parquet,
|
||||
# 导致分析页因找不到维度字段 (所属概念/所属同花顺行业) 而"数据消失"。
|
||||
# 见 ext_presets._flatten_* —— 手动拉取 / 定时拉取都必须走同一套转换。
|
||||
rows = _apply_preset_flatten(config.id, rows)
|
||||
|
||||
# 字段映射
|
||||
rows = _apply_field_map(rows, pull.field_map)
|
||||
|
||||
@@ -129,21 +148,46 @@ async def fetch_and_ingest(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PullScheduler:
|
||||
"""后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。"""
|
||||
"""后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。
|
||||
|
||||
线程安全说明:
|
||||
refresh()/stop() 可能从主事件循环 (lifespan startup) 或同步路由的
|
||||
worker 线程 (configure_pull 是 def 而非 async def, FastAPI 丢进线程池)
|
||||
调用。worker 线程里没有 running loop, 直接 asyncio.create_task 会抛
|
||||
"no running event loop"。因此对 task 的增删一律通过
|
||||
call_soon_threadsafe 提交到主循环执行 —— 同一套代码两种调用场景都安全。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
self._running = False
|
||||
self._lock = threading.Lock()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def start(self, data_dir) -> None:
|
||||
"""启动调度(在 lifespan startup 调用)。"""
|
||||
"""启动调度(在 lifespan startup 调用,主事件循环内)。"""
|
||||
self._running = True
|
||||
self._data_dir = data_dir
|
||||
try:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._loop = None
|
||||
logger.info("PullScheduler started")
|
||||
|
||||
def _submit(self, fn, *args) -> None:
|
||||
"""把一个 callable 提交到主事件循环执行 (线程安全)。
|
||||
|
||||
startup 在主循环内调用时 fn 立即排队; worker 线程调用时跨线程排队。
|
||||
两者都通过 call_soon_threadsafe, 保证 _tasks 字典的读写只在主循环里发生。
|
||||
"""
|
||||
loop = self._loop
|
||||
if loop is None or loop.is_closed():
|
||||
raise RuntimeError(
|
||||
"PullScheduler: 事件循环不可用 (start() 未在事件循环中调用?)"
|
||||
)
|
||||
loop.call_soon_threadsafe(fn, *args)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止所有任务。"""
|
||||
"""停止所有任务 (从 shutdown 调用)。"""
|
||||
self._running = False
|
||||
for task in self._tasks.values():
|
||||
task.cancel()
|
||||
@@ -151,47 +195,61 @@ class PullScheduler:
|
||||
logger.info("PullScheduler stopped")
|
||||
|
||||
def refresh(self, data_dir) -> None:
|
||||
"""重新加载配置,更新调度任务(增/删/改)。"""
|
||||
"""重新加载配置,更新调度任务(增/删/改)。线程安全。"""
|
||||
self._data_dir = data_dir
|
||||
store = ExtConfigStore(data_dir)
|
||||
configs = store.load_all()
|
||||
|
||||
active_ids: set[str] = set()
|
||||
new_configs: list[ExtConfig] = []
|
||||
|
||||
for config in configs:
|
||||
if not config.pull or not config.pull.enabled or not config.pull.url:
|
||||
continue
|
||||
active_ids.add(config.id)
|
||||
if config.id not in self._tasks:
|
||||
# 新增调度
|
||||
task = asyncio.create_task(self._run_loop(config))
|
||||
self._tasks[config.id] = task
|
||||
logger.info("PullScheduler: scheduled %s (every %d min)", config.id, config.pull.schedule_minutes)
|
||||
new_configs.append(config)
|
||||
|
||||
# 移除不再活跃的
|
||||
for cid in list(self._tasks):
|
||||
if cid not in active_ids:
|
||||
self._tasks[cid].cancel()
|
||||
del self._tasks[cid]
|
||||
logger.info("PullScheduler: removed %s", cid)
|
||||
# 需要移除的 id (快照当前 task 字典的键, 避免遍历时改字典)
|
||||
remove_ids = [cid for cid in list(self._tasks) if cid not in active_ids]
|
||||
|
||||
# 所有对 _tasks 的修改都提交到主循环里执行, 保证线程安全
|
||||
def _apply() -> None:
|
||||
for config in new_configs:
|
||||
if config.id not in self._tasks: # 二次校验, 防重复
|
||||
self._tasks[config.id] = self._loop.create_task(
|
||||
self._run_loop(config)
|
||||
)
|
||||
logger.info(
|
||||
"PullScheduler: scheduled %s (every %d min)",
|
||||
config.id, config.pull.schedule_minutes,
|
||||
)
|
||||
for cid in remove_ids:
|
||||
task = self._tasks.pop(cid, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
logger.info("PullScheduler: removed %s", cid)
|
||||
|
||||
self._submit(_apply)
|
||||
|
||||
async def _run_loop(self, config: ExtConfig) -> None:
|
||||
"""单个配置的定时拉取循环。"""
|
||||
"""单个配置的定时拉取循环。
|
||||
|
||||
策略: 启用后立即执行一次, 之后按 interval 循环。
|
||||
每次循环重读最新配置 (fresh), interval 取自 fresh.pull.schedule_minutes,
|
||||
这样用户中途修改间隔也能立即生效 (无需重启)。
|
||||
"""
|
||||
try:
|
||||
while self._running:
|
||||
pull = config.pull
|
||||
if not pull:
|
||||
break
|
||||
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
|
||||
await asyncio.sleep(interval)
|
||||
if not self._running:
|
||||
# 每轮重读最新配置 — 用户可能修改了 url / interval / enabled
|
||||
store = ExtConfigStore(self._data_dir)
|
||||
fresh = store.get(config.id)
|
||||
if not fresh or not fresh.pull or not fresh.pull.enabled:
|
||||
break
|
||||
pull = fresh.pull
|
||||
|
||||
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
|
||||
try:
|
||||
# 重新加载最新配置(用户可能中途修改)
|
||||
store = ExtConfigStore(self._data_dir)
|
||||
fresh = store.get(config.id)
|
||||
if not fresh or not fresh.pull or not fresh.pull.enabled:
|
||||
break
|
||||
n, d = await fetch_and_ingest(fresh, self._data_dir)
|
||||
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
fresh.pull.last_status = "success"
|
||||
@@ -200,14 +258,79 @@ class PullScheduler:
|
||||
store.upsert(fresh)
|
||||
logger.info("PullScheduler: %s success, %d rows", config.id, n)
|
||||
except Exception as e:
|
||||
store = ExtConfigStore(self._data_dir)
|
||||
fresh = store.get(config.id)
|
||||
if fresh and fresh.pull:
|
||||
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
fresh.pull.last_status = "error"
|
||||
fresh.pull.last_message = str(e)[:200]
|
||||
store.upsert(fresh)
|
||||
fresh2 = store.get(config.id)
|
||||
if fresh2 and fresh2.pull:
|
||||
fresh2.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
fresh2.pull.last_status = "error"
|
||||
fresh2.pull.last_message = str(e)[:200]
|
||||
store.upsert(fresh2)
|
||||
logger.warning("PullScheduler: %s error: %s", config.id, e)
|
||||
|
||||
# 间隔取自最新配置 (每次重新读取, 修复改间隔不生效)
|
||||
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
|
||||
# 预告下次运行时间, 供前端展示
|
||||
next_dt = datetime.now(timezone.utc).timestamp() + interval
|
||||
latest = store.get(config.id)
|
||||
if latest and latest.pull:
|
||||
latest.pull.next_run = datetime.fromtimestamp(
|
||||
next_dt, tz=timezone.utc
|
||||
).isoformat()
|
||||
store.upsert(latest)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
if not self._running:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _run_loop(self, config: ExtConfig) -> None:
|
||||
"""单个配置的定时拉取循环。
|
||||
|
||||
策略: 启用后立即执行一次, 之后按 interval 循环。
|
||||
每次循环重读最新配置 (fresh), interval 取自 fresh.pull.schedule_minutes,
|
||||
这样用户中途修改间隔也能立即生效 (无需重启)。
|
||||
"""
|
||||
try:
|
||||
while self._running:
|
||||
# 每轮重读最新配置 — 用户可能修改了 url / interval / enabled
|
||||
store = ExtConfigStore(self._data_dir)
|
||||
fresh = store.get(config.id)
|
||||
if not fresh or not fresh.pull or not fresh.pull.enabled:
|
||||
break
|
||||
pull = fresh.pull
|
||||
|
||||
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
|
||||
try:
|
||||
n, d = await fetch_and_ingest(fresh, self._data_dir)
|
||||
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
fresh.pull.last_status = "success"
|
||||
fresh.pull.last_message = f"{n} rows @ {d}"
|
||||
fresh.pull.last_rows = n
|
||||
store.upsert(fresh)
|
||||
logger.info("PullScheduler: %s success, %d rows", config.id, n)
|
||||
except Exception as e:
|
||||
fresh2 = store.get(config.id)
|
||||
if fresh2 and fresh2.pull:
|
||||
fresh2.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
fresh2.pull.last_status = "error"
|
||||
fresh2.pull.last_message = str(e)[:200]
|
||||
store.upsert(fresh2)
|
||||
logger.warning("PullScheduler: %s error: %s", config.id, e)
|
||||
|
||||
# 间隔取自最新配置 (每次重新读取, 修复改间隔不生效)
|
||||
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
|
||||
# 预告下次运行时间, 供前端展示
|
||||
next_dt = datetime.now(timezone.utc).timestamp() + interval
|
||||
latest = store.get(config.id)
|
||||
if latest and latest.pull:
|
||||
latest.pull.next_run = datetime.fromtimestamp(
|
||||
next_dt, tz=timezone.utc
|
||||
).isoformat()
|
||||
store.upsert(latest)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
if not self._running:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""AI 财务分析服务 — 读取个股财务数据 → 构建专业提示词 → 流式调用 LLM。
|
||||
|
||||
职责: 拉取单只标的的 4 张财务表 → 转成紧凑 JSON → 拼装 CFA 分析师级系统提示词
|
||||
→ 流式调用 OpenAI 兼容 API → 逐 chunk 吐给前端。
|
||||
|
||||
不知道: HTTP、前端、配置持久化。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.services.financial_sync import get_financial_df
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 最多注入的报告期数(最新 N 期),避免上下文爆炸 / token 浪费
|
||||
_MAX_PERIODS = 4
|
||||
|
||||
|
||||
def _load_stock_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]:
|
||||
"""读取该标的的 4 张财务表,返回 {table: [records...]}(按 period_end 降序,截取最新 N 期)。
|
||||
|
||||
数值统一做 NaN/Inf → null 清洗,保证 JSON 序列化不报错。
|
||||
"""
|
||||
result: dict[str, list[dict]] = {}
|
||||
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
|
||||
df = get_financial_df(data_dir, table)
|
||||
if df.is_empty():
|
||||
result[table] = []
|
||||
continue
|
||||
df = df.filter(pl.col("symbol") == symbol)
|
||||
if df.is_empty():
|
||||
result[table] = []
|
||||
continue
|
||||
# 按 period_end 降序,截取最新 N 期
|
||||
if "period_end" in df.columns:
|
||||
df = df.sort("period_end", descending=True).head(_MAX_PERIODS)
|
||||
# 清洗 NaN/Inf,转成 JSON 安全的 dict 列表
|
||||
rows = []
|
||||
for rec in df.to_dicts():
|
||||
clean = {}
|
||||
for k, v in rec.items():
|
||||
if k == "symbol":
|
||||
continue # 不需要重复回传 symbol
|
||||
if isinstance(v, float):
|
||||
import math
|
||||
clean[k] = None if not math.isfinite(v) else v
|
||||
else:
|
||||
clean[k] = v
|
||||
rows.append(clean)
|
||||
result[table] = rows
|
||||
return result
|
||||
|
||||
|
||||
def _summarize(fins: dict[str, list[dict]]) -> str:
|
||||
"""生成一行业务摘要,便于 LLM 快速把握数据全貌(行数/期数)。"""
|
||||
parts = []
|
||||
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
|
||||
rows = fins.get(table, [])
|
||||
if rows:
|
||||
periods = [r.get("period_end") for r in rows if r.get("period_end")]
|
||||
parts.append(f"{table}: {len(rows)}期 ({', '.join(str(p) for p in periods[:3])})")
|
||||
else:
|
||||
parts.append(f"{table}: 无数据")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 系统提示词 —— CFA 分析师级,九维分析框架
|
||||
# ================================================================
|
||||
|
||||
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股投研经验的资深财务分析师(CFA + CPA),服务于专业机构投资者。你的任务是:基于提供的上市公司财务数据,产出一份**严谨、专业、可直接用于投资决策**的财务分析报告。
|
||||
|
||||
## 输出规范
|
||||
|
||||
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
|
||||
|
||||
### 1. 📌 核心摘要(1-2 句)
|
||||
用一句话概括该公司的财务画像:盈利质量、成长动能、财务健康度的最关键判断。结尾用【综合评级:★★★☆☆】给出 1-5 星评级。
|
||||
|
||||
### 2. ✅ 亮点(2-3 条)
|
||||
列出最值得关注的**积极信号**,每条用加粗短语领起,配数据支撑。例如盈利高增、ROE 持续提升、现金流充沛等。
|
||||
|
||||
### 3. ⚠️ 风险提示(2-3 条)
|
||||
客观指出**潜在风险或值得警惕的信号**,例如应收激增、存货堆积、经营现金流与净利润背离、债务攀升等。宁可保守,不要回避。
|
||||
|
||||
### 4. 📊 分项诊断
|
||||
用**表格**呈现各维度的诊断结论,列为「维度 / 关键指标 / 判断」。维度包括:
|
||||
- **盈利能力**:ROE / ROA / 毛利率 / 净利率
|
||||
- **成长性**:营收同比 / 净利润同比
|
||||
- **偿债能力**:资产负债率 / 流动比率(用资产/负债估算)
|
||||
- **现金流**:经营现金流净额 / 与净利润的匹配度
|
||||
- **营运效率**:存货周转率等(有数据时)
|
||||
|
||||
每个判断给「优秀 / 良好 / 一般 / 偏弱 / 警惕」之一,并一句话说明依据。
|
||||
|
||||
### 5. 🎯 综合评估与展望
|
||||
2-3 段总结:该公司当前的财务状态(优秀/稳健/承压/恶化)、核心驱动力、未来需重点跟踪的指标。**结尾给出"投资参考"**:从纯财务质量角度,该股属于(高质量蓝筹 / 稳健成长 / 周期波动 / 财务承压 / 高风险)中的哪一类。
|
||||
|
||||
## 分析准则(务必遵守)
|
||||
|
||||
1. **数据说话**:每个判断必须引用具体数值(如"营收同比 +28.5%"),严禁空泛套话
|
||||
2. **纵向对比**:利用多期数据看趋势(改善/恶化),而非只看单期
|
||||
3. **交叉验证**:经营现金流 vs 净利润(是否造血)、毛利率 vs 费用率(盈利结构)、负债 vs 资产(杠杆)
|
||||
4. **行业常识**:对照 A 股常识判断水平(如 ROE>15% 优秀,资产负债率>70% 偏高,毛利率<20% 偏低)
|
||||
5. **诚实中立**:数据不支持时直言"数据不足,无法判断",绝不编造或过度演绎
|
||||
6. **简明有力**:避免冗长,用专业投资者能扫读的密度输出,总字数 800-1500 字
|
||||
|
||||
## 重要免责
|
||||
报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开财务数据生成,仅供参考,不构成任何投资建议。"
|
||||
|
||||
现在请基于下方数据进行分析。"""
|
||||
|
||||
|
||||
def _build_user_prompt(fins: dict[str, list[dict]], symbol: str, focus: str) -> str:
|
||||
"""构建用户消息:标的代码 + 数据 JSON + 可选关注点。"""
|
||||
data_json = json.dumps(fins, ensure_ascii=False, indent=2)
|
||||
lines = [
|
||||
f"标的标准代码: {symbol}",
|
||||
f"数据概览: {_summarize(fins)}",
|
||||
"",
|
||||
"以下是该标的最新财务数据(JSON 格式,金额单位为元,比率类指标为百分点):",
|
||||
"```json",
|
||||
data_json,
|
||||
"```",
|
||||
]
|
||||
if focus.strip():
|
||||
lines.extend([
|
||||
"",
|
||||
f"本次分析请特别关注: {focus.strip()}",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def analyze_financials_stream(
|
||||
data_dir: Path,
|
||||
symbol: str,
|
||||
focus: str = "",
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式分析:yield 出每个文本 chunk。
|
||||
|
||||
- 启动时先 yield 一条 {"type":"meta",...} 让前端显示数据摘要
|
||||
- 之后逐 chunk yield {"type":"delta","content":"..."}
|
||||
- 出错时 yield {"type":"error","message":"..."}
|
||||
- 结束 yield {"type":"done"}
|
||||
"""
|
||||
# 1. 加载数据
|
||||
fins = _load_stock_financials(data_dir, symbol)
|
||||
total_rows = sum(len(v) for v in fins.values())
|
||||
if total_rows == 0:
|
||||
yield json.dumps({"type": "error", "message": f"标的 {symbol} 暂无任何财务数据,请先同步财务表"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
# 2. meta
|
||||
yield json.dumps({
|
||||
"type": "meta",
|
||||
"symbol": symbol,
|
||||
"summary": _summarize(fins),
|
||||
"periods": total_rows,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 3. 调用 LLM 流式
|
||||
try:
|
||||
from app.services.ai_provider import stream_ai_text
|
||||
|
||||
user_prompt = _build_user_prompt(fins, symbol, focus)
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.4,
|
||||
max_tokens=4000,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("AI financial analysis failed for %s: %s", symbol, e)
|
||||
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -200,15 +200,82 @@ class FinancialScheduler:
|
||||
# 手动同步(run_now)是否正在进行。前端据此显示"同步中"并防重复点击。
|
||||
self._is_syncing = False
|
||||
|
||||
def start(self, data_dir: Path, capset: CapabilitySet) -> None:
|
||||
def start(self, data_dir: Path, capset: CapabilitySet, *, auto_schedule: bool = False) -> None:
|
||||
"""初始化调度器,并按需启动周期同步后台任务。
|
||||
|
||||
auto_schedule=False (默认): 仅初始化 (设置数据目录/能力 + 恢复 last_sync),
|
||||
供 /api/financials/sync/* 手动同步使用, 不启动自动调度。
|
||||
auto_schedule=True: 额外启动每周一次的 metrics 自动同步 (启动后 60s 首跑)。
|
||||
"""
|
||||
# 先记录 data_dir/capset, 即使当前无 FINANCIAL 也保留引用:
|
||||
# 用户稍后在「设置」页升级到 Expert Key 时, update_capabilities() 会把新 capset
|
||||
# 推进来,trigger()/run_now() 才能用上 FINANCIAL。否则 _capset 永远是 None,
|
||||
# 即便 app.state.capabilities 已更新, 调度器仍报 "no FINANCIAL capability"。
|
||||
self._data_dir = data_dir
|
||||
self._capset = capset
|
||||
if not capset.has(Cap.FINANCIAL):
|
||||
logger.info("FinancialScheduler skipped: no FINANCIAL capability")
|
||||
return
|
||||
self._data_dir = data_dir
|
||||
self._capset = capset
|
||||
# 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步"
|
||||
try:
|
||||
from app.services import preferences
|
||||
restored = dict(preferences.get_financial_sync_times())
|
||||
# 老用户迁移兜底: 若某表在 preferences 无记录但 parquet 已存在(升级前同步过),
|
||||
# 用 parquet 文件的修改时间作为同步时间并补写持久化。
|
||||
for table in FINANCIAL_TABLES:
|
||||
if table in restored:
|
||||
continue
|
||||
parquet = data_dir / "financials" / table / "part.parquet"
|
||||
if parquet.exists():
|
||||
mtime = datetime.fromtimestamp(parquet.stat().st_mtime, tz=timezone.utc).isoformat()
|
||||
restored[table] = mtime
|
||||
preferences.set_financial_sync_time(table, mtime)
|
||||
logger.info("FinancialScheduler backfilled last_sync for %s from parquet mtime", table)
|
||||
self._last_sync = restored
|
||||
if self._last_sync:
|
||||
logger.info("FinancialScheduler restored last_sync: %s", list(self._last_sync.keys()))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("restore financial_sync_times failed: %s", e)
|
||||
|
||||
if not auto_schedule:
|
||||
# 仅初始化 (手动同步用), 不启动周期任务。
|
||||
logger.info("FinancialScheduler initialized (auto-schedule disabled; manual sync only)")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("FinancialScheduler started")
|
||||
logger.info("FinancialScheduler started (auto-schedule enabled)")
|
||||
|
||||
def _record_sync(self, table: str) -> None:
|
||||
"""记录一张表的同步完成时间: 更新内存 + 持久化到 preferences.json。
|
||||
|
||||
持久化确保即使重启,前端 /status 仍返回真实的最后同步时间,
|
||||
不会错误地显示"尚未同步"。
|
||||
"""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
self._last_sync[table] = ts
|
||||
try:
|
||||
from app.services import preferences
|
||||
preferences.set_financial_sync_time(table, ts)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("persist financial_sync_time(%s) failed: %s", e)
|
||||
|
||||
def update_capabilities(self, capset: CapabilitySet) -> None:
|
||||
"""刷新调度器持有的能力集。
|
||||
|
||||
用户在「设置」页新增/清除 API Key 后, settings API 会重新探测能力并更新
|
||||
app.state.capabilities; 必须同步推给本调度器, 否则 trigger()/run_now() 仍读
|
||||
启动时的旧 capset, 即便 app.state 已含 FINANCIAL, 调度器仍报
|
||||
"no FINANCIAL capability" 而拒绝同步 (表现为前端「全部同步」按钮闪一下无动作)。
|
||||
"""
|
||||
prev = self._capset
|
||||
self._capset = capset
|
||||
had = bool(prev) and prev.has(Cap.FINANCIAL)
|
||||
now = capset.has(Cap.FINANCIAL)
|
||||
if had != now:
|
||||
logger.info(
|
||||
"FinancialScheduler capabilities updated: FINANCIAL %s -> %s", had, now
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
@@ -217,22 +284,6 @@ class FinancialScheduler:
|
||||
self._task = None
|
||||
logger.info("FinancialScheduler stopped")
|
||||
|
||||
def update(self, data_dir: Path, capset: CapabilitySet) -> None:
|
||||
"""运行时更新数据目录和能力集。
|
||||
|
||||
用户在设置页更换/清除 Key 后,能力集可能变化,无需重启服务即可让
|
||||
财务调度器生效或失效。
|
||||
"""
|
||||
had_financial = self._capset is not None and self._capset.has(Cap.FINANCIAL)
|
||||
has_financial = capset.has(Cap.FINANCIAL)
|
||||
self._data_dir = data_dir
|
||||
self._capset = capset
|
||||
|
||||
if has_financial and not self._running:
|
||||
self.start(data_dir, capset)
|
||||
elif had_financial and not has_financial and self._running:
|
||||
self.stop()
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""每周执行一次 metrics 同步。"""
|
||||
try:
|
||||
@@ -245,7 +296,7 @@ class FinancialScheduler:
|
||||
# 每周: 只同步 metrics
|
||||
try:
|
||||
rows = sync_metrics(self._data_dir, self._capset)
|
||||
self._last_sync["metrics"] = datetime.now(timezone.utc).isoformat()
|
||||
self._record_sync("metrics")
|
||||
logger.info("FinancialScheduler: metrics synced, %d rows", rows)
|
||||
except Exception as e:
|
||||
logger.warning("FinancialScheduler: metrics sync failed: %s", e)
|
||||
@@ -259,8 +310,39 @@ class FinancialScheduler:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def _run_body(self, table: str | None) -> dict[str, int]:
|
||||
"""同步逻辑本体(不加锁,假设调用方已持有 _is_syncing)。
|
||||
|
||||
table=None 同步全部 4 张表;否则只同步指定表。
|
||||
每张表完成立即更新 last_sync,让前端轮询 /status 能看到进度递增。
|
||||
"""
|
||||
if table:
|
||||
fn = {
|
||||
"metrics": sync_metrics,
|
||||
"income": sync_income,
|
||||
"balance_sheet": sync_balance_sheet,
|
||||
"cash_flow": sync_cash_flow,
|
||||
}.get(table)
|
||||
if not fn:
|
||||
return {}
|
||||
rows = fn(self._data_dir, self._capset)
|
||||
self._record_sync(table)
|
||||
return {table: rows}
|
||||
# 全部同步
|
||||
symbols = _get_symbols(self._data_dir)
|
||||
result: dict[str, int] = {}
|
||||
for t in FINANCIAL_TABLES:
|
||||
result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
|
||||
self._record_sync(t)
|
||||
_refresh_financials_views(self._data_dir)
|
||||
return result
|
||||
|
||||
def run_now(self, table: str | None = None) -> dict[str, int]:
|
||||
"""手动触发同步。table=None 同步全部。
|
||||
"""同步执行一次同步(阻塞调用线程)。
|
||||
|
||||
⚠ 全量同步需数分钟,务必在后台线程调用,不要直接在 HTTP 请求线程里阻塞,
|
||||
否则请求会长时间 pending 直至被浏览器/代理超时掐断(表现为"点击无反应")。
|
||||
HTTP 接口应调用 trigger() 立即返回,再让前端轮询 /status.syncing 看进度。
|
||||
|
||||
用 _is_syncing 标志防并发:若已有同步在进行,本次直接跳过,
|
||||
避免重复请求拖慢服务端 / 触发上游限流。
|
||||
@@ -273,32 +355,46 @@ class FinancialScheduler:
|
||||
return {"_skipped": 1}
|
||||
self._is_syncing = True
|
||||
try:
|
||||
if table:
|
||||
fn = {
|
||||
"metrics": sync_metrics,
|
||||
"income": sync_income,
|
||||
"balance_sheet": sync_balance_sheet,
|
||||
"cash_flow": sync_cash_flow,
|
||||
}.get(table)
|
||||
if not fn:
|
||||
return {}
|
||||
rows = fn(self._data_dir, self._capset)
|
||||
self._last_sync[table] = datetime.now(timezone.utc).isoformat()
|
||||
return {table: rows}
|
||||
else:
|
||||
# 全部同步: 逐表执行, 每张完成立即更新 last_sync,
|
||||
# 让前端轮询 /status 能看到进度递增 (而非等全部完成才一次性更新)。
|
||||
symbols = _get_symbols(self._data_dir)
|
||||
result: dict[str, int] = {}
|
||||
for t in FINANCIAL_TABLES:
|
||||
result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
|
||||
self._last_sync[t] = datetime.now(timezone.utc).isoformat()
|
||||
_refresh_financials_views(self._data_dir)
|
||||
return result
|
||||
return self._run_body(table)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._is_syncing = False
|
||||
|
||||
def trigger(self, table: str | None = None) -> dict[str, int]:
|
||||
"""触发一次同步(非阻塞,立即返回)。
|
||||
|
||||
在后台线程执行同步体,HTTP 请求无需等待。
|
||||
返回 {"started": True/False}:
|
||||
- False = 能力不足或已有同步在进行(被防并发跳过)
|
||||
- True = 已在后台开始,前端应轮询 /status.syncing 观察进度
|
||||
|
||||
⚠ _is_syncing 在此处置 True(持锁),确保 trigger 返回时前端轮询
|
||||
/status 已能看到 syncing=True,无竞态窗口;同时防止快速重复点击
|
||||
启动多个后台线程。后台线程复用 _run_body 执行真正的同步逻辑。
|
||||
"""
|
||||
if not self._capset or not self._capset.has(Cap.FINANCIAL):
|
||||
return {"started": False, "reason": "no FINANCIAL capability"}
|
||||
with self._lock:
|
||||
if self._is_syncing:
|
||||
logger.info("financial sync trigger skipped: already running")
|
||||
return {"started": False, "reason": "already running"}
|
||||
# 持锁置位:保证 trigger 返回前 syncing 已为 True
|
||||
self._is_syncing = True
|
||||
|
||||
def _bg() -> None:
|
||||
try:
|
||||
self._run_body(table)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("background financial sync failed: %s", e)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._is_syncing = False
|
||||
|
||||
t = threading.Thread(target=_bg, name="financial-sync", daemon=True)
|
||||
t.start()
|
||||
logger.info("financial sync triggered in background: table=%s", table or "all")
|
||||
return {"started": True}
|
||||
|
||||
@property
|
||||
def is_syncing(self) -> bool:
|
||||
"""手动同步是否正在进行(供 /status 返回,前端据此显示"同步中")。"""
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""指数数据同步服务。"""
|
||||
"""指数 / ETF 数据同步服务。
|
||||
|
||||
标的列表优先用免费的 exchanges.get_instruments(type=index/etf) 拉取
|
||||
(None/Free 档均可用,无需 quote.pool 权限);付费档可额外用
|
||||
quotes.get_by_universes 作为补充来源。日K统一走 klines.batch。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import gc
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import polars as pl
|
||||
@@ -15,9 +21,15 @@ from app.tickflow.repository import KlineRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# exchanges.get_instruments 查询的交易所(沪深京)
|
||||
_EXCHANGES = ["SH", "SZ", "BJ"]
|
||||
|
||||
|
||||
def _quotes_to_index_instruments(resp) -> pl.DataFrame:
|
||||
"""将数据源 quotes 响应规范为指数 instruments。"""
|
||||
"""将 TickFlow quotes 响应(get_by_universes)规范为指数 instruments。
|
||||
|
||||
付费档(Starter+)的补充来源,免费档用不到。
|
||||
"""
|
||||
if resp is None:
|
||||
return pl.DataFrame()
|
||||
|
||||
@@ -61,33 +73,119 @@ def _quotes_to_index_instruments(resp) -> pl.DataFrame:
|
||||
return result.unique(subset=["symbol"], keep="last").sort("symbol")
|
||||
|
||||
|
||||
def sync_index_instruments(repo: KlineRepository) -> int:
|
||||
"""同步 CN_Index 指数标的维表,返回指数数量。"""
|
||||
def _fetch_instruments_by_type(instrument_type: str, asset_type_label: str) -> pl.DataFrame:
|
||||
"""用免费的 exchanges.get_instruments 拉取指定类型的标的列表。
|
||||
|
||||
None/Free 档均可使用(标的信息查询免费开放)。
|
||||
instrument_type: 'index' / 'etf'
|
||||
asset_type_label: 写入 instruments 表的 asset_type 标记('index' / 'etf')
|
||||
"""
|
||||
tf = get_client()
|
||||
resp = None
|
||||
errors: list[str] = []
|
||||
for kwargs in (
|
||||
{"universes": ["CN_Index"]},
|
||||
{"universes": ["CN_Index"], "as_dataframe": False},
|
||||
):
|
||||
rows: list[dict] = []
|
||||
for ex in _EXCHANGES:
|
||||
try:
|
||||
resp = tf.quotes.get_by_universes(**kwargs)
|
||||
if resp is not None and len(resp) > 0:
|
||||
break
|
||||
items = tf.exchanges.get_instruments(ex, instrument_type=instrument_type)
|
||||
for it in items or []:
|
||||
item = it if isinstance(it, dict) else {}
|
||||
symbol = item.get("symbol")
|
||||
if not symbol:
|
||||
continue
|
||||
rows.append({
|
||||
"symbol": str(symbol),
|
||||
"name": item.get("name") or str(symbol),
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(str(e))
|
||||
resp = None
|
||||
logger.warning("get_instruments(%s, type=%s) failed: %s", ex, instrument_type, e)
|
||||
|
||||
if resp is None or len(resp) == 0:
|
||||
logger.warning("CN_Index universe returned empty: %s", "; ".join(errors))
|
||||
return 0
|
||||
if not rows:
|
||||
return pl.DataFrame()
|
||||
|
||||
instruments = _quotes_to_index_instruments(resp)
|
||||
if instruments.is_empty():
|
||||
return (
|
||||
pl.DataFrame(rows)
|
||||
.with_columns([
|
||||
pl.col("symbol").str.split(".").list.first().alias("code"),
|
||||
pl.lit(asset_type_label).alias("asset_type"),
|
||||
])
|
||||
.unique(subset=["symbol"], keep="last")
|
||||
.sort("symbol")
|
||||
)
|
||||
|
||||
|
||||
def sync_index_instruments(
|
||||
repo: KlineRepository,
|
||||
pull_index: bool = True,
|
||||
pull_etf: bool = True,
|
||||
) -> int:
|
||||
"""同步指数 / ETF 标的维表,返回标的总数。
|
||||
|
||||
新版物理分开保存: 指数写 instruments_index, ETF 写 instruments_etf。
|
||||
读取层仍兼容旧版 instruments_index 中 asset_type='etf' 的历史数据。
|
||||
"""
|
||||
index_parts: list[pl.DataFrame] = []
|
||||
etf_parts: list[pl.DataFrame] = []
|
||||
|
||||
# 1) 免费通道:按开关分别拉 index / etf
|
||||
if pull_index:
|
||||
index_df = _fetch_instruments_by_type("index", "index")
|
||||
if not index_df.is_empty():
|
||||
index_parts.append(index_df)
|
||||
if pull_etf:
|
||||
etf_df = _fetch_instruments_by_type("etf", "etf")
|
||||
if not etf_df.is_empty():
|
||||
etf_parts.append(etf_df)
|
||||
|
||||
# 2) 付费补充:Starter+ 用 get_by_universes 补指数(仅当开启指数拉取)
|
||||
if pull_index:
|
||||
capset = None
|
||||
try:
|
||||
from app.tickflow import policy
|
||||
capset = policy.detect_capabilities(force=False)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if capset is not None and capset.has(Cap.QUOTE_POOL):
|
||||
tf = get_client()
|
||||
for kwargs in (
|
||||
{"universes": ["CN_Index"]},
|
||||
{"universes": ["CN_Index"], "as_dataframe": False},
|
||||
):
|
||||
try:
|
||||
resp = tf.quotes.get_by_universes(**kwargs)
|
||||
if resp is not None and len(resp) > 0:
|
||||
sup = _quotes_to_index_instruments(resp)
|
||||
if not sup.is_empty():
|
||||
index_parts.append(sup)
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("CN_Index universe supplement failed: %s", e)
|
||||
|
||||
total = 0
|
||||
if index_parts:
|
||||
index_inst = pl.concat(index_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
|
||||
if not index_inst.is_empty():
|
||||
repo.save_index_instruments(index_inst)
|
||||
total += index_inst.height
|
||||
if etf_parts:
|
||||
etf_inst = pl.concat(etf_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
|
||||
if not etf_inst.is_empty():
|
||||
repo.save_etf_instruments(etf_inst)
|
||||
total += etf_inst.height
|
||||
|
||||
if total == 0:
|
||||
logger.warning("指数/ETF 标的列表为空(pull_index=%s, pull_etf=%s)", pull_index, pull_etf)
|
||||
return 0
|
||||
repo.save_index_instruments(instruments)
|
||||
repo.refresh_index_views()
|
||||
return instruments.height
|
||||
logger.info("指数/ETF 标的同步完成: %d 只", total)
|
||||
return total
|
||||
|
||||
|
||||
def sync_etf_instruments(repo: KlineRepository) -> int:
|
||||
"""单独同步 ETF 标的维表(返回 ETF 数量)。"""
|
||||
etf_df = _fetch_instruments_by_type("etf", "etf")
|
||||
if etf_df.is_empty():
|
||||
return 0
|
||||
repo.save_etf_instruments(etf_df)
|
||||
repo.refresh_index_views()
|
||||
return etf_df.height
|
||||
|
||||
|
||||
def sync_and_persist_index_daily(
|
||||
@@ -96,19 +194,32 @@ def sync_and_persist_index_daily(
|
||||
count: int | None = None,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
symbols_override: list[str] | None = None,
|
||||
on_chunk_done: Callable[[int, int], None] | None = None,
|
||||
) -> int:
|
||||
"""同步指数日K到独立 parquet,并计算指数 enriched。"""
|
||||
"""同步指数/ETF 日K到独立 parquet,并计算 enriched。
|
||||
|
||||
symbols_override 非空时,只拉这些代码(跳过 instruments 表),用于自定义范围。
|
||||
否则取 index_instruments 表全量(指数+ETF 合并存储)。
|
||||
on_chunk_done(current, total) 每个批次完成后回调。
|
||||
"""
|
||||
if not capset.has(Cap.KLINE_DAILY_BATCH):
|
||||
return 0
|
||||
|
||||
instruments = repo.get_index_instruments()
|
||||
if instruments.is_empty():
|
||||
sync_index_instruments(repo)
|
||||
if symbols_override:
|
||||
symbols = sorted(set(s for s in symbols_override if s))
|
||||
if not symbols:
|
||||
return 0
|
||||
else:
|
||||
instruments = repo.get_index_instruments()
|
||||
if instruments.is_empty() or "symbol" not in instruments.columns:
|
||||
return 0
|
||||
|
||||
symbols = sorted(set(instruments["symbol"].to_list()))
|
||||
if instruments.is_empty():
|
||||
sync_index_instruments(repo, pull_index=True, pull_etf=False)
|
||||
instruments = repo.get_index_instruments()
|
||||
if not instruments.is_empty() and "asset_type" in instruments.columns:
|
||||
instruments = instruments.filter(pl.col("asset_type") != "etf")
|
||||
if instruments.is_empty() or "symbol" not in instruments.columns:
|
||||
return 0
|
||||
symbols = sorted(set(instruments["symbol"].to_list()))
|
||||
lim = capset.limits(Cap.KLINE_DAILY_BATCH)
|
||||
batch_size = preferences.get_index_daily_batch_size()
|
||||
if lim and lim.batch:
|
||||
@@ -139,7 +250,110 @@ def sync_and_persist_index_daily(
|
||||
enriched = compute_enriched(raw, factors=None, instruments=None)
|
||||
repo.append_index_enriched(enriched)
|
||||
total_rows += raw.height
|
||||
logger.info("index daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
|
||||
logger.info("index/etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
|
||||
if on_chunk_done:
|
||||
on_chunk_done(i + 1, len(chunks))
|
||||
del raw, enriched
|
||||
gc.collect()
|
||||
repo.refresh_index_views()
|
||||
return total_rows
|
||||
|
||||
|
||||
def _load_etf_factors(repo: KlineRepository) -> pl.DataFrame:
|
||||
factor_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet"
|
||||
if not factor_path.exists():
|
||||
return pl.DataFrame()
|
||||
try:
|
||||
return pl.read_parquet(factor_path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ETF 复权因子读取失败: %s", e)
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
def sync_etf_adj_factor(
|
||||
symbols: list[str],
|
||||
repo: KlineRepository,
|
||||
capset: CapabilitySet,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
on_chunk_done=None,
|
||||
) -> tuple[int, list[str]]:
|
||||
"""同步 ETF 复权因子;失败由调用方降级为 warning。"""
|
||||
return kline_sync.sync_adj_factor(
|
||||
symbols,
|
||||
repo,
|
||||
capset,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
on_chunk_done=on_chunk_done,
|
||||
asset_type="etf",
|
||||
)
|
||||
|
||||
|
||||
def sync_and_persist_etf_daily(
|
||||
repo: KlineRepository,
|
||||
capset: CapabilitySet,
|
||||
count: int | None = None,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
symbols_override: list[str] | None = None,
|
||||
on_chunk_done: Callable[[int, int], None] | None = None,
|
||||
) -> int:
|
||||
"""同步 ETF 日K到独立 kline_etf_* parquet,并计算 ETF enriched。
|
||||
on_chunk_done(current, total) 每个批次完成后回调。
|
||||
"""
|
||||
if not capset.has(Cap.KLINE_DAILY_BATCH):
|
||||
return 0
|
||||
|
||||
if symbols_override:
|
||||
symbols = sorted(set(s for s in symbols_override if s))
|
||||
else:
|
||||
instruments = repo.get_etf_instruments()
|
||||
if instruments.is_empty():
|
||||
sync_etf_instruments(repo)
|
||||
instruments = repo.get_etf_instruments()
|
||||
if instruments.is_empty() or "symbol" not in instruments.columns:
|
||||
return 0
|
||||
symbols = sorted(set(instruments["symbol"].to_list()))
|
||||
if not symbols:
|
||||
return 0
|
||||
|
||||
lim = capset.limits(Cap.KLINE_DAILY_BATCH)
|
||||
batch_size = preferences.get_index_daily_batch_size()
|
||||
if lim and lim.batch:
|
||||
batch_size = min(batch_size, lim.batch)
|
||||
rpm = lim.rpm if lim else None
|
||||
|
||||
end_time = end_date or datetime.now()
|
||||
start_time = start_date or (end_time - timedelta(days=365))
|
||||
|
||||
total_rows = 0
|
||||
interval = (60.0 / rpm) if rpm else 0
|
||||
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
|
||||
factors = _load_etf_factors(repo)
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i > 0 and interval > 0 and len(chunks) > rpm:
|
||||
import time
|
||||
time.sleep(interval)
|
||||
raw = kline_sync.sync_daily_batch(
|
||||
chunk,
|
||||
count=count,
|
||||
batch_size=None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
if raw.is_empty():
|
||||
continue
|
||||
|
||||
repo.append_etf_daily(raw)
|
||||
batch_factors = factors.filter(pl.col("symbol").is_in(chunk)) if not factors.is_empty() else factors
|
||||
# ETF 使用复权和通用技术指标;不传 instruments,避免套用 A股涨跌停/连板逻辑。
|
||||
enriched = compute_enriched(raw, factors=batch_factors, instruments=None)
|
||||
repo.append_etf_enriched(enriched)
|
||||
total_rows += raw.height
|
||||
logger.info("etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
|
||||
if on_chunk_done:
|
||||
on_chunk_done(i + 1, len(chunks))
|
||||
del raw, enriched
|
||||
gc.collect()
|
||||
repo.refresh_index_views()
|
||||
|
||||
@@ -223,11 +223,53 @@ def sync_daily_by_quotes(repo: KlineRepository) -> int:
|
||||
return daily_df.height
|
||||
|
||||
|
||||
def _normalize_adj_factor(raw) -> pl.DataFrame:
|
||||
"""Normalize SDK ex_factors response to symbol/trade_date/ex_factor."""
|
||||
if raw is None or len(raw) == 0:
|
||||
return pl.DataFrame()
|
||||
if isinstance(raw, dict):
|
||||
rows: list[dict] = []
|
||||
for sym, values in raw.items():
|
||||
for item in values or []:
|
||||
row = dict(item or {})
|
||||
row.setdefault("symbol", sym)
|
||||
rows.append(row)
|
||||
df = pl.DataFrame(rows) if rows else pl.DataFrame()
|
||||
elif isinstance(raw, pl.DataFrame):
|
||||
df = raw
|
||||
else:
|
||||
df = pl.from_pandas(raw.reset_index() if hasattr(raw, "reset_index") else raw)
|
||||
if df.is_empty():
|
||||
return df
|
||||
# rename: timestamp/date → trade_date, adj_factor → ex_factor
|
||||
# 注意: 新版 SDK 可能同时返回 timestamp 和 trade_date (或 adj_factor 和 ex_factor),
|
||||
# 直接 rename 会产生重复列报错。仅当目标列不存在时才 rename。
|
||||
rename_map: dict[str, str] = {}
|
||||
for src, dst in (("timestamp", "trade_date"), ("date", "trade_date"), ("adj_factor", "ex_factor")):
|
||||
if src in df.columns and dst not in df.columns:
|
||||
rename_map[src] = dst
|
||||
df = df.rename(rename_map)
|
||||
if "trade_date" in df.columns:
|
||||
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
|
||||
df = df.with_columns(
|
||||
pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
|
||||
)
|
||||
else:
|
||||
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
|
||||
if "ex_factor" in df.columns:
|
||||
df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False))
|
||||
cols = [c for c in ["symbol", "trade_date", "ex_factor"] if c in df.columns]
|
||||
if len(cols) < 3:
|
||||
return pl.DataFrame()
|
||||
return df.select(cols).drop_nulls()
|
||||
|
||||
|
||||
def sync_adj_factor(symbols: list[str], repo: KlineRepository,
|
||||
capset: CapabilitySet,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
on_chunk_done: Callable[[int, int], None] | None = None) -> tuple[int, list[str]]:
|
||||
on_chunk_done: Callable[[int, int], None] | None = None,
|
||||
asset_type: str = "stock") -> tuple[int, list[str]]:
|
||||
"""同步除权因子(Starter+)。SDK 接口:`tf.klines.ex_factors(symbols=...)`。
|
||||
|
||||
支持增量: 传 start_time/end_time 只拉取该时间范围内的新除权事件。
|
||||
@@ -257,10 +299,9 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
|
||||
time.sleep(interval)
|
||||
try:
|
||||
raw = tf.klines.ex_factors(chunk, **sdk_kwargs)
|
||||
if raw is not None and len(raw) > 0:
|
||||
all_dfs.append(pl.from_pandas(
|
||||
raw.reset_index() if hasattr(raw, "reset_index") else raw
|
||||
))
|
||||
normalized = _normalize_adj_factor(raw)
|
||||
if not normalized.is_empty():
|
||||
all_dfs.append(normalized)
|
||||
logger.debug("adj_factor chunk %d/%d: %d symbols", i + 1, len(chunks), len(chunk))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("adj_factor chunk %d failed: %s", i + 1, e)
|
||||
@@ -276,7 +317,8 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
|
||||
# 提取受影响的 symbol 列表(合并前)
|
||||
affected = new_data["symbol"].unique().to_list()
|
||||
|
||||
out = repo.store.data_dir / "adj_factor" / "all.parquet"
|
||||
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
|
||||
out = repo.store.data_dir / factor_dir / "all.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if out.exists():
|
||||
@@ -420,7 +462,7 @@ def sync_minute_batch(
|
||||
|
||||
|
||||
def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
|
||||
"""从数据源实时拉取单股单日分钟 K(不写入本地)。"""
|
||||
"""从 TickFlow 实时拉取单股单日分钟 K(不写入本地)。"""
|
||||
from datetime import datetime
|
||||
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
|
||||
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
|
||||
@@ -445,6 +487,21 @@ def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
def fetch_adj_factor_single(symbol: str) -> pl.DataFrame:
|
||||
"""从 TickFlow 实时拉取单股除权因子(不写入本地), 用于单股 K 线即时前复权。
|
||||
|
||||
返回结构: symbol, trade_date, ex_factor (空 DataFrame 表示无除权事件或拉取失败)。
|
||||
与 _apply_adj_factor / compute_enriched 的 factors 参数格式一致。
|
||||
"""
|
||||
tf = get_client()
|
||||
try:
|
||||
raw = tf.klines.ex_factors([symbol], as_dataframe=True, show_progress=False)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("fetch_adj_factor_single(%s) failed: %s", symbol, e)
|
||||
return pl.DataFrame()
|
||||
return _normalize_adj_factor(raw)
|
||||
|
||||
|
||||
def _latest_minute_datetime(repo: KlineRepository) -> datetime | None:
|
||||
"""本地分钟 K 数据的最新时间。"""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
"""市场总览数据装配(与 HTTP Request 解耦)。
|
||||
|
||||
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」
|
||||
等无 Request 的调用方(定时任务、复盘服务)也能复用同一套聚合逻辑。
|
||||
|
||||
行为与原 `_build_overview` 完全一致,仅把对 `request.app.state.{repo,
|
||||
quote_service,depth_service}` 的依赖改为显式参数。
|
||||
|
||||
公共入口:
|
||||
build_market_overview(repo, quote_service, depth_service, as_of)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.services.ext_data import ExtConfig, ExtConfigStore
|
||||
from app.services.screener import ScreenerService
|
||||
|
||||
# ================================================================
|
||||
# 常量(与 overview.py 保持同步;复盘复盘仅 A 股核心指数)
|
||||
# ================================================================
|
||||
|
||||
CORE_INDEX_NAMES = {
|
||||
"000001.SH": "上证指数",
|
||||
"399001.SZ": "深证成指",
|
||||
"399006.SZ": "创业板指",
|
||||
"000680.SH": "科创综指",
|
||||
}
|
||||
CORE_INDEX_SYMBOLS = tuple(CORE_INDEX_NAMES.keys())
|
||||
|
||||
_DIMENSION_SEP = re.compile(r"[、,,;;|/\s]+")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 通用工具
|
||||
# ================================================================
|
||||
|
||||
def _finite(v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
f = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f if math.isfinite(f) else None
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {k: _json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(v) for v in value]
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _board(symbol: str) -> str:
|
||||
if symbol.endswith(".BJ"):
|
||||
return "北交所"
|
||||
if symbol.startswith(("300", "301")):
|
||||
return "创业板"
|
||||
if symbol.startswith(("688", "689")):
|
||||
return "科创板"
|
||||
if symbol.endswith(".SH"):
|
||||
return "沪主板"
|
||||
if symbol.endswith(".SZ"):
|
||||
return "深主板"
|
||||
return "其他"
|
||||
|
||||
|
||||
def _score(value: float, low: float, high: float) -> int:
|
||||
if high <= low:
|
||||
return 50
|
||||
return max(0, min(100, round((value - low) / (high - low) * 100)))
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 指数行情(实时 quote_service 优先,回退 kline_index_daily SQL)
|
||||
# ================================================================
|
||||
|
||||
def _quote_status(quote_service) -> dict:
|
||||
qs = quote_service
|
||||
if not qs:
|
||||
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
|
||||
return qs.status()
|
||||
|
||||
|
||||
def _index_quotes(repo, quote_service, as_of: date | None = None) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
if quote_service and as_of is None:
|
||||
df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS))
|
||||
if not df.is_empty():
|
||||
rows = df.to_dicts()
|
||||
|
||||
if not rows and repo:
|
||||
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
|
||||
try:
|
||||
db_rows = repo.execute_all(
|
||||
f"""
|
||||
WITH ranked AS (
|
||||
SELECT symbol, date, close,
|
||||
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
|
||||
FROM kline_index_daily
|
||||
WHERE symbol IN ({placeholders})
|
||||
AND (? IS NULL OR date <= ?)
|
||||
), latest AS (
|
||||
SELECT symbol,
|
||||
max(CASE WHEN rn = 1 THEN date END) AS date,
|
||||
max(CASE WHEN rn = 1 THEN close END) AS last_price,
|
||||
max(CASE WHEN rn = 2 THEN close END) AS prev_close
|
||||
FROM ranked
|
||||
WHERE rn <= 2
|
||||
GROUP BY symbol
|
||||
)
|
||||
SELECT symbol, date, last_price, prev_close
|
||||
FROM latest
|
||||
""",
|
||||
[*CORE_INDEX_SYMBOLS, as_of, as_of],
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
db_rows = []
|
||||
for symbol, dt, last_price, prev_close in db_rows:
|
||||
change_amount = None
|
||||
change_pct = None
|
||||
lp = _finite(last_price)
|
||||
pc = _finite(prev_close)
|
||||
if lp is not None and pc not in (None, 0):
|
||||
change_amount = lp - pc
|
||||
change_pct = change_amount / pc * 100
|
||||
rows.append({
|
||||
"symbol": symbol,
|
||||
"name": CORE_INDEX_NAMES.get(symbol),
|
||||
"date": str(dt) if dt else None,
|
||||
"last_price": lp,
|
||||
"close": lp,
|
||||
"prev_close": pc,
|
||||
"change_amount": change_amount,
|
||||
"change_pct": change_pct,
|
||||
})
|
||||
|
||||
by_symbol = {r.get("symbol"): r for r in rows}
|
||||
out = []
|
||||
for symbol in CORE_INDEX_SYMBOLS:
|
||||
r = by_symbol.get(symbol, {"symbol": symbol})
|
||||
out.append({
|
||||
"symbol": symbol,
|
||||
"name": r.get("name") or CORE_INDEX_NAMES[symbol],
|
||||
"last_price": _finite(r.get("last_price") if r.get("last_price") is not None else r.get("close")),
|
||||
"change_pct": _finite(r.get("change_pct")),
|
||||
"change_amount": _finite(r.get("change_amount")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 扩展数据(行业 / 概念)维度聚合
|
||||
# ================================================================
|
||||
|
||||
def _dimension_field(config: ExtConfig, kind: str) -> str | None:
|
||||
candidates = ["概念", "concept", "theme"] if kind == "concept" else ["行业", "industry", "sector"]
|
||||
for candidate in candidates:
|
||||
needle = candidate.lower()
|
||||
for field in config.fields:
|
||||
haystack = f"{field.name} {field.label}".lower()
|
||||
if needle in haystack:
|
||||
return field.name
|
||||
return None
|
||||
|
||||
|
||||
def _ext_files(data_dir, config: ExtConfig) -> list[str]:
|
||||
base = data_dir / "ext_data" / config.id
|
||||
if config.mode == "timeseries":
|
||||
root = base / "timeseries"
|
||||
return [str(p) for p in sorted(root.rglob("*.parquet")) if p.is_file()]
|
||||
return [str(p) for p in sorted(base.glob("*.parquet")) if p.is_file()]
|
||||
|
||||
|
||||
def _read_ext_rows(data_dir, config: ExtConfig, dimension_field: str) -> list[dict]:
|
||||
files = _ext_files(data_dir, config)
|
||||
if not files:
|
||||
return []
|
||||
try:
|
||||
df = pl.read_parquet(files, hive_partitioning=True)
|
||||
except TypeError:
|
||||
try:
|
||||
df = pl.read_parquet(files)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if df.is_empty() or dimension_field not in df.columns:
|
||||
return []
|
||||
|
||||
if config.mode == "timeseries" and "date" in df.columns:
|
||||
latest = df.get_column("date").max()
|
||||
if latest is not None:
|
||||
df = df.filter(pl.col("date") == latest)
|
||||
|
||||
symbol_cols = ["symbol", "code", "股票代码", "代码"]
|
||||
for mapping in (config.symbol_map, config.code_map):
|
||||
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
|
||||
symbol_cols.append(str(mapping["col"]))
|
||||
cols = []
|
||||
for col in [dimension_field, *symbol_cols]:
|
||||
if col in df.columns and col not in cols:
|
||||
cols.append(col)
|
||||
return df.select(cols).to_dicts()
|
||||
|
||||
|
||||
def _dimension_values(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
values = [v.strip() for v in _DIMENSION_SEP.split(str(raw).strip()) if v.strip()]
|
||||
return values
|
||||
|
||||
|
||||
def _symbol_keys(row: dict, config: ExtConfig) -> list[str]:
|
||||
fields = ["symbol", "code", "股票代码", "代码"]
|
||||
for mapping in (config.symbol_map, config.code_map):
|
||||
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
|
||||
fields.append(str(mapping["col"]))
|
||||
|
||||
keys: list[str] = []
|
||||
for field in fields:
|
||||
raw = row.get(field)
|
||||
if raw is None:
|
||||
continue
|
||||
text = str(raw).strip().upper()
|
||||
if not text:
|
||||
continue
|
||||
keys.append(text)
|
||||
if "." in text:
|
||||
keys.append(text.split(".", 1)[0])
|
||||
return keys
|
||||
|
||||
|
||||
def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: int | None = None) -> dict:
|
||||
if not rows:
|
||||
return {"leading": [], "lagging": []}
|
||||
|
||||
quote_map: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
symbol = str(row.get("symbol") or "").strip().upper()
|
||||
if not symbol:
|
||||
continue
|
||||
quote_map[symbol] = row
|
||||
quote_map[symbol.split(".", 1)[0]] = row
|
||||
|
||||
store = ExtConfigStore(repo.store.data_dir)
|
||||
groups: dict[str, dict[str, dict]] = {}
|
||||
for config in store.load_all():
|
||||
field = _dimension_field(config, kind)
|
||||
if not field:
|
||||
continue
|
||||
for ext_row in _read_ext_rows(repo.store.data_dir, config, field):
|
||||
quote = None
|
||||
for key in _symbol_keys(ext_row, config):
|
||||
quote = quote_map.get(key)
|
||||
if quote:
|
||||
break
|
||||
if not quote:
|
||||
continue
|
||||
symbol = str(quote.get("symbol") or "")
|
||||
for value in _dimension_values(ext_row.get(field)):
|
||||
# 行业按 "-" 拆分级: "银行-银行-股份制银行" → level=2 取"银行"(二级)
|
||||
if level is not None and "-" in value:
|
||||
parts = value.split("-")
|
||||
value = parts[level - 1] if level <= len(parts) else parts[-1]
|
||||
groups.setdefault(value, {})[symbol] = quote
|
||||
|
||||
items = []
|
||||
for name, by_symbol in groups.items():
|
||||
stocks = list(by_symbol.values())
|
||||
changes = [_finite(s.get("change_pct")) for s in stocks]
|
||||
changes = [v for v in changes if v is not None]
|
||||
if not changes:
|
||||
continue
|
||||
leader = max(stocks, key=lambda s: _finite(s.get("change_pct")) or -999)
|
||||
items.append({
|
||||
"name": name,
|
||||
"count": len(stocks),
|
||||
"avg_pct": sum(changes) / len(changes),
|
||||
"up_count": sum(1 for v in changes if v > 0),
|
||||
"down_count": sum(1 for v in changes if v < 0),
|
||||
"amount": sum(_finite(s.get("amount")) or 0 for s in stocks),
|
||||
"leader": {
|
||||
"symbol": leader.get("symbol"),
|
||||
"name": leader.get("name"),
|
||||
"change_pct": _finite(leader.get("change_pct")),
|
||||
},
|
||||
})
|
||||
|
||||
leading = sorted(items, key=lambda x: x["avg_pct"], reverse=True)[:limit]
|
||||
lagging = sorted(items, key=lambda x: x["avg_pct"])[:limit]
|
||||
return {"leading": leading, "lagging": lagging}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Top 行 / 涨跌幅分桶
|
||||
# ================================================================
|
||||
|
||||
def _top_rows(rows: list[dict], key: str, descending: bool, limit: int = 8) -> list[dict]:
|
||||
filtered = [r for r in rows if _finite(r.get(key)) is not None]
|
||||
filtered.sort(key=lambda r: _finite(r.get(key)) or 0, reverse=descending)
|
||||
return [
|
||||
{
|
||||
"symbol": r.get("symbol"),
|
||||
"name": r.get("name"),
|
||||
"close": _finite(r.get("close")),
|
||||
"change_pct": _finite(r.get("change_pct")),
|
||||
"amount": _finite(r.get("amount")),
|
||||
"turnover_rate": _finite(r.get("turnover_rate")),
|
||||
"board": _board(str(r.get("symbol") or "")),
|
||||
}
|
||||
for r in filtered[:limit]
|
||||
]
|
||||
|
||||
|
||||
def _pct_band_rows(values: list[float]) -> list[dict]:
|
||||
bands = [
|
||||
("<-5%", None, -0.05),
|
||||
("-5~-3%", -0.05, -0.03),
|
||||
("-3~-1%", -0.03, -0.01),
|
||||
("-1~0%", -0.01, 0),
|
||||
("0~1%", 0, 0.01),
|
||||
("1~3%", 0.01, 0.03),
|
||||
("3~5%", 0.03, 0.05),
|
||||
(">5%", 0.05, None),
|
||||
]
|
||||
total = len(values) or 1
|
||||
out = []
|
||||
for label, low, high in bands:
|
||||
count = 0
|
||||
for v in values:
|
||||
if low is None and v < high:
|
||||
count += 1
|
||||
elif high is None and v >= low:
|
||||
count += 1
|
||||
elif low is not None and high is not None and low <= v < high:
|
||||
count += 1
|
||||
out.append({"label": label, "count": count, "pct": count / total * 100})
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 主装配入口
|
||||
# ================================================================
|
||||
|
||||
def build_market_overview(
|
||||
repo,
|
||||
quote_service=None,
|
||||
depth_service=None,
|
||||
as_of: date | None = None,
|
||||
) -> dict:
|
||||
"""装配市场总览(与原 overview._build_overview 行为一致)。
|
||||
|
||||
Args:
|
||||
repo: KlineRepository(必填)。
|
||||
quote_service: QuoteService(可选;实时指数行情来源)。
|
||||
depth_service: DepthService(可选;五档封板修正)。
|
||||
as_of: 指定日期,None 则取最新有数据日。
|
||||
"""
|
||||
svc = ScreenerService(repo)
|
||||
as_of = as_of or svc.latest_date()
|
||||
status = _quote_status(quote_service)
|
||||
indices = _index_quotes(repo, quote_service, as_of)
|
||||
|
||||
if not as_of:
|
||||
return {
|
||||
"as_of": None,
|
||||
"quote_status": status,
|
||||
"indices": indices,
|
||||
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
|
||||
"amount": {"total": 0, "avg": 0},
|
||||
"boards": [],
|
||||
"limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []},
|
||||
"distribution": [],
|
||||
"trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0},
|
||||
"activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1},
|
||||
"radar": [],
|
||||
"emotion": {"score": 50, "label": "暂无"},
|
||||
"top_gainers": [],
|
||||
"top_losers": [],
|
||||
"turnover_leaders": [],
|
||||
"active_leaders": [],
|
||||
"concept_rank": {"leading": [], "lagging": []},
|
||||
"industry_rank": {"leading": [], "lagging": []},
|
||||
}
|
||||
|
||||
df = svc._load_enriched_for_date(as_of)
|
||||
if df.is_empty():
|
||||
rows: list[dict] = []
|
||||
else:
|
||||
cols = [
|
||||
"symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume",
|
||||
"vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down",
|
||||
"ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low",
|
||||
]
|
||||
df = df.select([c for c in cols if c in df.columns])
|
||||
rows = df.to_dicts()
|
||||
|
||||
# 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径
|
||||
if rows and "volume" in rows[0]:
|
||||
rows = [r for r in rows
|
||||
if (_finite(r.get("volume")) or 0) > 0
|
||||
or (_finite(r.get("change_pct")) or 0) != 0]
|
||||
|
||||
total = len(rows)
|
||||
up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0)
|
||||
down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0)
|
||||
flat = max(0, total - up - down)
|
||||
up_pct = up / total * 100 if total else 0
|
||||
down_pct = down / total * 100 if total else 0
|
||||
|
||||
amounts = [_finite(r.get("amount")) or 0 for r in rows]
|
||||
total_amount = sum(amounts)
|
||||
avg_amount = total_amount / total if total else 0
|
||||
|
||||
pct_values = [_finite(r.get("change_pct")) for r in rows]
|
||||
pct_values = [v for v in pct_values if v is not None]
|
||||
avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0
|
||||
median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0
|
||||
strong_up = sum(1 for v in pct_values if v >= 0.03)
|
||||
strong_down = sum(1 for v in pct_values if v <= -0.03)
|
||||
|
||||
limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0)
|
||||
broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up")))
|
||||
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
|
||||
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
|
||||
|
||||
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
|
||||
sealed_ready = False
|
||||
fake_up = 0
|
||||
fake_down = 0
|
||||
if depth_service:
|
||||
up_map = depth_service.get_sealed_map(as_of, is_down=False)
|
||||
down_map = depth_service.get_sealed_map(as_of, is_down=True)
|
||||
sealed_ready = bool(up_map or down_map) and depth_service.is_sealed_ready(as_of)
|
||||
if up_map:
|
||||
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
|
||||
if down_map:
|
||||
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
|
||||
if sealed_ready:
|
||||
limit_up = max(0, limit_up - fake_up)
|
||||
limit_down = max(0, limit_down - fake_down)
|
||||
|
||||
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
|
||||
|
||||
def above_ma_count(ma_key: str) -> int:
|
||||
return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0)))
|
||||
|
||||
above_ma5 = above_ma_count("ma5")
|
||||
above_ma20 = above_ma_count("ma20")
|
||||
above_ma60 = above_ma_count("ma60")
|
||||
new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0)))
|
||||
new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0)))
|
||||
|
||||
turnovers = [_finite(r.get("turnover_rate")) for r in rows]
|
||||
turnovers = [v for v in turnovers if v is not None]
|
||||
avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0
|
||||
high_turnover = sum(1 for v in turnovers if v >= 5)
|
||||
|
||||
boards_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = _board(str(r.get("symbol") or ""))
|
||||
item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0})
|
||||
item["count"] += 1
|
||||
change = _finite(r.get("change_pct")) or 0
|
||||
if change > 0:
|
||||
item["up"] += 1
|
||||
elif change < 0:
|
||||
item["down"] += 1
|
||||
item["amount"] += _finite(r.get("amount")) or 0
|
||||
boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True)
|
||||
for b in boards:
|
||||
count = b["count"] or 1
|
||||
b["up_pct"] = b["up"] / count * 100
|
||||
|
||||
tiers_map: dict[int, int] = {}
|
||||
for r in rows:
|
||||
n = int(_finite(r.get("consecutive_limit_ups")) or 0)
|
||||
if n > 0:
|
||||
tiers_map[n] = tiers_map.get(n, 0) + 1
|
||||
tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])]
|
||||
|
||||
index_changes = [_finite(r.get("change_pct")) for r in indices]
|
||||
index_changes = [v for v in index_changes if v is not None]
|
||||
avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0
|
||||
vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows]
|
||||
vol_ratios = [v for v in vol_ratios if v is not None]
|
||||
avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1
|
||||
high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5)
|
||||
|
||||
concept_rank = _dimension_rank(rows, repo, "concept")
|
||||
industry_rank = _dimension_rank(rows, repo, "industry", level=2)
|
||||
|
||||
strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0
|
||||
high_vol_pct = high_vol_ratio / total * 100 if total else 0
|
||||
strong_down_pct = strong_down / total * 100 if total else 0
|
||||
tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2)
|
||||
mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]]
|
||||
mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0)
|
||||
mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0
|
||||
mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50
|
||||
|
||||
radar = [
|
||||
{"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)},
|
||||
{"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)},
|
||||
{"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)},
|
||||
{"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)},
|
||||
{"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)},
|
||||
{"key": "mainline", "label": "主线", "value": mainline_score},
|
||||
]
|
||||
emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50
|
||||
if emotion_score >= 70:
|
||||
emotion_label = "强势"
|
||||
elif emotion_score >= 55:
|
||||
emotion_label = "偏暖"
|
||||
elif emotion_score >= 45:
|
||||
emotion_label = "震荡"
|
||||
elif emotion_score >= 30:
|
||||
emotion_label = "偏冷"
|
||||
else:
|
||||
emotion_label = "冰点"
|
||||
|
||||
return _json_safe({
|
||||
"as_of": str(as_of),
|
||||
"quote_status": status,
|
||||
"indices": indices,
|
||||
"breadth": {
|
||||
"total": total,
|
||||
"up": up,
|
||||
"down": down,
|
||||
"flat": flat,
|
||||
"up_pct": up_pct,
|
||||
"down_pct": down_pct,
|
||||
"avg_pct": avg_pct,
|
||||
"median_pct": median_pct,
|
||||
"strong_up": strong_up,
|
||||
"strong_down": strong_down,
|
||||
},
|
||||
"amount": {"total": total_amount, "avg": avg_amount},
|
||||
"boards": boards,
|
||||
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down},
|
||||
"distribution": _pct_band_rows(pct_values),
|
||||
"trend": {
|
||||
"above_ma5": above_ma5,
|
||||
"above_ma20": above_ma20,
|
||||
"above_ma60": above_ma60,
|
||||
"above_ma5_pct": above_ma5 / total * 100 if total else 0,
|
||||
"above_ma20_pct": above_ma20 / total * 100 if total else 0,
|
||||
"above_ma60_pct": above_ma60 / total * 100 if total else 0,
|
||||
"new_high": new_high,
|
||||
"new_low": new_low,
|
||||
},
|
||||
"activity": {
|
||||
"avg_turnover": avg_turnover,
|
||||
"high_turnover": high_turnover,
|
||||
"high_vol_ratio": high_vol_pct,
|
||||
"vol_ratio": avg_vol_ratio,
|
||||
},
|
||||
"radar": radar,
|
||||
"emotion": {"score": emotion_score, "label": emotion_label},
|
||||
"top_gainers": _top_rows(rows, "change_pct", True),
|
||||
"top_losers": _top_rows(rows, "change_pct", False),
|
||||
"turnover_leaders": _top_rows(rows, "amount", True),
|
||||
"active_leaders": _top_rows(rows, "turnover_rate", True),
|
||||
"concept_rank": concept_rank,
|
||||
"industry_rank": industry_rank,
|
||||
})
|
||||
@@ -0,0 +1,343 @@
|
||||
"""AI 大盘复盘 —— 流式 LLM 复盘生成。
|
||||
|
||||
复刻 stock_analyzer.py 的 NDJSON 流式协议(meta/delta/error/done),
|
||||
将「市场总览」聚合数据交给 LLM 生成结构化复盘报告。
|
||||
|
||||
数据来源:services.market_overview_builder.build_market_overview
|
||||
(与 GET /api/overview/market 同源,保证复盘与看板数据口径一致)。
|
||||
|
||||
流式协议(与 stock_analyzer / financial_analyzer 一致,前端解析无差异):
|
||||
{"type":"meta", "as_of", "emotion_score", "emotion_label", "summary"}
|
||||
{"type":"delta","content":"..."} 逐 chunk 文本
|
||||
{"type":"error","message":"..."}
|
||||
{"type":"done"}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import AsyncIterator
|
||||
|
||||
from app.services.market_overview_builder import build_market_overview
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 指数简称映射:摘要里用简称(上/深/创/科),全称太长列表放不下。与前端 INDEX_SHORT 对齐。
|
||||
_INDEX_SHORT = {
|
||||
"上证指数": "上",
|
||||
"深证成指": "深",
|
||||
"创业板指": "创",
|
||||
"科创综指": "科",
|
||||
"科创50": "科",
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# 系统提示词(市场策略师人格 + 固定七节模板)
|
||||
# ================================================================
|
||||
|
||||
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深市场策略师,擅长从指数结构、涨跌家数、连板梯队、板块轮动与资金情绪中提炼交易主线,产出可直接指导次日仓位与节奏的盘后复盘报告。
|
||||
|
||||
## 输出规范
|
||||
|
||||
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
|
||||
|
||||
### 1. 🎯 一句话定调(1-2 句)
|
||||
用一句话概括今日市场的**核心矛盾与状态**(如"放量普涨、情绪修复,主线围绕科技扩散"/"指数虚高、个股杀跌,赚钱效应冰点")。结尾用【明日基调:进攻 / 均衡 / 防守】给出明确倾向。
|
||||
|
||||
### 2. 📊 盘面总览
|
||||
- 三大指数(上证/深证/创业板)表现:谁强谁弱、量能配合
|
||||
- 涨跌家数、涨停/跌停/炸板结构、两市成交额(放量/缩量判断)
|
||||
- 情绪温度(强势/偏暖/震荡/偏冷/冰点)及一句话依据
|
||||
|
||||
### 3. 📈 指数结构
|
||||
谁在护盘、谁在拖累;指数是否同步;关键支撑/压力位(基于当日点位推断);是否存在量价背离。
|
||||
|
||||
### 4. 🔥 板块主线
|
||||
- 领涨板块:背后的逻辑(消息/业绩/资金/技术)、持续性判断、是否形成可交易主线
|
||||
- 领跌板块:风险信号、是否扩散
|
||||
- 连板梯队与投机情绪:最高连板、封板率、炸板率反映的资金激进程度
|
||||
|
||||
### 5. 💰 资金与情绪
|
||||
成交额结构(增量/存量)、市场宽度(上涨占比、站上均线占比)、量能指标(量比)解读;风险偏好是修复还是转弱。
|
||||
|
||||
### 6. 📰 消息催化
|
||||
结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动,明确区分"已兑现"与"待发酵"。**若无新闻数据,则直接从量价异动推断可能的催化逻辑并给出结论,不要标注"[推断]"之类的过程标签,更不要编造具体消息。**
|
||||
|
||||
### 7. 🎯 明日交易计划
|
||||
- 进攻 / 均衡 / 防守:基于今日盘面给出次日基调
|
||||
- 仓位区间建议(轻仓/半仓/重仓的粗略指引)
|
||||
- 关注方向(领涨延续 / 低吸 / 反包)与回避方向(高位滞涨 / 杀跌扩散)
|
||||
- 一个明确的触发失效条件(如"若上证跌破 X 点则转为防守")
|
||||
|
||||
### 8. ⚠️ 风险提示
|
||||
列出需要重点盯的风险点(如量能跟不上、外资流出、连板断层等)。末尾附一行:
|
||||
"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
|
||||
|
||||
## 分析准则(务必遵守)
|
||||
|
||||
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤或方法论。不要写"我先按...做结构化复盘""接下来看...""基于上述数据我认为"这类元话语——直接给结论。读者要的是复盘结果,不是你怎么推导出来的。
|
||||
1. **数据说话**:每个判断引用具体数值,严禁空泛套话("情绪回暖"必须改成"涨停 68 家较前日 +22,封板率 75%")
|
||||
2. **诚实中立**:看多就写多,看空就写空,不要骑墙;数据不支持时直言无法判断
|
||||
3. **结构优先**:先看指数同步性与量能结构,再看板块与情绪,最后才是消息
|
||||
4. **不重复数字**:正文负责解读表格数据背后的含义,不要照抄罗列已提供的大段原始数字
|
||||
5. **风险前置**:任何进攻建议都要配触发失效条件
|
||||
6. **简明实战**:用交易员能扫读的密度输出,总字数 1200-2000 字,重在可执行
|
||||
|
||||
现在请基于下方数据进行复盘。"""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 用户消息构建(精简切片,控制 token)
|
||||
# ================================================================
|
||||
|
||||
def _fmt_pct(v, suffix="%") -> str:
|
||||
if v is None:
|
||||
return "—"
|
||||
return f"{v:+.2f}{suffix}" if suffix else f"{v:.2f}"
|
||||
|
||||
|
||||
def _build_indices_block(overview: dict) -> str:
|
||||
"""指数行情精简块。"""
|
||||
indices = overview.get("indices") or []
|
||||
if not indices:
|
||||
return "(暂无指数)"
|
||||
lines = []
|
||||
for idx in indices:
|
||||
name = idx.get("name") or idx.get("symbol")
|
||||
price = idx.get("last_price")
|
||||
chg = idx.get("change_pct")
|
||||
price_s = f"{price:.2f}" if price is not None else "—"
|
||||
lines.append(f"- {name}: {price_s} {_fmt_pct(chg)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_breadth_block(overview: dict) -> str:
|
||||
b = overview.get("breadth") or {}
|
||||
amt = overview.get("amount") or {}
|
||||
lim = overview.get("limit") or {}
|
||||
tr = overview.get("trend") or {}
|
||||
act = overview.get("activity") or {}
|
||||
|
||||
total_amount = amt.get("total") or 0
|
||||
# 成交额单位换算为亿元(原始为元)
|
||||
amount_yi = total_amount / 1e8 if total_amount else 0
|
||||
|
||||
lines = [
|
||||
f"- 上涨/下跌/平盘: {b.get('up',0)} / {b.get('down',0)} / {b.get('flat',0)}"
|
||||
f" (上涨占比 {b.get('up_pct',0):.1f}%)",
|
||||
f"- 涨停/炸板/跌停: {lim.get('limit_up',0)} / {lim.get('broken',0)} / {lim.get('limit_down',0)}"
|
||||
f" (封板率 {lim.get('seal_rate',0):.0f}%, 最高连板 {lim.get('max_boards',0)})",
|
||||
]
|
||||
if lim.get("tiers"):
|
||||
tiers_str = "、".join(f"{t['boards']}板×{t['count']}" for t in lim["tiers"][:5])
|
||||
lines.append(f"- 连板梯队: {tiers_str}")
|
||||
lines.append(f"- 两市成交额: {amount_yi:.0f} 亿元")
|
||||
lines.append(
|
||||
f"- 均线站位: MA5 {tr.get('above_ma5_pct',0):.0f}% / "
|
||||
f"MA20 {tr.get('above_ma20_pct',0):.0f}% / MA60 {tr.get('above_ma60_pct',0):.0f}%"
|
||||
)
|
||||
lines.append(
|
||||
f"- 量能: 平均换手 {act.get('avg_turnover',0):.2f}%, "
|
||||
f"量比5日均 {act.get('vol_ratio',1):.2f}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_sector_block(rank: dict, label: str) -> str:
|
||||
"""板块排名精简块(领涨/领跌 top5)。"""
|
||||
if not rank:
|
||||
return f"### {label}\n(暂无数据)"
|
||||
def _fmt(items):
|
||||
if not items:
|
||||
return "—"
|
||||
return "、".join(
|
||||
f"{it.get('name')}({(it.get('avg_pct') or 0)*100:+.2f}%,领涨:{it.get('leader',{}).get('name','—')})"
|
||||
for it in items[:5]
|
||||
)
|
||||
return (
|
||||
f"- 领涨{label}: {_fmt(rank.get('leading'))}\n"
|
||||
f"- 领跌{label}: {_fmt(rank.get('lagging'))}"
|
||||
)
|
||||
|
||||
|
||||
def _build_emotion_block(overview: dict) -> str:
|
||||
emo = overview.get("emotion") or {}
|
||||
radar = overview.get("radar") or []
|
||||
score = emo.get("score", 50)
|
||||
label = emo.get("label", "—")
|
||||
lines = [f"- 情绪温度: {score} ({label})"]
|
||||
if radar:
|
||||
dims = "、".join(f"{r.get('label')}{r.get('value',0)}" for r in radar)
|
||||
lines.append(f"- 六维雷达: {dims}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
|
||||
"""构建用户消息:复盘日期 + 市场数据精简切片 + 新闻 + 关注点。"""
|
||||
as_of = overview.get("as_of") or "今日"
|
||||
|
||||
parts: list[str] = [
|
||||
f"复盘日期: {as_of}",
|
||||
"",
|
||||
"## 主要指数",
|
||||
_build_indices_block(overview),
|
||||
"",
|
||||
"## 盘面数据",
|
||||
_build_breadth_block(overview),
|
||||
"",
|
||||
"## 市场情绪",
|
||||
_build_emotion_block(overview),
|
||||
"",
|
||||
"## 概念板块排名",
|
||||
_build_sector_block(overview.get("concept_rank"), "概念"),
|
||||
"",
|
||||
"## 行业板块排名",
|
||||
_build_sector_block(overview.get("industry_rank"), "行业"),
|
||||
]
|
||||
|
||||
if news:
|
||||
news_lines = []
|
||||
for i, n in enumerate(news[:8], 1):
|
||||
title = (n.get("title") or "").strip()
|
||||
snippet = (n.get("snippet") or "").strip()
|
||||
source = (n.get("source") or "").strip()
|
||||
pub = (n.get("published_date") or "").strip()
|
||||
meta = " / ".join(p for p in (source, pub) if p)
|
||||
news_lines.append(f"{i}. {title} ({meta})\n {snippet}" if meta else f"{i}. {title}\n {snippet}")
|
||||
parts.extend(["", "## 近期市场新闻", "\n".join(news_lines)])
|
||||
else:
|
||||
parts.extend([
|
||||
"",
|
||||
"## 近期市场新闻",
|
||||
"(暂无新闻数据:本功能新闻检索能力将在后续版本接入。"
|
||||
"消息催化一节请直接从量价异动给出可能的催化逻辑结论,不要编造具体消息,也不要复述本说明。)",
|
||||
])
|
||||
|
||||
if focus.strip():
|
||||
parts.extend(["", f"本次复盘请特别关注: {focus.strip()}"])
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 摘要生成(供 meta 事件 / 历史报告 summary)
|
||||
# ================================================================
|
||||
|
||||
def _recap_summary(overview: dict) -> str:
|
||||
"""一句话摘要(供 meta 事件与历史列表展示)。
|
||||
|
||||
指数用简称(上/深/创/科),与前端摘要条一致,避免列表里全称放不下。
|
||||
"""
|
||||
indices = overview.get("indices") or []
|
||||
emo = overview.get("emotion") or {}
|
||||
lim = overview.get("limit") or {}
|
||||
amt = overview.get("amount") or {}
|
||||
total_amount = (amt.get("total") or 0) / 1e8
|
||||
|
||||
idx_str = "、".join(
|
||||
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','—')}) | "
|
||||
f"涨停{lim.get('limit_up',0)} | 成交{total_amount:.0f}亿"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 流式主入口
|
||||
# ================================================================
|
||||
|
||||
async def recap_market_stream(
|
||||
repo,
|
||||
quote_service=None,
|
||||
depth_service=None,
|
||||
as_of: date | None = None,
|
||||
focus: str = "",
|
||||
news: list[dict] | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式大盘复盘:yield 出每个 NDJSON 事件。
|
||||
|
||||
Args:
|
||||
repo: KlineRepository(必填)。
|
||||
quote_service / depth_service: 可选,数据装配依赖。
|
||||
as_of: 复盘日期,None 取最新有数据日。
|
||||
focus: 用户追加的复盘关注点。
|
||||
news: 预检索的新闻列表(P1 不传,留 None 走降级说明;P3 由 news_search 注入)。
|
||||
"""
|
||||
# 1. 装配市场总览
|
||||
overview = build_market_overview(repo, quote_service, depth_service, as_of)
|
||||
as_of_str = overview.get("as_of")
|
||||
|
||||
if not as_of_str:
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"message": "暂无市场数据,请先在「数据」页同步日 K 与指数后再复盘",
|
||||
}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
emo = overview.get("emotion") or {}
|
||||
|
||||
# 2. meta 事件(前端据此先渲染信号灯/看板)
|
||||
yield json.dumps({
|
||||
"type": "meta",
|
||||
"as_of": as_of_str,
|
||||
"emotion_score": emo.get("score", 50),
|
||||
"emotion_label": emo.get("label", "—"),
|
||||
"summary": _recap_summary(overview),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 3+4. 构建 prompt + 流式调用 LLM(整体 try-except,任何异常 yield error,避免前端卡死)
|
||||
try:
|
||||
from app.services.ai_provider import stream_ai_text
|
||||
|
||||
user_prompt = _build_user_prompt(overview, news or [], focus)
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4500,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("AI market recap failed for %s: %s", as_of_str, e)
|
||||
yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
|
||||
|
||||
async def recap_market_once(
|
||||
repo,
|
||||
quote_service=None,
|
||||
depth_service=None,
|
||||
as_of: date | None = None,
|
||||
focus: str = "",
|
||||
news: list[dict] | None = None,
|
||||
) -> tuple[str | None, dict]:
|
||||
"""非流式版本(供定时任务调用):累积全部 delta,返回 (content, meta)。
|
||||
|
||||
content 为完整 Markdown 文本;失败时为 None。
|
||||
meta 含 as_of / emotion_score / emotion_label / summary(即使失败也尽量回填)。
|
||||
"""
|
||||
content_parts: list[str] = []
|
||||
meta: dict = {"as_of": as_of.isoformat() if as_of else None}
|
||||
async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news):
|
||||
try:
|
||||
obj = json.loads(evt)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
t = obj.get("type")
|
||||
if t == "meta":
|
||||
meta = obj
|
||||
elif t == "delta":
|
||||
content_parts.append(obj.get("content", ""))
|
||||
elif t == "error":
|
||||
logger.warning("market recap error event: %s", obj.get("message"))
|
||||
return None, meta
|
||||
return "".join(content_parts), meta
|
||||
@@ -0,0 +1,92 @@
|
||||
"""AI 大盘复盘报告持久化存储。
|
||||
|
||||
与 stock_reports.py(个股分析报告)/ ai_reports.py(财务分析报告)完全独立 ——
|
||||
单独的文件、字段、上限,互不影响。刻意不复用,避免引入 kind 判别字段与分支
|
||||
(解耦 > 抽象)。
|
||||
|
||||
存储位置: data/user_data/ai_market_recaps.json (数组,按 created_at 降序)
|
||||
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
|
||||
|
||||
每条报告结构:
|
||||
{
|
||||
"id": "mkr_xxx", # 唯一 id(market-recap-report)
|
||||
"as_of": "2026-06-27", # 复盘日期
|
||||
"focus": "", # 用户追加的关心点(可为空)
|
||||
"content": "# ...markdown", # 报告正文
|
||||
"summary": "三大指数齐涨...", # 一句话摘要
|
||||
"emotion_score": 68, # 情绪分(0-100, 复盘生成时的市场情绪雷达均分)
|
||||
"emotion_label": "偏暖", # 情绪标签(强势/偏暖/震荡/偏冷/冰点)
|
||||
"created_at": "2026-06-27T15:35:00"
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_REPORTS = 20
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "ai_market_recaps.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def list_reports() -> list[dict]:
|
||||
"""返回全部报告(按 created_at 降序)。"""
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ai_market_recaps.json malformed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _save_all(reports: list[dict]) -> None:
|
||||
"""全量写入(裁剪到 MAX_REPORTS)。"""
|
||||
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
if len(reports) > MAX_REPORTS:
|
||||
reports = reports[:MAX_REPORTS]
|
||||
_path().write_text(
|
||||
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_report(report: dict) -> dict:
|
||||
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
|
||||
reports = list_reports()
|
||||
if not report.get("id"):
|
||||
report["id"] = f"mkr_{int(time.time() * 1000)}"
|
||||
if not report.get("created_at"):
|
||||
report["created_at"] = _now_iso()
|
||||
reports.append(report)
|
||||
_save_all(reports)
|
||||
logger.info("Market recap saved: %s (as_of=%s), total %d",
|
||||
report.get("id"), report.get("as_of"), len(reports))
|
||||
return report
|
||||
|
||||
|
||||
def delete_report(report_id: str) -> bool:
|
||||
"""删除指定报告。返回是否删除成功。"""
|
||||
reports = list_reports()
|
||||
before = len(reports)
|
||||
reports = [r for r in reports if r.get("id") != report_id]
|
||||
if len(reports) < before:
|
||||
_save_all(reports)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
@@ -1,12 +1,12 @@
|
||||
"""系统通知适配器 — 调用操作系统原生通知命令。
|
||||
"""系统通知适配器 — 三平台原生通知中心。
|
||||
|
||||
职责: 把后端产生的告警事件推送到操作系统通知中心。
|
||||
窗口最小化 / 被遮挡 / 后台运行时都能弹通知。
|
||||
窗口最小化 / 被遮挡 / 后台运行时都能弹通知 (不依赖前端 WebView)。
|
||||
|
||||
平台实现:
|
||||
- macOS: osascript (系统已内置)
|
||||
- Linux: notify-send (系统已内置)
|
||||
- Windows: 暂不支持原生通知中心 (无额外依赖实现)
|
||||
- Windows: winotify (进现代操作中心, 支持图标)
|
||||
- macOS: osascript (系统已内置, 无需额外依赖)
|
||||
- Linux: notify-send (系统已内置) / plyer 兜底
|
||||
|
||||
设计: 失败静默降级, 绝不因通知失败阻断告警主流程 (落盘 / SSE 推送)。
|
||||
通知去重不在本层做, 复用 MonitorRuleEngine 的 cooldown 逻辑。
|
||||
@@ -34,7 +34,15 @@ def _detect_backend() -> str | None:
|
||||
return _backend_cache if _backend_cache != "none" else None
|
||||
|
||||
backend = None
|
||||
if sys.platform == "darwin":
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import winotify # type: ignore[import-not-found] # noqa: F401
|
||||
|
||||
backend = "winotify"
|
||||
except ImportError:
|
||||
logger.debug("winotify 不可用, Windows 通知降级")
|
||||
backend = None
|
||||
elif sys.platform == "darwin":
|
||||
backend = "osascript"
|
||||
elif sys.platform.startswith("linux"):
|
||||
backend = "notify-send"
|
||||
@@ -71,6 +79,8 @@ def notify(title: str, message: str, icon: Path | None = None) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
if backend == "winotify":
|
||||
return _notify_winotify(title, message)
|
||||
if backend == "osascript":
|
||||
return _notify_osascript(title, message)
|
||||
if backend == "notify-send":
|
||||
@@ -82,6 +92,20 @@ def notify(title: str, message: str, icon: Path | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _notify_winotify(title: str, message: str) -> bool:
|
||||
"""Windows 通知 (winotify) — 进现代操作中心。"""
|
||||
from winotify import Notifier # type: ignore[import-not-found]
|
||||
|
||||
Notifier().create_notification(
|
||||
title=title,
|
||||
msg=message,
|
||||
# winotify 要求 duration 为 "short" 或 "long"
|
||||
duration="short",
|
||||
# 无可点击动作 (桌面版不实现"点击回到窗口"的复杂交互)
|
||||
).show()
|
||||
return True
|
||||
|
||||
|
||||
def _notify_osascript(title: str, message: str) -> bool:
|
||||
"""macOS 通知 (osascript) — 调用系统 AppleScript。"""
|
||||
# 转义双引号, 避免 AppleScript 注入
|
||||
|
||||
@@ -53,6 +53,29 @@ def get_realtime_quote_interval() -> float:
|
||||
return load().get("realtime_quote_interval", 10.0)
|
||||
|
||||
|
||||
def get_realtime_watchlist_symbols() -> list[str]:
|
||||
"""Free 档自选实时监控标的:直接取自选页前 5 个。"""
|
||||
try:
|
||||
from app.services import watchlist
|
||||
rows = watchlist.list_symbols()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("load watchlist for realtime failed: %s", e)
|
||||
return []
|
||||
out: list[str] = []
|
||||
for row in rows:
|
||||
symbol = str((row or {}).get("symbol") or "").strip().upper()
|
||||
if symbol and symbol not in out:
|
||||
out.append(symbol)
|
||||
if len(out) >= 5:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001
|
||||
"""兼容旧接口: Free 实时标的现在由自选页前 5 个决定。"""
|
||||
return get_realtime_watchlist_symbols()
|
||||
|
||||
|
||||
def set_realtime_quote_interval(interval: float) -> float:
|
||||
"""保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。"""
|
||||
current = load()
|
||||
@@ -71,6 +94,83 @@ def get_minute_sync_days() -> int:
|
||||
return max(1, min(30, load().get("minute_sync_days", 5)))
|
||||
|
||||
|
||||
# ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) =====
|
||||
|
||||
_ALLOWED_DATA_PROVIDERS = {"tickflow"}
|
||||
|
||||
|
||||
def get_daily_data_provider() -> str:
|
||||
provider = str(load().get("daily_data_provider", "tickflow") or "tickflow").lower()
|
||||
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
|
||||
|
||||
|
||||
def get_adj_factor_provider() -> str:
|
||||
provider = str(load().get("adj_factor_provider", "same_as_daily") or "same_as_daily").lower()
|
||||
if provider == "same_as_daily":
|
||||
return provider
|
||||
return provider if provider in _ALLOWED_DATA_PROVIDERS else "same_as_daily"
|
||||
|
||||
|
||||
def get_minute_data_provider() -> str:
|
||||
provider = str(load().get("minute_data_provider", "tickflow") or "tickflow").lower()
|
||||
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
|
||||
|
||||
|
||||
def get_realtime_data_provider() -> str:
|
||||
# 盘中实时现阶段仅支持 TickFlow。
|
||||
return "tickflow"
|
||||
|
||||
|
||||
# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) =====
|
||||
|
||||
def get_pipeline_pull_a_share() -> bool:
|
||||
"""A 股日K固定拉取。"""
|
||||
return True
|
||||
|
||||
|
||||
def get_pipeline_pull_etf() -> bool:
|
||||
"""是否拉取 ETF 日K。默认 False(标的多,首次较慢)。"""
|
||||
return load().get("pipeline_pull_etf", False)
|
||||
|
||||
|
||||
def get_pipeline_pull_index() -> bool:
|
||||
"""是否拉取指数日K。默认 True。"""
|
||||
return load().get("pipeline_pull_index", True)
|
||||
|
||||
|
||||
_PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index")
|
||||
|
||||
|
||||
def get_pipeline_pull_types() -> dict:
|
||||
"""返回三个拉取开关的当前值。"""
|
||||
return {
|
||||
"pipeline_pull_a_share": get_pipeline_pull_a_share(),
|
||||
"pipeline_pull_etf": get_pipeline_pull_etf(),
|
||||
"pipeline_pull_index": get_pipeline_pull_index(),
|
||||
}
|
||||
|
||||
|
||||
def set_pipeline_pull_types(cfg: dict) -> dict:
|
||||
"""批量保存拉取开关。只接受白名单内的布尔字段。"""
|
||||
updates = {
|
||||
k: bool(v) for k, v in cfg.items()
|
||||
if k in _PIPELINE_PULL_KEYS and v is not None
|
||||
}
|
||||
save(updates)
|
||||
return get_pipeline_pull_types()
|
||||
|
||||
|
||||
def get_pipeline_index_symbols() -> str:
|
||||
"""指数自定义拉取代码(逗号/换行/空格分隔)。空串表示全量。"""
|
||||
return str(load().get("pipeline_index_symbols", "") or "").strip()
|
||||
|
||||
|
||||
def set_pipeline_index_symbols(symbols: str) -> str:
|
||||
"""保存指数自定义代码,返回规范化后的字符串。"""
|
||||
save({"pipeline_index_symbols": symbols})
|
||||
return get_pipeline_index_symbols()
|
||||
|
||||
|
||||
def get_pipeline_schedule() -> dict:
|
||||
"""返回盘后管道调度时间 {"hour": 15, "minute": 30}。"""
|
||||
d = load().get("pipeline_schedule", {"hour": 15, "minute": 30})
|
||||
@@ -165,6 +265,73 @@ def set_depth_finalize_time(hour: int, minute: int) -> dict:
|
||||
return {"hour": h, "minute": m}
|
||||
|
||||
|
||||
# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位)
|
||||
# 多选: 不推送 = 空数组, 而非 'none'
|
||||
REVIEW_PUSH_CHANNELS = {"feishu"}
|
||||
|
||||
|
||||
def get_review_schedule() -> dict:
|
||||
"""定时复盘调度 {"enabled": False, "hour": 15, "minute": 10}。默认关闭。
|
||||
|
||||
A股 15:00 收盘, 默认时间设为 15:10(收盘后即时复盘), 强制下限 15:00。
|
||||
"""
|
||||
d = load().get("review_schedule", {"enabled": False, "hour": 15, "minute": 10})
|
||||
return {
|
||||
"enabled": bool(d.get("enabled", False)),
|
||||
"hour": d.get("hour", 15),
|
||||
"minute": d.get("minute", 10),
|
||||
}
|
||||
|
||||
|
||||
def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict:
|
||||
"""保存定时复盘调度。强制时间下限 15:00(A股收盘)。
|
||||
|
||||
enabled=False 时时间仍保存(下次开启可沿用), 但调度器不会注册 job。
|
||||
"""
|
||||
h = max(0, min(23, hour))
|
||||
m = max(0, min(59, minute))
|
||||
# 下限 15:00: A股 15:00 收盘, 收盘后才有当日完整数据复盘
|
||||
if h * 60 + m < 15 * 60:
|
||||
h, m = 15, 0
|
||||
save({"review_schedule": {"enabled": bool(enabled), "hour": h, "minute": m}})
|
||||
return {"enabled": bool(enabled), "hour": h, "minute": m}
|
||||
|
||||
|
||||
def get_review_push_channels() -> list[str]:
|
||||
"""复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。
|
||||
|
||||
与 review_schedule / 实时行情完全独立, 常驻可单独设置。
|
||||
空列表 = 不推送; ['feishu'] = 推送到飞书(复用监控中心全局 feishu_webhook_url/secret)。
|
||||
|
||||
向后兼容:
|
||||
- 老多版本单选 review_push_channel=='feishu' → ['feishu']
|
||||
- 更老布尔 review_push_enabled==True → ['feishu']
|
||||
"""
|
||||
d = load()
|
||||
raw = d.get("review_push_channels")
|
||||
if isinstance(raw, list):
|
||||
return [c for c in raw if c in REVIEW_PUSH_CHANNELS]
|
||||
# 兼容老单选字符串
|
||||
if d.get("review_push_channel") == "feishu":
|
||||
return ["feishu"]
|
||||
# 兼容更老布尔开关
|
||||
if d.get("review_push_enabled") is True:
|
||||
return ["feishu"]
|
||||
return []
|
||||
|
||||
|
||||
def set_review_push_channels(channels: list[str]) -> list[str]:
|
||||
"""保存复盘推送渠道(多选)。过滤白名单外的值、去重、保序。空列表 = 不推送。"""
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
for c in channels or []:
|
||||
if c in REVIEW_PUSH_CHANNELS and c not in seen:
|
||||
seen.add(c)
|
||||
cleaned.append(c)
|
||||
save({"review_push_channels": cleaned})
|
||||
return cleaned
|
||||
|
||||
|
||||
|
||||
# ===== 实时监控 =====
|
||||
|
||||
@@ -178,6 +345,59 @@ SSE_REFRESH_PAGES_DEFAULT = {
|
||||
SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"]
|
||||
|
||||
|
||||
# ===== 盘中实时行情范围 (独立于盘后管道范围) =====
|
||||
|
||||
|
||||
def get_realtime_pull_stock() -> bool:
|
||||
return load().get("realtime_pull_stock", True)
|
||||
|
||||
|
||||
def get_realtime_pull_etf() -> bool:
|
||||
# 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。
|
||||
return load().get("realtime_pull_etf", False)
|
||||
|
||||
|
||||
def get_realtime_pull_index() -> bool:
|
||||
return load().get("realtime_pull_index", True)
|
||||
|
||||
|
||||
def get_realtime_index_mode() -> str:
|
||||
mode = str(load().get("realtime_index_mode", "core") or "core").lower()
|
||||
return mode if mode in {"core", "all"} else "core"
|
||||
|
||||
|
||||
def get_realtime_index_symbols() -> list[str]:
|
||||
stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
|
||||
if isinstance(stored, str):
|
||||
import re
|
||||
stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()]
|
||||
return [str(s) for s in stored if str(s).strip()]
|
||||
|
||||
|
||||
def set_realtime_quote_scope(cfg: dict) -> dict:
|
||||
updates = {}
|
||||
for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"):
|
||||
if key in cfg and cfg[key] is not None:
|
||||
updates[key] = bool(cfg[key])
|
||||
if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}:
|
||||
updates["realtime_index_mode"] = cfg["realtime_index_mode"]
|
||||
if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None:
|
||||
updates["realtime_index_symbols"] = cfg["realtime_index_symbols"]
|
||||
if updates:
|
||||
save(updates)
|
||||
return get_realtime_quote_scope()
|
||||
|
||||
|
||||
def get_realtime_quote_scope() -> dict:
|
||||
return {
|
||||
"realtime_pull_stock": get_realtime_pull_stock(),
|
||||
"realtime_pull_etf": get_realtime_pull_etf(),
|
||||
"realtime_pull_index": get_realtime_pull_index(),
|
||||
"realtime_index_mode": get_realtime_index_mode(),
|
||||
"realtime_index_symbols": get_realtime_index_symbols(),
|
||||
}
|
||||
|
||||
|
||||
def get_sse_refresh_pages() -> dict[str, bool]:
|
||||
"""返回每个页面的 SSE 刷新开关。"""
|
||||
stored = load().get("sse_refresh_pages", {})
|
||||
@@ -216,6 +436,43 @@ def set_system_notify_enabled(enabled: bool) -> bool:
|
||||
return bool(enabled)
|
||||
|
||||
|
||||
def get_feishu_webhook_url() -> str:
|
||||
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
|
||||
return load().get("feishu_webhook_url", "")
|
||||
|
||||
|
||||
def get_feishu_webhook_secret() -> str:
|
||||
"""飞书自定义机器人签名密钥 — 机器人启用「签名校验」时必填, 留空表示不验签。"""
|
||||
return load().get("feishu_webhook_secret", "")
|
||||
|
||||
|
||||
def set_feishu_webhook_url(url: str) -> str:
|
||||
"""保存飞书 Webhook 地址。传入空串表示清空配置。"""
|
||||
save({"feishu_webhook_url": str(url or "").strip()})
|
||||
return get_feishu_webhook_url()
|
||||
|
||||
|
||||
def set_feishu_webhook_secret(secret: str) -> str:
|
||||
"""保存飞书签名密钥。传入空串表示不验签。"""
|
||||
save({"feishu_webhook_secret": str(secret or "").strip()})
|
||||
return get_feishu_webhook_secret()
|
||||
|
||||
|
||||
def get_webhook_enabled_default() -> bool:
|
||||
"""新建监控规则时是否默认勾选「飞书推送」。
|
||||
|
||||
数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定。
|
||||
此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。
|
||||
"""
|
||||
return load().get("webhook_enabled_default", False)
|
||||
|
||||
|
||||
def set_webhook_enabled_default(enabled: bool) -> bool:
|
||||
"""保存飞书推送默认勾选态。"""
|
||||
save({"webhook_enabled_default": bool(enabled)})
|
||||
return get_webhook_enabled_default()
|
||||
|
||||
|
||||
def get_screener_auto_run() -> bool:
|
||||
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
|
||||
return load().get("screener_auto_run", True)
|
||||
@@ -311,3 +568,18 @@ def set_onboarding_completed(done: bool = True) -> bool:
|
||||
"""标记首次使用向导完成状态。"""
|
||||
save({"onboarding_completed": bool(done)})
|
||||
return bool(done)
|
||||
|
||||
|
||||
# ===== 财务数据同步时间(持久化,重启不丢失) =====
|
||||
# 结构: { "metrics": "2026-06-25T10:00:00+08:00", "income": ..., ... }
|
||||
|
||||
def get_financial_sync_times() -> dict[str, str]:
|
||||
"""返回各财务表的最后同步时间(ISO 字符串)。未同步过的表不在返回值中。"""
|
||||
return load().get("financial_sync_times", {}) or {}
|
||||
|
||||
|
||||
def set_financial_sync_time(table: str, iso_ts: str) -> None:
|
||||
"""更新单张财务表的最后同步时间(合并写入,不清除其他表)。"""
|
||||
times = get_financial_sync_times()
|
||||
times[table] = iso_ts
|
||||
save({"financial_sync_times": times})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
集中管理全市场行情拉取 + enriched 缓存,供盘中选股、自选股等所有模块复用。
|
||||
|
||||
架构:
|
||||
- 后台线程轮询数据源 get_by_universes(["CN_Equity_A", "CN_Index"])
|
||||
- 后台线程轮询 TickFlow get_by_universes(["CN_Equity_A", "CN_Index"])
|
||||
- 拉取行情 → 写 kline_daily (不复权) + 增量计算 enriched → 写盘 + 更新缓存
|
||||
- _enriched_cache 是唯一的盘中数据源 (OHLCV + 全套技术指标)
|
||||
- _live_agg_cache 是递推状态 (只加载一次, 盘中不变)
|
||||
@@ -43,6 +43,7 @@ class QuoteService:
|
||||
"expert": 1.0,
|
||||
"pro": 2.0,
|
||||
"starter": 3.0,
|
||||
"free": 6.0,
|
||||
}
|
||||
DEFAULT_INTERVAL = 10.0
|
||||
MAX_INTERVAL = 60.0
|
||||
@@ -59,6 +60,10 @@ class QuoteService:
|
||||
self._depth_update_event = threading.Event() # SSE 通知: depth 五档修正后 set (刷新连板梯队)
|
||||
self._pending_alerts: list[dict] = [] # 待推送的告警
|
||||
self._max_pending_alerts: int = 1000 # 背压上限: 超出丢弃最旧
|
||||
# 复盘进度 SSE 通道: 定时复盘流式生成时, 把 meta/delta/done 事件推给开着页面的前端
|
||||
self._review_event = threading.Event() # SSE 通知: 有复盘进度事件时 set
|
||||
self._pending_review: list[str] = [] # 待推送的复盘事件(JSON 字符串)
|
||||
self._max_pending_review: int = 200 # 背压上限: 超出丢弃最旧
|
||||
self._strategy_monitor = None # 延迟注入
|
||||
self._app_state = None # 延迟注入 (FastAPI app.state)
|
||||
|
||||
@@ -68,6 +73,7 @@ class QuoteService:
|
||||
self._fetched_at: float = 0.0 # 拉取完成的 Unix 时间戳 (毫秒)
|
||||
self._symbol_count: int = 0
|
||||
self._index_symbol_count: int = 0
|
||||
self._etf_symbol_count: int = 0
|
||||
self._index_quotes_cache: pl.DataFrame | None = None
|
||||
|
||||
# ================================================================
|
||||
@@ -102,11 +108,11 @@ class QuoteService:
|
||||
def enable(self) -> bool:
|
||||
"""开启自动行情 (不立即启动线程,等下一个交易时段)。
|
||||
|
||||
none/free 档无实时行情权限,拒绝开启并返回 False;
|
||||
starter+ 正常启动。返回值表示是否真正开启。
|
||||
none 档无实时行情权限,拒绝开启并返回 False;
|
||||
free 档开启自选股实时,starter+ 开启全市场实时。返回值表示是否真正开启。
|
||||
"""
|
||||
if not self.is_realtime_allowed():
|
||||
logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限")
|
||||
logger.warning("实时行情开启被拒:当前档位(none)无实时行情权限")
|
||||
return False
|
||||
self._enabled = True
|
||||
self._save_enabled(True)
|
||||
@@ -126,14 +132,14 @@ class QuoteService:
|
||||
def boot_check(self) -> None:
|
||||
"""启动时检查 preferences,若 enabled 则自动启动。
|
||||
|
||||
none/free 档无实时行情权限:即使 preferences 标记为 enabled,
|
||||
none 档无实时行情权限:即使 preferences 标记为 enabled,
|
||||
也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)。
|
||||
"""
|
||||
from app.services import preferences
|
||||
if not self.is_realtime_allowed():
|
||||
if preferences.get_realtime_quotes_enabled():
|
||||
self._save_enabled(False)
|
||||
logger.info("实时行情未启动:当前档位(none/free)无实时行情权限")
|
||||
logger.info("实时行情未启动:当前档位(none)无实时行情权限")
|
||||
return
|
||||
if preferences.get_realtime_quotes_enabled():
|
||||
self.start()
|
||||
@@ -188,6 +194,34 @@ class QuoteService:
|
||||
self._pending_alerts = []
|
||||
return alerts
|
||||
|
||||
# ================================================================
|
||||
# 复盘进度 SSE 通道 — 定时复盘流式生成时, 把事件实时推给前端
|
||||
# ================================================================
|
||||
def push_review_event(self, event_json: str) -> None:
|
||||
"""追加一条复盘进度事件(JSON 字符串), 并唤醒 SSE generator。
|
||||
|
||||
事件格式与 recap_market_stream 的产出一致(meta/delta/error/done),
|
||||
前端 reviewStore 直接消费。背压: 超过上限丢弃最旧(复盘流几百条 delta, 200 够用)。
|
||||
"""
|
||||
with self._lock:
|
||||
self._pending_review.append(event_json)
|
||||
if len(self._pending_review) > self._max_pending_review:
|
||||
overflow = len(self._pending_review) - self._max_pending_review
|
||||
self._pending_review = self._pending_review[overflow:]
|
||||
self._review_event.set()
|
||||
|
||||
def wait_for_review(self, timeout: float = 30.0) -> bool:
|
||||
"""阻塞等待复盘进度事件 (供 SSE 线程使用)。"""
|
||||
self._review_event.clear()
|
||||
return self._review_event.wait(timeout=timeout)
|
||||
|
||||
def pop_review_events(self) -> list[str]:
|
||||
"""取走所有待推送的复盘事件 (线程安全)。"""
|
||||
with self._lock:
|
||||
events = self._pending_review
|
||||
self._pending_review = []
|
||||
return events
|
||||
|
||||
# ================================================================
|
||||
# 档位感知间隔限制
|
||||
# ================================================================
|
||||
@@ -199,13 +233,19 @@ class QuoteService:
|
||||
return tier_label().split()[0].split("+")[0].strip().lower()
|
||||
|
||||
@classmethod
|
||||
def is_realtime_allowed(cls) -> bool:
|
||||
"""当前档位是否允许使用实时行情。
|
||||
def realtime_mode(cls) -> str:
|
||||
"""当前实时行情模式: none / watchlist / full_market。"""
|
||||
tier = cls._current_tier()
|
||||
if tier == "none":
|
||||
return "none"
|
||||
if tier == "free":
|
||||
return "watchlist"
|
||||
return "full_market"
|
||||
|
||||
none/free 档走 free-api 服务器,无实时行情权限 → 不允许;
|
||||
starter+ 付费档走付费端点,有实时行情 → 允许。
|
||||
"""
|
||||
return cls._current_tier() not in ("none", "free")
|
||||
@classmethod
|
||||
def is_realtime_allowed(cls) -> bool:
|
||||
"""当前档位是否允许使用实时行情。"""
|
||||
return cls.realtime_mode() != "none"
|
||||
|
||||
@classmethod
|
||||
def _tier_min_interval(cls) -> float:
|
||||
@@ -251,7 +291,7 @@ class QuoteService:
|
||||
return df
|
||||
|
||||
def get_index_quotes(self, symbols: list[str] | None = None) -> pl.DataFrame:
|
||||
"""返回实时指数行情缓存。不会触发数据源请求。"""
|
||||
"""返回实时指数行情缓存。不会触发 TickFlow 请求。"""
|
||||
with self._lock:
|
||||
df = self._index_quotes_cache.clone() if self._index_quotes_cache is not None else pl.DataFrame()
|
||||
if df.is_empty():
|
||||
@@ -262,13 +302,19 @@ class QuoteService:
|
||||
|
||||
def status(self) -> dict:
|
||||
"""返回行情服务状态。"""
|
||||
from app.services import preferences
|
||||
age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1
|
||||
mode = self.realtime_mode()
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"running": self._running,
|
||||
"mode": mode,
|
||||
"realtime_allowed": mode != "none",
|
||||
"watchlist_symbol_count": len(preferences.get_realtime_watchlist_symbols()),
|
||||
"interval_s": self._interval,
|
||||
"symbol_count": self._symbol_count,
|
||||
"index_symbol_count": self._index_symbol_count,
|
||||
"etf_symbol_count": self._etf_symbol_count,
|
||||
"quote_age_ms": round(age, 0) if age >= 0 else None,
|
||||
"is_trading_hours": self._is_trading_hours(),
|
||||
"last_fetch_ms": round(self._fetched_at, 0) if self._fetched_at else None,
|
||||
@@ -299,17 +345,47 @@ class QuoteService:
|
||||
waited += 0.5
|
||||
|
||||
def _fetch_quotes(self) -> None:
|
||||
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。"""
|
||||
from app.tickflow.client import get_client
|
||||
"""按当前档位拉取行情。"""
|
||||
if self.realtime_mode() == "watchlist":
|
||||
self._fetch_watchlist_quotes()
|
||||
return
|
||||
self._fetch_full_market_quotes()
|
||||
|
||||
tf = get_client()
|
||||
def _fetch_full_market_quotes(self) -> None:
|
||||
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。"""
|
||||
from app.tickflow.client import get_paid_realtime_client
|
||||
|
||||
tf = get_paid_realtime_client()
|
||||
if tf is None:
|
||||
logger.warning("实时行情拉取失败:未配置付费服务器 API Key")
|
||||
return
|
||||
t0 = time.perf_counter()
|
||||
now_ts = time.perf_counter()
|
||||
|
||||
try:
|
||||
from app.services import preferences
|
||||
all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set()
|
||||
all_index_symbols.update(self.CORE_INDEX_SYMBOLS)
|
||||
resp = tf.quotes.get_by_universes(universes=["CN_Equity_A", "CN_Index"])
|
||||
core_index_symbols = set(preferences.get_realtime_index_symbols() or self.CORE_INDEX_SYMBOLS)
|
||||
all_index_symbols.update(core_index_symbols)
|
||||
all_etf_symbols = set()
|
||||
if self._repo:
|
||||
etf_inst = self._repo.get_etf_instruments()
|
||||
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
|
||||
all_etf_symbols = set(etf_inst["symbol"].cast(pl.Utf8).to_list())
|
||||
|
||||
universes: list[str] = []
|
||||
if preferences.get_realtime_pull_stock():
|
||||
universes.append("CN_Equity_A")
|
||||
if preferences.get_realtime_pull_etf() and all_etf_symbols:
|
||||
universes.append("CN_ETF")
|
||||
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "all":
|
||||
universes.append("CN_Index")
|
||||
|
||||
resp = []
|
||||
if universes:
|
||||
resp.extend(tf.quotes.get_by_universes(universes=universes) or [])
|
||||
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core":
|
||||
resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or [])
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("行情拉取失败: %s", e)
|
||||
return
|
||||
@@ -349,7 +425,11 @@ class QuoteService:
|
||||
})
|
||||
|
||||
index_records = [r for r in records if r.get("symbol") in all_index_symbols]
|
||||
stock_records = [r for r in records if r.get("symbol") not in all_index_symbols]
|
||||
etf_records = [r for r in records if r.get("symbol") in all_etf_symbols]
|
||||
stock_records = [
|
||||
r for r in records
|
||||
if r.get("symbol") not in all_index_symbols and r.get("symbol") not in all_etf_symbols
|
||||
]
|
||||
|
||||
fetch_ms = (time.perf_counter() - t0) * 1000
|
||||
fetched_at = time.time() * 1000
|
||||
@@ -361,9 +441,10 @@ class QuoteService:
|
||||
self._fetched_at = fetched_at
|
||||
self._symbol_count = len(stock_records)
|
||||
self._index_symbol_count = len(index_records)
|
||||
self._etf_symbol_count = len(etf_records)
|
||||
self._index_quotes_cache = self._build_index_quotes(index_records)
|
||||
|
||||
logger.info("行情刷新: %d 只股票, %d 只指数, 耗时 %.0fms", len(stock_records), len(index_records), fetch_ms)
|
||||
logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms)
|
||||
|
||||
# ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ----
|
||||
daily_df = self._build_daily(stock_records)
|
||||
@@ -373,12 +454,22 @@ class QuoteService:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("日K写盘失败: %s", e)
|
||||
|
||||
etf_daily_df = self._build_daily(etf_records)
|
||||
if not etf_daily_df.is_empty() and self._repo:
|
||||
try:
|
||||
self._repo.flush_live_daily_asset("etf", etf_daily_df)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ETF 日K写盘失败: %s", e)
|
||||
|
||||
# ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ----
|
||||
quote_extra = self._build_quote_extra(stock_records)
|
||||
etf_quote_extra = self._build_quote_extra(etf_records)
|
||||
|
||||
# ---- 增量计算 enriched + 写盘 + 更新缓存 ----
|
||||
if not daily_df.is_empty() and self._repo:
|
||||
self._flush_live_enriched(daily_df, quote_extra)
|
||||
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock")
|
||||
if not etf_daily_df.is_empty() and self._repo:
|
||||
self._flush_live_enriched(etf_daily_df, etf_quote_extra, asset_type="etf")
|
||||
|
||||
# ---- 通知 SSE ----
|
||||
self._update_event.set()
|
||||
@@ -386,6 +477,87 @@ class QuoteService:
|
||||
# ---- 策略监控 + 告警评估 ----
|
||||
self._evaluate_monitors(daily_df, quote_extra)
|
||||
|
||||
def _fetch_watchlist_quotes(self) -> None:
|
||||
"""Free 档自选股实时: 只拉取最多 5 个 symbols。"""
|
||||
from app.services import preferences
|
||||
from app.tickflow.client import get_paid_realtime_client
|
||||
|
||||
symbols = preferences.get_realtime_watchlist_symbols()
|
||||
if not symbols:
|
||||
logger.info("自选实时未配置标的, 跳过行情拉取")
|
||||
return
|
||||
|
||||
tf = get_paid_realtime_client()
|
||||
if tf is None:
|
||||
logger.warning("自选实时拉取失败:未配置付费服务器 API Key")
|
||||
return
|
||||
|
||||
t0 = time.perf_counter()
|
||||
now_ts = time.perf_counter()
|
||||
try:
|
||||
resp = tf.quotes.get(symbols=symbols) or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("自选实时拉取失败: %s", e)
|
||||
return
|
||||
|
||||
if not resp:
|
||||
logger.warning("自选实时行情数据为空")
|
||||
return
|
||||
|
||||
records = []
|
||||
for q in resp:
|
||||
ext = q.get("ext") or {}
|
||||
last_price = q.get("last_price")
|
||||
prev_close = q.get("prev_close")
|
||||
change_amount = ext.get("change_amount")
|
||||
change_pct = ext.get("change_pct")
|
||||
if change_amount is None and last_price is not None and prev_close is not None:
|
||||
change_amount = float(last_price) - float(prev_close)
|
||||
if change_pct is None and change_amount is not None and prev_close not in (None, 0):
|
||||
change_pct = float(change_amount) / float(prev_close) * 100
|
||||
records.append({
|
||||
"symbol": q.get("symbol"),
|
||||
"name": q.get("name") or ext.get("name"),
|
||||
"last_price": last_price,
|
||||
"prev_close": prev_close,
|
||||
"open": q.get("open"),
|
||||
"high": q.get("high"),
|
||||
"low": q.get("low"),
|
||||
"volume": q.get("volume"),
|
||||
"amount": q.get("amount"),
|
||||
"change_pct": change_pct,
|
||||
"change_amount": change_amount,
|
||||
"amplitude": ext.get("amplitude"),
|
||||
"turnover_rate": ext.get("turnover_rate"),
|
||||
"timestamp": q.get("timestamp"),
|
||||
"session": q.get("session"),
|
||||
})
|
||||
|
||||
fetch_ms = (time.perf_counter() - t0) * 1000
|
||||
fetched_at = time.time() * 1000
|
||||
with self._lock:
|
||||
self._fetch_time = now_ts
|
||||
self._fetch_ms = fetch_ms
|
||||
self._fetched_at = fetched_at
|
||||
self._symbol_count = len(records)
|
||||
self._index_symbol_count = 0
|
||||
self._etf_symbol_count = 0
|
||||
self._index_quotes_cache = None
|
||||
|
||||
logger.info("自选实时刷新: %d 只股票, 耗时 %.0fms", len(records), fetch_ms)
|
||||
|
||||
daily_df = self._build_daily(records)
|
||||
quote_extra = self._build_quote_extra(records)
|
||||
if not daily_df.is_empty() and self._repo:
|
||||
try:
|
||||
self._repo.merge_live_daily_asset("stock", daily_df)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("自选实时日K写盘失败: %s", e)
|
||||
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock", merge=True)
|
||||
|
||||
self._update_event.set()
|
||||
self._evaluate_monitors(daily_df, quote_extra)
|
||||
|
||||
# ================================================================
|
||||
# 工具
|
||||
# ================================================================
|
||||
@@ -495,6 +667,8 @@ class QuoteService:
|
||||
return
|
||||
|
||||
all_alerts: list[dict] = []
|
||||
rule_events: list[dict] = []
|
||||
engine = None
|
||||
|
||||
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
|
||||
if self._app_state:
|
||||
@@ -511,7 +685,11 @@ class QuoteService:
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("name_map 构建失败 (不影响监控): %s", e)
|
||||
rule_events = engine.evaluate(enriched_today)
|
||||
# 连板梯队封单监控: 有 ladder 规则时, 从 depth_service 注入封单量到 enriched
|
||||
eval_df = enriched_today
|
||||
if engine.has_rule_type("ladder"):
|
||||
eval_df = self._inject_sealed_vol(enriched_today, enriched_date)
|
||||
rule_events = engine.evaluate(eval_df)
|
||||
if rule_events:
|
||||
# 落盘到 alerts.jsonl
|
||||
try:
|
||||
@@ -535,11 +713,13 @@ class QuoteService:
|
||||
"change_pct": ev["change_pct"],
|
||||
"signals": ev["signals"],
|
||||
"severity": ev.get("severity", "info"),
|
||||
"conditions": ev.get("conditions") or [],
|
||||
"logic": ev.get("logic") or "and",
|
||||
})
|
||||
|
||||
# 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算)
|
||||
if self._enabled and self._app_state:
|
||||
self._refresh_strategy_cache(enriched_today, enriched_date)
|
||||
# 策略页实时回显: 不写文件 (实时行情每轮更新 enriched, 写文件会被 read_cache
|
||||
# 的 mtime 校验判过期, 反复读不到)。监控引擎本轮已算出的结果存在内存
|
||||
# (latest_strategy_results), 由 /api/screener/cached 端点直接叠加读取。
|
||||
|
||||
# 推入待推送队列 + 通知 SSE (含背压保护)
|
||||
if all_alerts:
|
||||
@@ -556,9 +736,95 @@ class QuoteService:
|
||||
# cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。
|
||||
self._maybe_send_system_notifications(all_alerts)
|
||||
|
||||
# Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。
|
||||
# 紧随系统通知, 同样静默降级不阻断主流程。
|
||||
if rule_events:
|
||||
self._maybe_send_webhook(rule_events, engine)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("监控评估失败: %s", e)
|
||||
|
||||
def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame:
|
||||
"""从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。
|
||||
|
||||
涨停封单(买一量) + 跌停封单(卖一量)合并, 供 ladder 规则评估。
|
||||
depth 未就绪时返回原 df (不注入, ladder 规则安全降级不触发)。
|
||||
"""
|
||||
try:
|
||||
depth_svc = getattr(self._app_state, "depth_service", None)
|
||||
if not depth_svc:
|
||||
return enriched_today
|
||||
# enriched_date 可能是 date 或字符串, 统一为 date
|
||||
from datetime import date as date_cls
|
||||
target_date = enriched_date if isinstance(enriched_date, date_cls) else date_cls.fromisoformat(str(enriched_date))
|
||||
# 取涨停 + 跌停封单, 合并 {symbol: vol}
|
||||
up_map = depth_svc.get_sealed_map(target_date, is_down=False)
|
||||
down_map = depth_svc.get_sealed_map(target_date, is_down=True)
|
||||
sealed: dict[str, int] = {}
|
||||
for m in (up_map, down_map):
|
||||
for sym, info in m.items():
|
||||
vol = (info or {}).get("vol")
|
||||
if vol and vol > 0:
|
||||
sealed[sym] = vol # 后者覆盖前者 (同 symbol 不可能在涨跌停都封单)
|
||||
if not sealed:
|
||||
return enriched_today
|
||||
# 构造 (symbol, _sealed_vol) DataFrame, join 到 enriched 副本
|
||||
sealed_df = pl.DataFrame({
|
||||
"symbol": list(sealed.keys()),
|
||||
"_sealed_vol": list(sealed.values()),
|
||||
})
|
||||
# 若已有残留列先移除 (避免重复 join 报错)
|
||||
df = enriched_today.drop("_sealed_vol") if "_sealed_vol" in enriched_today.columns else enriched_today
|
||||
return df.join(sealed_df, on="symbol", how="left")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("封单注入失败 (ladder 规则将不触发): %s", e)
|
||||
return enriched_today
|
||||
|
||||
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
|
||||
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。
|
||||
|
||||
- 全局飞书 URL 未配置: 直接返回
|
||||
- 仅推送 webhook_enabled=True 的规则触发的告警
|
||||
- 失败静默, 不阻断主流程
|
||||
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
|
||||
|
||||
注意: 用 rule_events (含 rule_id) 而非重建后的 all_alerts,
|
||||
以便反查引擎规则判断是否启用推送。
|
||||
"""
|
||||
try:
|
||||
from app.services import preferences
|
||||
from app.services import webhook_adapter
|
||||
|
||||
url = preferences.get_feishu_webhook_url()
|
||||
if not url:
|
||||
return
|
||||
secret = preferences.get_feishu_webhook_secret()
|
||||
|
||||
# 反查规则, 过滤出启用推送的事件
|
||||
source_labels = {
|
||||
"strategy": "策略", "signal": "信号",
|
||||
"price": "价格", "market": "异动",
|
||||
}
|
||||
rules = engine.rules if engine is not None else {}
|
||||
pushed = 0
|
||||
for ev in rule_events:
|
||||
rule = rules.get(ev.get("rule_id"))
|
||||
if not rule or not rule.get("webhook_enabled"):
|
||||
continue
|
||||
source = ev.get("source", "")
|
||||
source_label = source_labels.get(source, source or "通知")
|
||||
symbol = ev.get("symbol") or ""
|
||||
name = ev.get("name") or ""
|
||||
message = ev.get("message") or ""
|
||||
title = f"TickFlow · {source_label}"
|
||||
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
|
||||
if webhook_adapter.send_feishu(url, title, body, secret):
|
||||
pushed += 1
|
||||
if pushed:
|
||||
logger.info("飞书 Webhook 推送: %d 条", pushed)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e)
|
||||
|
||||
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
|
||||
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
|
||||
|
||||
@@ -592,95 +858,11 @@ class QuoteService:
|
||||
else:
|
||||
body = message or name
|
||||
|
||||
title = f"Stock Panel · {source_label}"
|
||||
title = f"TickFlow · {source_label}"
|
||||
notify_adapter.notify(title, body)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("系统通知发送异常 (不影响告警主流程): %s", e)
|
||||
|
||||
def _refresh_strategy_cache(self, enriched_today: pl.DataFrame, enriched_date: date | None) -> None:
|
||||
"""利用已计算好的 enriched 数据,运行策略池并写入缓存。"""
|
||||
import math
|
||||
from dataclasses import asdict
|
||||
from app.services import strategy_cache
|
||||
from app.services.screener import PRESET_STRATEGIES, ScreenerService
|
||||
from app.strategy import config as strategy_config
|
||||
|
||||
try:
|
||||
if enriched_date is None:
|
||||
return
|
||||
as_of = enriched_date
|
||||
data_dir = self._repo.store.data_dir
|
||||
svc = ScreenerService(self._repo)
|
||||
engine = getattr(self._app_state, "strategy_engine", None)
|
||||
|
||||
# 确定要运行的策略: 策略监控池中的策略
|
||||
monitor_ids = self._get_monitor_pool_ids()
|
||||
if not monitor_ids:
|
||||
return
|
||||
|
||||
# 一次加载所有 override
|
||||
all_overrides = strategy_config.list_overrides(data_dir)
|
||||
|
||||
# 历史策略: 只在需要时加载
|
||||
shared_history = None
|
||||
history_strats = []
|
||||
if engine:
|
||||
id_set = set(monitor_ids)
|
||||
history_strats = [
|
||||
(sid, s) for sid, s in engine._strategies.items()
|
||||
if s.filter_history_fn and sid in id_set
|
||||
]
|
||||
if history_strats:
|
||||
max_lb = max(s.lookback_days for _, s in history_strats)
|
||||
shared_history = svc._load_enriched_history(as_of, max(1, max_lb))
|
||||
|
||||
results: dict[str, dict] = {}
|
||||
for sid in monitor_ids:
|
||||
try:
|
||||
overrides = all_overrides.get(sid, {})
|
||||
bf = overrides.get("basic_filter") if overrides else None
|
||||
dl = overrides.get("display_limit") if overrides else None
|
||||
if dl is None and overrides and "display_limit" in overrides:
|
||||
dl = 0
|
||||
|
||||
if sid in PRESET_STRATEGIES:
|
||||
r = svc.run_preset(sid, as_of=as_of, precomputed=enriched_today, basic_filter=bf, display_limit=dl)
|
||||
elif engine:
|
||||
r = engine.run(
|
||||
sid, as_of, overrides=overrides or None,
|
||||
precomputed=enriched_today, precomputed_history=shared_history,
|
||||
)
|
||||
if dl is not None and dl > 0:
|
||||
r.rows = r.rows[:dl]
|
||||
r.total = min(r.total, dl)
|
||||
else:
|
||||
continue
|
||||
|
||||
# sanitize NaN/Inf
|
||||
rows = []
|
||||
for row_dict in asdict(r).get("rows", []):
|
||||
for k, v in list(row_dict.items()):
|
||||
if isinstance(v, float) and not math.isfinite(v):
|
||||
row_dict[k] = None
|
||||
rows.append(row_dict)
|
||||
results[sid] = {"total": r.total, "as_of": str(as_of), "rows": rows}
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
|
||||
if results:
|
||||
strategy_cache.write_cache(data_dir, str(as_of), results)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("策略缓存刷新失败: %s", e)
|
||||
|
||||
def _get_monitor_pool_ids(self) -> list[str]:
|
||||
"""获取策略监控池中的策略 ID 列表。"""
|
||||
from app.services import preferences
|
||||
ids = preferences.get_strategy_monitor_ids()
|
||||
if not ids:
|
||||
return []
|
||||
return [sid for sid in ids if sid]
|
||||
|
||||
@staticmethod
|
||||
def _get_strategy_monitor():
|
||||
"""获取 StrategyMonitorService — 不再使用, 改用 _app_state 注入。"""
|
||||
@@ -690,7 +872,7 @@ class QuoteService:
|
||||
# enriched 增量计算
|
||||
# ================================================================
|
||||
|
||||
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None) -> None:
|
||||
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None, asset_type: str = "stock", merge: bool = False) -> None:
|
||||
"""增量计算今天的 enriched: 用昨天的递推状态 + 今天 OHLCV → 只算今天 5500 行。
|
||||
|
||||
quote_extra: API 直接提供的补充字段 (prev_close, change_pct 等),
|
||||
@@ -701,11 +883,16 @@ class QuoteService:
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# ---- 尝试增量路径 ----
|
||||
live_agg = self._repo.get_live_agg()
|
||||
prev_enriched, prev_date = self._repo.get_enriched_latest()
|
||||
live_agg = self._repo.get_live_agg() if asset_type == "stock" else pl.DataFrame()
|
||||
prev_enriched, prev_date = (
|
||||
self._repo.get_enriched_latest()
|
||||
if asset_type == "stock"
|
||||
else self._repo.get_enriched_latest_asset(asset_type)
|
||||
)
|
||||
|
||||
use_incremental = (
|
||||
not live_agg.is_empty()
|
||||
asset_type == "stock"
|
||||
and not live_agg.is_empty()
|
||||
and not prev_enriched.is_empty()
|
||||
and prev_date is not None
|
||||
)
|
||||
@@ -736,7 +923,8 @@ class QuoteService:
|
||||
"ok" if not live_agg.is_empty() else "空", prev_date)
|
||||
|
||||
cutoff = today - timedelta(days=90)
|
||||
daily_glob = str(self._repo.store.data_dir / "kline_daily" / "**" / "*.parquet")
|
||||
table = "kline_etf_daily" if asset_type == "etf" else "kline_daily"
|
||||
daily_glob = str(self._repo.store.data_dir / table / "**" / "*.parquet")
|
||||
ohlcv_cols = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
|
||||
hist_df = (
|
||||
pl.scan_parquet(daily_glob)
|
||||
@@ -753,14 +941,15 @@ class QuoteService:
|
||||
full_df = pl.concat([hist_df, daily_ohlcv], how="diagonal_relaxed")
|
||||
full_df = full_df.sort(["symbol", "date"])
|
||||
|
||||
factor_path = self._repo.store.data_dir / "adj_factor" / "all.parquet"
|
||||
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
|
||||
factor_path = self._repo.store.data_dir / factor_dir / "all.parquet"
|
||||
factors = pl.DataFrame()
|
||||
if factor_path.exists():
|
||||
try:
|
||||
factors = pl.read_parquet(factor_path)
|
||||
except Exception:
|
||||
pass
|
||||
instruments = self._repo.get_instruments()
|
||||
instruments = self._repo.get_instruments() if asset_type == "stock" else None
|
||||
|
||||
enriched_full = compute_enriched(full_df, factors=factors, instruments=instruments)
|
||||
enriched_today = enriched_full.filter(pl.col("date") == today)
|
||||
@@ -769,7 +958,10 @@ class QuoteService:
|
||||
return
|
||||
|
||||
# ---- 写盘 + 更新缓存 ----
|
||||
self._repo.flush_live_enriched(enriched_today)
|
||||
if merge:
|
||||
self._repo.merge_live_enriched_asset(asset_type, enriched_today)
|
||||
else:
|
||||
self._repo.flush_live_enriched_asset(asset_type, enriched_today)
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
mode_label = "增量" if use_incremental else "全量"
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""概念涨幅轮动矩阵 service。
|
||||
|
||||
输出「每列(日期)各自把所有概念按当天涨幅从高到低排序」的矩阵,供前端
|
||||
「概念分析 → 涨幅RPS轮动」对话框渲染。
|
||||
|
||||
数据来源全部复用现有资产, 不引入新数据源:
|
||||
- 个股历史涨跌幅: repo.get_enriched_range(..., columns=["symbol","date","change_pct"])
|
||||
命中启动时构建的 _enriched_history_cache (0ms, 含 change_pct 小数列)
|
||||
- 概念成分股映射: 复用 market_overview_builder 的 _dimension_field / _read_ext_rows /
|
||||
_symbol_keys / _dimension_values, 与看板/复盘的概念聚合口径完全一致
|
||||
|
||||
性能: 387 概念 × 30 天的 group_by + sort 是 polars 内存操作, 实测 <50ms;
|
||||
另加进程级结果缓存 (_CACHE_TTL=120s), 重复请求 <1ms。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import date, timedelta
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.services.market_overview_builder import (
|
||||
_dimension_field,
|
||||
_dimension_values,
|
||||
_read_ext_rows,
|
||||
_symbol_keys,
|
||||
)
|
||||
from app.services.ext_data import ExtConfigStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 进程级结果缓存 (照搬 overview.py:18 的模式, TTL 拉长到 120s —— 轮动矩阵
|
||||
# 不像看板那样需要近实时, 盘后数据稳定, 缓存久一点无妨)
|
||||
_CACHE_TTL = 120.0
|
||||
_cache: dict[str, dict] = {}
|
||||
_cache_ts: dict[str, float] = {}
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
"""清空轮动矩阵结果缓存(数据管道完成后调用, 避免返回旧数据)。"""
|
||||
_cache.clear()
|
||||
_cache_ts.clear()
|
||||
|
||||
|
||||
def _latest_enriched_date(repo) -> date | None:
|
||||
"""取 enriched 缓存里的最新交易日(矩阵的右端=最新日期)。"""
|
||||
cache = repo._enriched_history_cache # noqa: SLF001 —— 缓存字段无公开 getter
|
||||
if cache is None or cache.is_empty() or "date" not in cache.columns:
|
||||
return None
|
||||
return cache["date"].max()
|
||||
|
||||
|
||||
def _load_concept_map_df(repo) -> tuple[pl.DataFrame, int]:
|
||||
"""构建并缓存 {symbol_upper → 概念} 的已展开 polars 映射表。
|
||||
|
||||
复用 market_overview_builder 的概念识别 + 成分股读取逻辑(_dimension_field /
|
||||
_read_ext_rows / _symbol_keys / _dimension_values), 但要的是「反向映射」
|
||||
(symbol → 概念), 且直接产出 polars DataFrame 供 join 使用。
|
||||
|
||||
返回 (map_df, concept_count):
|
||||
- map_df: 两列 (_sym_up: 大写 symbol, concept: 概念名), 已 explode, 一个
|
||||
symbol 属多概念时有多行。无概念数据时返回空 DataFrame。
|
||||
- concept_count: 去重概念总数。
|
||||
|
||||
缓存: 概念成分股是 snapshot, 进程内不变, 缓存 600s。
|
||||
直接缓存 DataFrame 而非 Python dict —— 后续 join 时省掉每次 ~1s 的 dict→DataFrame
|
||||
重建开销(这是结果缓存失效后重算的主要瓶颈)。
|
||||
"""
|
||||
global _concept_map_cache, _concept_map_count, _concept_map_ts
|
||||
now = time.time()
|
||||
if _concept_map_cache is not None and (now - _concept_map_ts) < 600:
|
||||
return _concept_map_cache, _concept_map_count
|
||||
|
||||
data_dir = repo.store.data_dir
|
||||
store = ExtConfigStore(data_dir)
|
||||
# 先收集成扁平的 (sym, concept) 行, 再一次性构造 DataFrame(比 list 列快得多)
|
||||
pairs: list[tuple[str, str]] = []
|
||||
concepts_seen: set[str] = set()
|
||||
|
||||
for config in store.load_all():
|
||||
field = _dimension_field(config, "concept")
|
||||
if not field:
|
||||
continue
|
||||
for ext_row in _read_ext_rows(data_dir, config, field):
|
||||
concepts = _dimension_values(ext_row.get(field))
|
||||
if not concepts:
|
||||
continue
|
||||
keys = _symbol_keys(ext_row, config)
|
||||
for key in keys:
|
||||
for c in concepts:
|
||||
pairs.append((key, c))
|
||||
concepts_seen.add(c)
|
||||
|
||||
if pairs:
|
||||
# 去重: 同一 (symbol, concept) 对会因多 key 形式(SZ/000001)和
|
||||
# 多 config 重复出现, 去重后从 ~48万 行降到 ~14万, join 快 3x+
|
||||
_concept_map_cache = pl.DataFrame(
|
||||
{"_sym_up": [p[0] for p in pairs], "concept": [p[1] for p in pairs]},
|
||||
schema={"_sym_up": pl.Utf8, "concept": pl.Utf8},
|
||||
).unique()
|
||||
_concept_map_count = len(concepts_seen)
|
||||
else:
|
||||
_concept_map_cache = pl.DataFrame(
|
||||
schema={"_sym_up": pl.Utf8, "concept": pl.Utf8}
|
||||
)
|
||||
_concept_map_count = 0
|
||||
_concept_map_ts = now
|
||||
return _concept_map_cache, _concept_map_count
|
||||
|
||||
|
||||
_concept_map_cache: pl.DataFrame | None = None
|
||||
_concept_map_count: int = 0
|
||||
_concept_map_ts: float = 0.0
|
||||
|
||||
|
||||
def build_rps_rotation(repo, days: int = 12) -> dict:
|
||||
"""构建概念涨幅轮动矩阵。
|
||||
|
||||
Args:
|
||||
repo: KlineRepository(含 _enriched_history_cache 内存历史)。
|
||||
days: 取最近 N 个交易日, 范围 [7, 30], 默认 12。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"dates": ["2026-06-30", ...], # 最新在最前, 长度 ≤ days
|
||||
"columns": {"2026-06-30": [[概念, 涨幅], ...], ...}, # 每列各自排序(高→低)
|
||||
"concept_count": 387, # 去重概念总数(0 表示无概念数据)
|
||||
}
|
||||
涨幅是小数(0.0522 = +5.22%)。无数据时返回空 columns。
|
||||
"""
|
||||
days = max(7, min(30, days))
|
||||
|
||||
# 结果缓存: 同 days(→ 同 start/end)的请求在 TTL 内直接返回
|
||||
latest = _latest_enriched_date(repo)
|
||||
if latest is None:
|
||||
return {"dates": [], "columns": {}, "concept_count": 0}
|
||||
|
||||
cache_key = latest.isoformat()
|
||||
now = time.time()
|
||||
cached = _cache.get(cache_key)
|
||||
if cached and (now - _cache_ts.get(cache_key, 0)) < _CACHE_TTL:
|
||||
# 缓存的是所有日期, 按需要的 days 截取(避免不同 days 各存一份)
|
||||
return _slice_cached(cached, days)
|
||||
|
||||
# 1. 概念映射(symbol → 概念), 已缓存为 polars DataFrame
|
||||
map_df, concept_count = _load_concept_map_df(repo)
|
||||
if map_df.is_empty():
|
||||
logger.info("rps_rotation: no concept data (ext_gn_ths not fetched yet)")
|
||||
return {"dates": [], "columns": {}, "concept_count": 0}
|
||||
|
||||
# 2. 取最近 N 交易日的个股 change_pct(命中内存缓存)
|
||||
start = latest - timedelta(days=days * 2 + 10) # 日历天 ≈ 2/3 交易日, 多取余量
|
||||
df = repo.get_enriched_range(
|
||||
start, latest, columns=["symbol", "date", "change_pct"]
|
||||
)
|
||||
if df is None or df.is_empty():
|
||||
return {"dates": [], "columns": {}, "concept_count": 0}
|
||||
|
||||
# 3. 把个股 symbol 映射到概念, 一只股票拆成多行(每个概念一行)
|
||||
# symbol 大写匹配(map_df 的 _sym_up 已大写)
|
||||
df = df.with_columns(pl.col("symbol").str.to_uppercase().alias("_sym_up"))
|
||||
joined = df.join(map_df, on="_sym_up", how="inner").drop("_sym_up")
|
||||
|
||||
if joined.is_empty():
|
||||
return {"dates": [], "columns": {}, "concept_count": 0}
|
||||
|
||||
# 4. 按 (date, concept) 聚合 avg change_pct —— 与 _dimension_rank:288 的简单平均口径一致
|
||||
agg = joined.group_by(["date", "concept"]).agg(
|
||||
pl.col("change_pct").mean().alias("avg_pct")
|
||||
)
|
||||
# 去掉 NaN/Null(停牌等无行情的概念日)
|
||||
agg = agg.filter(pl.col("avg_pct").is_not_null() & pl.col("avg_pct").is_not_nan())
|
||||
|
||||
# 5. 每个日期内按 avg_pct 降序排, 再 group_by 把每组的 (concept, avg_pct)
|
||||
# 收集成并行 list —— 一次 polars 操作拿到全部列, 避免 partition_by 的 tuple key 歧义
|
||||
agg = agg.sort(["date", "avg_pct"], descending=[False, True])
|
||||
grouped = agg.group_by("date", maintain_order=True).agg(
|
||||
pl.col("concept"), pl.col("avg_pct")
|
||||
)
|
||||
# 最新日期排最前
|
||||
grouped = grouped.sort("date", descending=True)
|
||||
|
||||
columns: dict[str, list[list]] = {}
|
||||
all_dates_sorted: list[str] = []
|
||||
for row in grouped.iter_rows(named=True):
|
||||
d_str = str(row["date"])
|
||||
all_dates_sorted.append(d_str)
|
||||
columns[d_str] = list(zip(row["concept"], row["avg_pct"]))
|
||||
|
||||
full = {
|
||||
"dates": [str(d) for d in all_dates_sorted],
|
||||
"columns": columns,
|
||||
"concept_count": concept_count,
|
||||
}
|
||||
|
||||
# 写缓存(存全量, 按需 slice)
|
||||
_cache[cache_key] = full
|
||||
_cache_ts[cache_key] = now
|
||||
|
||||
return _slice_cached(full, days)
|
||||
|
||||
|
||||
def _slice_cached(full: dict, days: int) -> dict:
|
||||
"""从全量缓存截取最近 N 天(days)。"""
|
||||
dates_all = full["dates"]
|
||||
if len(dates_all) <= days:
|
||||
return full
|
||||
keep_dates = dates_all[:days]
|
||||
return {
|
||||
"dates": keep_dates,
|
||||
"columns": {d: full["columns"][d] for d in keep_dates},
|
||||
"concept_count": full["concept_count"],
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"""AI 个股分析服务 — 技术面 / 基本面 / 财务面 / 消息面 四维综合分析。
|
||||
|
||||
职责:
|
||||
组合一只股票的 K 线(含已算好的技术指标)+ 财务表 + 关键价位 →
|
||||
拼装"实战派交易员"级系统提示词 → 流式调用 LLM → 逐 chunk 吐给前端。
|
||||
|
||||
与 financial_analyzer.py 的区别(刻意区分,非复用):
|
||||
- 角色:A 股实战派交易员 / 技术分析师(非 CFA 财务分析师)
|
||||
- 数据源:K 线 + 技术指标为主,财务表为辅(财务分析以财务表为主)
|
||||
- 输出框架:技术面→基本面→财务面→消息面(四维),落点是买卖区间与操作建议
|
||||
(财务分析的落点是财务质量评级)
|
||||
|
||||
不知道: HTTP、前端、配置持久化。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.indicators.levels import compute_levels, summarize_levels
|
||||
from app.services.financial_sync import get_financial_df
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 注入最近多少根日 K(技术面分析样本)
|
||||
_KLINE_WINDOW = 90
|
||||
# 注入财务表的最近期数
|
||||
_MAX_PERIODS = 4
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 数据加载
|
||||
# ================================================================
|
||||
|
||||
def _load_kline(repo, symbol: str) -> pl.DataFrame:
|
||||
"""读取该标的最近 N 根日 K(已含技术指标 / 信号)。
|
||||
|
||||
repo: KlineRepository;走内存缓存,性能可控。
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
end = date.today()
|
||||
start = end - timedelta(days=_KLINE_WINDOW * 2) # 多取一些保证交易日够
|
||||
df = repo.get_daily(symbol, start, end)
|
||||
if df.is_empty():
|
||||
return df
|
||||
return df.tail(_KLINE_WINDOW)
|
||||
|
||||
|
||||
def _clean_rows(df: pl.DataFrame, keep_cols: list[str]) -> list[dict]:
|
||||
"""把 DataFrame 转成 JSON 安全的 dict 列表(只保留关键列 + 清洗 NaN/Inf + date→字符串)。
|
||||
|
||||
polars 的 date 列会变成 Python datetime.date,json.dumps 无法直接序列化,
|
||||
必须转成 ISO 字符串,否则 json.dumps 抛 TypeError 让整个流静默失败。
|
||||
"""
|
||||
import datetime
|
||||
import math
|
||||
cols = [c for c in keep_cols if c in df.columns]
|
||||
sub = df.select(cols)
|
||||
rows = []
|
||||
for rec in sub.to_dicts():
|
||||
clean = {}
|
||||
for k, v in rec.items():
|
||||
if isinstance(v, float):
|
||||
clean[k] = None if not math.isfinite(v) else round(v, 4)
|
||||
elif isinstance(v, (datetime.date, datetime.datetime)):
|
||||
clean[k] = v.isoformat()
|
||||
else:
|
||||
clean[k] = v
|
||||
rows.append(clean)
|
||||
return rows
|
||||
|
||||
|
||||
def _load_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]:
|
||||
"""读取该标的核心财务指标 + 利润表(只取最有信息量的两张表)。
|
||||
|
||||
财务面只需要关键指标(ROE / 增速 / 毛利率 等),不需要把 4 张表全塞进上下文
|
||||
(那是 financial_analyzer 的职责)。这里取轻量,留给技术面更多 token。
|
||||
"""
|
||||
out: dict[str, list[dict]] = {}
|
||||
for table in ("metrics", "income"):
|
||||
df = get_financial_df(data_dir, table)
|
||||
if df.is_empty():
|
||||
out[table] = []
|
||||
continue
|
||||
df = df.filter(pl.col("symbol") == symbol)
|
||||
if df.is_empty():
|
||||
out[table] = []
|
||||
continue
|
||||
if "period_end" in df.columns:
|
||||
df = df.sort("period_end", descending=True).head(2) # 只取最近 2 期
|
||||
import math
|
||||
rows = []
|
||||
for rec in df.to_dicts():
|
||||
clean = {}
|
||||
for k, v in rec.items():
|
||||
if k == "symbol":
|
||||
continue
|
||||
if isinstance(v, float):
|
||||
clean[k] = None if not math.isfinite(v) else v
|
||||
else:
|
||||
clean[k] = v
|
||||
rows.append(clean)
|
||||
out[table] = rows
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 系统提示词 —— 实战派交易员四维框架(与财务分析明确区分)
|
||||
# ================================================================
|
||||
|
||||
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深交易员兼技术分析师,擅长从 K 线、量价、关键价位与基本面交叉验证中把握买卖时机。你的任务是:基于提供的个股数据,产出一份**实战、可直接指导交易决策**的综合分析报告。
|
||||
|
||||
## 输出规范
|
||||
|
||||
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
|
||||
|
||||
### 1. 🎯 一句话定调(1-2 句)
|
||||
用一句话概括该股当前的**技术状态与交易属性**(如"高位放量滞涨,需警惕回调"/"底部筹码集中,放量突破在即")。结尾用【操作建议:观望 / 轻仓试探 / 逢低吸纳 / 持有 / 减仓 / 规避】给出明确倾向。
|
||||
|
||||
### 2. 📈 技术面分析(核心维度)
|
||||
这是你的主战场,务必深入:
|
||||
- **趋势判断**:均线多头/空头排列、20/60 日均线方向、价格在均线之上/下
|
||||
- **形态结构**:近期是否有突破/破位/双底/双顶/旗形等关键形态
|
||||
- **指标信号**:MACD 金叉/死叉/背离、KDJ 超买超卖、RSI 强弱、布林通道位置
|
||||
- **量价配合**:放量上涨/缩量回调/量价背离/换手率异动
|
||||
每条结论必须引用具体数值(如"MACD 在 6/12 出现金叉,DIF 0.32 上穿 DEA 0.18")。
|
||||
|
||||
### 3. 💰 关键价位(买卖区间)
|
||||
基于提供的关键价位数据,明确指出:
|
||||
- **上方压力位**(逐档列出,标注强度):第一压力、第二压力
|
||||
- **下方支撑位**(逐档列出,标注强度):第一支撑、第二支撑
|
||||
- 给出**建议买入区间**与**止损位**(基于支撑位)
|
||||
用数据说话,引用提供的压力/支撑(成交密集区)/枢轴点数值。
|
||||
|
||||
### 4. 🏭 基本面与财务面(辅助验证)
|
||||
简要点评(2-4 句,不展开长篇):
|
||||
- 盈利质量(ROE / 毛利率水平)、成长性(营收/利润增速)
|
||||
- 与技术面的**交叉验证**:好公司 + 技术面走坏 → 仍需谨慎;差公司 + 技术面强势 → 警惕炒作风险
|
||||
|
||||
**当用户消息中标注了"该标的暂无财务数据"时**,本节请输出:
|
||||
> 📌 财务面分析能力正在接入中。当前版本(Free)未同步该标的的财务报表,基本面维度暂无法评估。
|
||||
> 技术面分析不依赖财务数据,以下结论依然有效;升级套餐或等待财务数据同步后可补充本维度。
|
||||
|
||||
**绝对不要**在无数据时编造 ROE / 增速等数字。
|
||||
|
||||
### 5. 📰 消息面(价量异动推断)
|
||||
**注意:本期无直接新闻数据输入。** 请基于 K 线的**异动信号**进行推断(如:
|
||||
- 涨停/连板/炸板 → 可能有利好或资金炒作
|
||||
- 放量暴跌 → 可能有未公开利空
|
||||
- 突破放量 → 可能有催化剂
|
||||
明确标注"[推断]",告诉用户这是基于价量的推测,真实消息面数据待接入。若无明显异动,直说"近期价量平稳,无明显消息面信号"。
|
||||
|
||||
### 6. ⚖️ 综合研判与操作建议
|
||||
2-3 段:
|
||||
- 该股当前处于(底部启动 / 上升途中 / 高位震荡 / 下跌趋势 / 底部企稳)哪个阶段
|
||||
- 风险收益比评估(距支撑位的空间 vs 距压力位的空间)
|
||||
- **明确操作建议**:激进型 / 稳健型 / 保守型 分别怎么应对
|
||||
- **需要重点盯的信号**(如跌破 X 支撑止损、站上 Y 压力加仓)
|
||||
|
||||
## 分析准则(务必遵守)
|
||||
|
||||
1. **技术面优先**:作为交易员,技术面和量价是主要依据,基本面是验证手段,主次分明
|
||||
2. **数据说话**:每个判断引用具体数值,严禁空泛套话("走势良好"必须改成"连续 3 日站稳 20 日均线且放量")
|
||||
3. **诚实中立**:看多就写多,看空就写空,不要模棱两可骑墙;数据不支持时直言无法判断
|
||||
4. **价位精确**:买卖区间必须落到具体价格,基于提供的关键价位数据推演
|
||||
5. **风险前置**:任何买入建议都要配止损位;提示潜在风险不回避
|
||||
6. **简明实战**:用交易员能扫读的密度输出,总字数 1000-1800 字,重在可执行
|
||||
|
||||
## 重要免责
|
||||
报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开行情与财务数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
|
||||
|
||||
现在请基于下方数据进行分析。"""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 用户消息构建
|
||||
# ================================================================
|
||||
|
||||
def _build_user_prompt(
|
||||
kline_tail: list[dict],
|
||||
fins: dict[str, list[dict]],
|
||||
levels: dict[str, list[dict]],
|
||||
close: float | None,
|
||||
symbol: str,
|
||||
focus: str,
|
||||
) -> str:
|
||||
"""构建用户消息:标的 + 价位摘要 + 技术指标 JSON + 财务摘要 + 关注点。"""
|
||||
parts: list[str] = [
|
||||
f"标的标准代码: {symbol}",
|
||||
f"关键价位概览: {summarize_levels(levels, close)}",
|
||||
"",
|
||||
"以下是该标的最近日 K 数据(JSON,含 OHLCV 与已计算的技术指标。"
|
||||
f"最近 {_KLINE_WINDOW} 个交易日,升序):",
|
||||
"```json",
|
||||
json.dumps(kline_tail, ensure_ascii=False),
|
||||
"```",
|
||||
]
|
||||
|
||||
has_fin = any(fins.values())
|
||||
if has_fin:
|
||||
parts.extend([
|
||||
"",
|
||||
"以下是该标的最新财务数据(JSON,核心指标 + 利润表,金额单位为元):",
|
||||
"```json",
|
||||
json.dumps(fins, ensure_ascii=False),
|
||||
"```",
|
||||
])
|
||||
else:
|
||||
parts.extend([
|
||||
"",
|
||||
"(该标的暂无财务数据:当前为 Free 模式或尚未同步财务报表。"
|
||||
"请按系统提示词第 4 节的说明,在基本面/财务面维度给出\"接入中\"的友好提示,不要编造数据。)",
|
||||
])
|
||||
|
||||
if focus.strip():
|
||||
parts.extend(["", f"本次分析请特别关注: {focus.strip()}"])
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 关键列筛选(控制上下文体积)
|
||||
# ================================================================
|
||||
|
||||
_KLINE_KEEP_COLS = [
|
||||
"date", "open", "high", "low", "close", "volume", "change_pct",
|
||||
"ma5", "ma10", "ma20", "ma60",
|
||||
"macd_dif", "macd_dea", "macd_hist",
|
||||
"kdj_k", "kdj_d", "kdj_j",
|
||||
"rsi_6", "rsi_14", "rsi_24",
|
||||
"boll_upper", "boll_mid", "boll_lower",
|
||||
"atr_14", "vol_ratio_5d", "turnover_rate",
|
||||
"consecutive_limit_ups",
|
||||
# 信号类(布尔)——只挑对消息面推断有用的几个
|
||||
"signal_limit_up", "signal_broken_limit_up", "signal_macd_golden",
|
||||
"signal_macd_death", "signal_ma_golden_5_20", "signal_volume_surge",
|
||||
"signal_boll_breakout_upper", "signal_boll_breakout_lower",
|
||||
]
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 流式分析入口
|
||||
# ================================================================
|
||||
|
||||
async def analyze_stock_stream(
|
||||
repo,
|
||||
data_dir: Path,
|
||||
symbol: str,
|
||||
focus: str = "",
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式个股分析:yield 出每个 NDJSON 事件。
|
||||
|
||||
协议(与 financial_analyzer 一致,前端解析无差异):
|
||||
{"type":"meta","symbol","summary","levels"} 数据 + 价位摘要
|
||||
{"type":"delta","content":"..."} 逐 chunk 文本
|
||||
{"type":"error","message":"..."}
|
||||
{"type":"done"}
|
||||
"""
|
||||
# 1. 加载 K 线
|
||||
df = _load_kline(repo, symbol)
|
||||
if df.is_empty():
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"message": f"标的 {symbol} 暂无日 K 数据,请先同步",
|
||||
}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
# 2. 价位计算(基于 K 线)
|
||||
levels = compute_levels(df)
|
||||
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
|
||||
|
||||
# 3. 财务(辅助)
|
||||
fins = _load_financials(data_dir, symbol)
|
||||
|
||||
# 4. meta
|
||||
yield json.dumps({
|
||||
"type": "meta",
|
||||
"symbol": symbol,
|
||||
"summary": summarize_levels(levels, close),
|
||||
"levels": levels,
|
||||
"close": close,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 5+6. 构建提示词 + 流式调用 LLM(整体 try-except,任何异常都 yield error,避免前端卡死)
|
||||
try:
|
||||
from app.services.ai_provider import stream_ai_text
|
||||
|
||||
kline_tail = _clean_rows(df, _KLINE_KEEP_COLS)
|
||||
user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus)
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4500,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("AI stock analysis failed for %s: %s", symbol, e)
|
||||
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""AI 个股分析报告持久化存储。
|
||||
|
||||
与 ai_reports.py(财务分析报告)完全独立 —— 单独的文件、字段、上限,
|
||||
互不影响。刻意不复用,避免引入 kind 判别字段与分支(解耦 > 抽象)。
|
||||
|
||||
存储位置: data/user_data/ai_stock_reports.json (数组,按 created_at 降序)
|
||||
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
|
||||
|
||||
每条报告结构:
|
||||
{
|
||||
"id": "sar_xxx", # 唯一 id(stock-analysis-report)
|
||||
"symbol": "600519.SH",
|
||||
"name": "贵州茅台",
|
||||
"focus": "", # 用户追加的关心点(可为空)
|
||||
"content": "# ...markdown", # 报告正文
|
||||
"summary": "当前价 12.3 · 压力位...", # 价位/数据摘要
|
||||
"levels": {...}, # 报告生成时的关键价位(供图表回放)
|
||||
"close": 12.3, # 报告生成时的收盘价
|
||||
"created_at": "2026-06-26T10:00:00"
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_REPORTS = 50
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "ai_stock_reports.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def list_reports() -> list[dict]:
|
||||
"""返回全部报告(按 created_at 降序)。"""
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ai_stock_reports.json malformed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _save_all(reports: list[dict]) -> None:
|
||||
"""全量写入(裁剪到 MAX_REPORTS)。"""
|
||||
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
if len(reports) > MAX_REPORTS:
|
||||
reports = reports[:MAX_REPORTS]
|
||||
_path().write_text(
|
||||
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_report(report: dict) -> dict:
|
||||
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
|
||||
reports = list_reports()
|
||||
if not report.get("id"):
|
||||
report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
|
||||
if not report.get("created_at"):
|
||||
report["created_at"] = _now_iso()
|
||||
reports.append(report)
|
||||
_save_all(reports)
|
||||
logger.info("Stock report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
|
||||
return report
|
||||
|
||||
|
||||
def delete_report(report_id: str) -> bool:
|
||||
"""删除指定报告。返回是否删除成功。"""
|
||||
reports = list_reports()
|
||||
before = len(reports)
|
||||
reports = [r for r in reports if r.get("id") != report_id]
|
||||
if len(reports) < before:
|
||||
_save_all(reports)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
@@ -54,7 +54,14 @@ def _get_enriched_mtime(data_dir: Path, as_of: str) -> float | None:
|
||||
|
||||
|
||||
def read_cache(data_dir: Path) -> dict | None:
|
||||
"""读取策略缓存文件。返回 None 表示无缓存、读取失败或 enriched 数据已更新导致缓存过期。"""
|
||||
"""读取策略缓存文件。返回 None 表示无缓存或读取失败。
|
||||
|
||||
说明: 原先有 enriched mtime 过期校验 (数据文件变化 → 判过期返回 None),
|
||||
但在有实时行情的系统里, enriched parquet 每轮被刷新 → mtime 必然变化 →
|
||||
缓存被永久判死, 策略页读不到数据。且判过期后不触发重算, 只能让用户手动重跑,
|
||||
保护价值有限。故移除: 盘后缓存总能读出, 实时新鲜度由 /api/screener/cached
|
||||
端点叠加监控引擎的内存实时结果 (latest_strategy_results) 来保证。
|
||||
"""
|
||||
path = _cache_path(data_dir)
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -67,15 +74,6 @@ def read_cache(data_dir: Path) -> dict | None:
|
||||
logger.warning("读取策略缓存失败: %s", e)
|
||||
return None
|
||||
|
||||
# 校验 enriched mtime: 数据文件变化 → 缓存过期
|
||||
as_of = cached.get("as_of")
|
||||
stored_mtime = cached.get("enriched_mtime")
|
||||
if as_of and stored_mtime:
|
||||
current_mtime = _get_enriched_mtime(data_dir, as_of)
|
||||
if current_mtime is not None and current_mtime != stored_mtime:
|
||||
logger.info("策略缓存过期: enriched 数据已更新 (as_of=%s)", as_of)
|
||||
return None
|
||||
|
||||
return cached
|
||||
|
||||
|
||||
@@ -130,7 +128,8 @@ def write_cache(
|
||||
# 从 ever_rows 提取 symbol 列表 (用于快速计数)
|
||||
today_ever_matched = {sid: sorted(maps.keys()) for sid, maps in today_ever_rows.items()}
|
||||
|
||||
# 记录 enriched parquet 文件的 mtime,用于后续校验缓存是否过期
|
||||
# enriched_mtime: 盘后缓存写入时记录 (向后兼容旧字段)。read_cache 已不再用它
|
||||
# 做过期校验, 实时新鲜度改由 /cached 端点叠加监控引擎内存结果保证。
|
||||
enriched_mtime = _get_enriched_mtime(data_dir, as_of)
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -63,6 +63,20 @@ def remove(symbol: str) -> list[dict]:
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def move_to_top(symbol: str) -> list[dict]:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pl.read_parquet(p)
|
||||
if df.is_empty() or symbol not in df["symbol"].to_list():
|
||||
return df.to_dicts()
|
||||
target = df.filter(pl.col("symbol") == symbol)
|
||||
rest = df.filter(pl.col("symbol") != symbol)
|
||||
out = pl.concat([target, rest], how="diagonal_relaxed")
|
||||
out.write_parquet(p)
|
||||
return out.to_dicts()
|
||||
|
||||
|
||||
def clear() -> int:
|
||||
"""清空自选列表。返回移除的数量。"""
|
||||
p = _path()
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Webhook 推送适配器 — 把告警事件推送到外部 IM / 量化软件。
|
||||
|
||||
职责: 把后端产生的告警事件, 通过用户配置的 Webhook 地址推送到外部。
|
||||
目前支持飞书群机器人; QMT / ptrade 等量化通道为待定。
|
||||
|
||||
飞书自定义机器人接入:
|
||||
1. 飞书群 → 群设置 → 群机器人 → 添加「自定义机器人」
|
||||
2. 复制生成的 Webhook 地址 (形如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx)
|
||||
3. (可选) 安全设置 → 启用「签名校验」, 记录签名密钥(secret)
|
||||
4. 填入设置页「飞书 Webhook」配置
|
||||
|
||||
设计: 失败静默降级, 绝不因推送失败阻断告警主流程 (落盘 / SSE 推送)。
|
||||
去重不在本层做, 复用 MonitorRuleEngine 的 cooldown。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 单次推送最长字符 (飞书单条文本消息上限 30KB, 这里保守截断避免刷屏)
|
||||
_MAX_LEN = 500
|
||||
|
||||
# 卡片消息正文最长字符 (飞书 interactive 卡片上限 30KB, 保守留余量给标题/结构)
|
||||
_CARD_MAX_LEN = 28000
|
||||
|
||||
# 飞书自定义机器人 Webhook 前缀 (用于 URL 合法性校验)
|
||||
FEISHU_HOOK_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
||||
|
||||
|
||||
def _truncate(text: str) -> str:
|
||||
"""截断超长文本。"""
|
||||
text = (text or "").strip()
|
||||
return text[:_MAX_LEN] + ("…" if len(text) > _MAX_LEN else "")
|
||||
|
||||
|
||||
def is_valid_feishu_url(url: str) -> bool:
|
||||
"""校验是否为合法的飞书自定义机器人 Webhook 地址。"""
|
||||
return bool(url) and url.startswith(FEISHU_HOOK_PREFIX)
|
||||
|
||||
|
||||
def _gen_sign(timestamp: str, secret: str) -> str:
|
||||
"""计算飞书自定义机器人签名。
|
||||
|
||||
算法 (官方): 把 `timestamp + "\\n" + secret` 作为签名字符串 (key),
|
||||
用 HmacSHA256 计算空字符串的签名结果, 再 Base64 编码。
|
||||
"""
|
||||
string_to_sign = f"{timestamp}\n{secret}"
|
||||
hmac_code = hmac.new(
|
||||
string_to_sign.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
return base64.b64encode(hmac_code).decode("utf-8")
|
||||
|
||||
|
||||
def _truncate_card(text: str) -> str:
|
||||
"""截断卡片正文 (留余量给标题与卡片结构)。"""
|
||||
text = (text or "").strip()
|
||||
return text[:_CARD_MAX_LEN] + ("…" if len(text) > _CARD_MAX_LEN else "")
|
||||
|
||||
|
||||
def _post_feishu(webhook_url: str, payload: dict, secret: str) -> bool:
|
||||
"""发送一次飞书 webhook 请求并判定成败 (供 text / card 共用)。
|
||||
|
||||
成功响应: HTTP 200 且业务 code=0 (或非 JSON 的 200)。失败静默返回 False。
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# 启用签名校验时, 请求体须带 timestamp + sign (秒级时间戳)
|
||||
if secret:
|
||||
timestamp = str(int(time.time()))
|
||||
payload["timestamp"] = timestamp
|
||||
payload["sign"] = _gen_sign(timestamp, secret)
|
||||
|
||||
resp = httpx.post(webhook_url, json=payload, timeout=5.0)
|
||||
# 飞书成功响应: {"code":0,"msg":"success"} (或 StatusCode 200 + Extra)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
# code=0 表示飞书业务侧成功; 部分版本无 code 字段则按 msg 判断
|
||||
if isinstance(data, dict):
|
||||
code = data.get("code", data.get("StatusCode", 0))
|
||||
if code == 0:
|
||||
return True
|
||||
logger.debug("飞书推送业务失败: %s", data)
|
||||
return False
|
||||
except ValueError:
|
||||
# 非 JSON 响应但 HTTP 200, 视为成功
|
||||
return True
|
||||
logger.debug("飞书推送 HTTP %s: %s", resp.status_code, resp.text[:200])
|
||||
return False
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("飞书 Webhook 推送失败: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def send_feishu(webhook_url: str, title: str, body: str, secret: str = "") -> bool:
|
||||
"""推送一条文本消息到飞书群机器人。
|
||||
|
||||
Args:
|
||||
webhook_url: 飞书自定义机器人 Webhook 地址
|
||||
title: 消息标题 (与正文拼接为一条文本)
|
||||
body: 消息正文
|
||||
secret: 签名密钥 (机器人启用了「签名校验」时必填; 留空则不带签名)
|
||||
|
||||
Returns:
|
||||
True=成功送达, False=失败或 URL 非法。
|
||||
失败静默, 不抛异常 (Webhook 是辅助通道, 不能阻断告警主流程)。
|
||||
"""
|
||||
if not is_valid_feishu_url(webhook_url):
|
||||
return False
|
||||
|
||||
text = _truncate(f"{title}\n{body}".strip())
|
||||
if not text:
|
||||
return False
|
||||
|
||||
payload: dict = {"msg_type": "text", "content": {"text": text}}
|
||||
return _post_feishu(webhook_url, payload, secret)
|
||||
|
||||
|
||||
def send_feishu_card(webhook_url: str, title: str, subtitle: str, body_md: str, secret: str = "") -> bool:
|
||||
"""推送一条 interactive 卡片消息到飞书群机器人 —— 用 lark_md 渲染完整 markdown 报告。
|
||||
|
||||
飞书「自定义机器人」webhook 不支持文件附件, 但 interactive 卡片的 lark_md 元素
|
||||
可渲染 markdown, 能承载完整复盘报告(通常 2-5KB, 远小于卡片 30KB 上限)。
|
||||
|
||||
Args:
|
||||
webhook_url: 飞书自定义机器人 Webhook 地址
|
||||
title: 卡片标题 (显示在蓝色 header)
|
||||
subtitle: 副标题 (加粗显示, 如日期/情绪标签; 留空则省略)
|
||||
body_md: 卡片正文 markdown (报告全文)
|
||||
secret: 签名密钥 (启用签名校验时必填)
|
||||
|
||||
Returns:
|
||||
True=成功送达, False=失败或 URL 非法。
|
||||
失败静默, 不抛异常 (与 send_feishu 一致, 不阻断告警主流程)。
|
||||
"""
|
||||
if not is_valid_feishu_url(webhook_url):
|
||||
return False
|
||||
|
||||
body = _truncate_card(body_md)
|
||||
elements: list[dict] = []
|
||||
if subtitle.strip():
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": f"**{subtitle.strip()}**"},
|
||||
})
|
||||
elements.append({"tag": "hr"})
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": body},
|
||||
})
|
||||
|
||||
payload: dict = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": title},
|
||||
"template": "blue",
|
||||
},
|
||||
"elements": elements,
|
||||
},
|
||||
}
|
||||
return _post_feishu(webhook_url, payload, secret)
|
||||
@@ -75,38 +75,18 @@ class AIStrategyGenerator:
|
||||
return {"code": code, "meta": meta, "valid": True, "error": None}
|
||||
|
||||
async def _call_llm(self, user_prompt: str, guide: str) -> str:
|
||||
"""调用 OpenAI 兼容 API(流式,避免 CDN 长连接超时)"""
|
||||
from openai import AsyncOpenAI
|
||||
from app import secrets_store
|
||||
"""Call the configured AI provider and return generated strategy code."""
|
||||
from app.services.ai_provider import generate_ai_text
|
||||
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
if not ai_key:
|
||||
raise RuntimeError("AI API Key 未配置,请在设置页面配置")
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=ai_key,
|
||||
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
|
||||
timeout=180.0,
|
||||
max_retries=2,
|
||||
)
|
||||
# 使用流式请求:CDN 收到首个 token 后会持续转发,不会因等待超时
|
||||
stream = await client.chat.completions.create(
|
||||
model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
|
||||
messages=[
|
||||
content = await generate_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PREFIX + guide},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=3000,
|
||||
stream=True,
|
||||
)
|
||||
chunks: list[str] = []
|
||||
async for chunk in stream:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta and delta.content:
|
||||
chunks.append(delta.content)
|
||||
content = "".join(chunks).strip()
|
||||
# 提取代码块
|
||||
# Extract fenced code if the model wrapped the answer in Markdown.
|
||||
if "```python" in content:
|
||||
content = content.split("```python", 1)[1].split("```", 1)[0].strip()
|
||||
elif "```" in content:
|
||||
|
||||
@@ -24,6 +24,44 @@ from app.strategy import config as _strategy_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 信号 / 字段中文名映射 — 与前端 lib/signals.ts 对齐, 用于告警 message / 推送文案。
|
||||
# signal_* 为内置原子信号, 其余为技术指标/行情字段。
|
||||
_SIGNAL_CN: dict[str, str] = {
|
||||
# 内置信号
|
||||
"signal_ma_golden_5_20": "MA5上穿MA20", "signal_ma_dead_5_20": "MA5下穿MA20",
|
||||
"signal_ma_golden_20_60": "MA20上穿MA60", "signal_macd_golden": "MACD金叉",
|
||||
"signal_macd_dead": "MACD死叉", "signal_ma20_breakout": "突破MA20",
|
||||
"signal_ma20_breakdown": "跌破MA20", "signal_n_day_high": "60日新高",
|
||||
"signal_n_day_low": "60日新低", "signal_boll_breakout_upper": "突破布林上轨",
|
||||
"signal_boll_breakdown_lower": "跌破布林下轨", "signal_volume_surge": "放量",
|
||||
"signal_limit_up": "涨停", "signal_limit_down": "跌停",
|
||||
"signal_limit_down_recovery": "跌停翘板", "signal_broken_limit_up": "炸板",
|
||||
# 行情字段
|
||||
"close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价",
|
||||
"change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅",
|
||||
"turnover_rate": "换手率", "volume": "成交量", "amount": "成交额",
|
||||
# 均线
|
||||
"ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60",
|
||||
"ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20",
|
||||
# MACD / BOLL / KDJ / RSI
|
||||
"macd_dif": "MACD-DIF", "macd_dea": "MACD-DEA", "macd_hist": "MACD柱",
|
||||
"boll_upper": "布林上轨", "boll_lower": "布林下轨",
|
||||
"kdj_k": "KDJ-K", "kdj_d": "KDJ-D", "kdj_j": "KDJ-J",
|
||||
"rsi_6": "RSI6", "rsi_14": "RSI14", "rsi_24": "RSI24",
|
||||
# 量能 / 动量 / 波动
|
||||
"vol_ratio_5d": "5日量比", "vol_ratio_20d": "20日量比",
|
||||
"vol_ma5": "5日均量", "vol_ma10": "10日均量",
|
||||
"high_60d": "60日最高", "low_60d": "60日最低",
|
||||
"momentum_5d": "5日动量", "momentum_20d": "20日动量", "momentum_60d": "60日动量",
|
||||
"atr_14": "ATR14", "annual_vol_20d": "20日年化波动",
|
||||
"consecutive_limit_ups": "连板数", "consecutive_limit_downs": "跌停连板",
|
||||
}
|
||||
|
||||
|
||||
def _signal_cn_name(name: str) -> str:
|
||||
"""返回信号/字段的中文名, 找不到原样返回 (与前端 cnSignal 对齐)。"""
|
||||
return _SIGNAL_CN.get(name, name)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyAlert:
|
||||
@@ -276,6 +314,13 @@ class MonitorRuleEngine:
|
||||
self._strategy_pools: dict[str, set[str]] = {}
|
||||
# 数据目录 (用于加载策略 overrides)
|
||||
self._data_dir = None
|
||||
# 历史窗口加载器: (target_date, lookback_days) → 多日 enriched DataFrame。
|
||||
# 用于声明 filter_history 的策略 (如反包), 实时监控时拼历史窗口 + 今日行情跑选股。
|
||||
# 为 None 时, filter_history 策略仍会被跳过 (保持旧行为, 不破坏无历史场景)。
|
||||
self._history_loader: Callable[[_dt.date, int], "pl.DataFrame"] | None = None
|
||||
# 本轮 evaluate() 产出的策略选股结果: strategy_id → {rows, total, as_of}
|
||||
# 供策略页实时回显复用 (/api/screener/cached 端点直接读取此内存结果), 避免重跑
|
||||
self._latest_strategy_results: dict[str, dict] = {}
|
||||
|
||||
def set_strategy_engine(self, engine) -> None:
|
||||
"""注入 StrategyEngine, type=strategy 规则据此跑选股。"""
|
||||
@@ -285,6 +330,15 @@ class MonitorRuleEngine:
|
||||
"""注入数据目录, 用于加载策略的用户覆盖配置。"""
|
||||
self._data_dir = data_dir
|
||||
|
||||
def set_history_loader(self, fn) -> None:
|
||||
"""注入历史窗口加载器, 用于声明 filter_history 的策略跑实时监控。
|
||||
|
||||
loader 签名: (target_date, lookback_days) → 多日 enriched DataFrame。
|
||||
复用 ScreenerService._load_enriched_history (三级缓存, 命中 ~0ms)。
|
||||
为 None 时 filter_history 策略退回到跳过逻辑 (不破坏无历史场景)。
|
||||
"""
|
||||
self._history_loader = fn
|
||||
|
||||
def set_name_map(self, name_map: dict[str, str]) -> None:
|
||||
"""注入 symbol → 股票名 映射, 用于在告警事件里回填 name 字段。
|
||||
|
||||
@@ -325,6 +379,23 @@ class MonitorRuleEngine:
|
||||
def rule_count(self) -> int:
|
||||
return len(self._rules)
|
||||
|
||||
def latest_strategy_results(self) -> dict[str, dict]:
|
||||
"""返回本轮 evaluate() 产出的策略选股结果 (strategy_id → {rows, total, as_of})。
|
||||
|
||||
供策略页实时回显复用: /api/screener/cached 端点直接读取此内存结果,
|
||||
避免对被监控的策略重跑第二遍。无 type=strategy 规则时返回空 dict。
|
||||
"""
|
||||
return self._latest_strategy_results
|
||||
|
||||
def has_rule_type(self, rtype: str) -> bool:
|
||||
"""是否存在指定类型的 (已启用) 规则。供 quote_service 判断是否需要注入特殊数据。"""
|
||||
if not self._rules:
|
||||
return False
|
||||
return any(
|
||||
r.get("enabled", True) and r.get("type") == rtype
|
||||
for r in self._rules.values()
|
||||
)
|
||||
|
||||
# ── 评估 ───────────────────────────────────────────
|
||||
def evaluate(self, df: pl.DataFrame) -> list[dict]:
|
||||
"""行情更新后评估所有规则。
|
||||
@@ -339,6 +410,8 @@ class MonitorRuleEngine:
|
||||
|
||||
now = time.time()
|
||||
events: list[dict] = []
|
||||
# 每轮重置: 只保留本次 evaluate 产出的策略结果
|
||||
self._latest_strategy_results = {}
|
||||
|
||||
for rule_id, rule in self._rules.items():
|
||||
try:
|
||||
@@ -363,6 +436,9 @@ class MonitorRuleEngine:
|
||||
if rtype == "strategy":
|
||||
# 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件
|
||||
hit_rows = self._match_strategy(scoped, rule)
|
||||
elif rtype == "ladder":
|
||||
# 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message)
|
||||
return self._evaluate_ladder(scoped, rule, now)
|
||||
else:
|
||||
# signal / price / market: 通用条件匹配
|
||||
for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule):
|
||||
@@ -395,7 +471,11 @@ class MonitorRuleEngine:
|
||||
message = name # name 字段即批量消息
|
||||
else:
|
||||
resolved_name = name if name else self._name_map.get(sym)
|
||||
message = rule.get("message", "") or self._default_message(rule, ev_type=ev_type, sym=sym, name=resolved_name, pct=pct)
|
||||
message = rule.get("message", "") or self._default_message(
|
||||
rule, ev_type=ev_type, sym=sym, name=resolved_name,
|
||||
pct=pct, price=price,
|
||||
conditions=list(rule.get("conditions", [])) if rule.get("type") != "strategy" else None,
|
||||
)
|
||||
|
||||
ev = {
|
||||
"ts": int(now * 1000),
|
||||
@@ -410,6 +490,10 @@ class MonitorRuleEngine:
|
||||
"change_pct": pct,
|
||||
"signals": hit_sigs,
|
||||
"severity": severity,
|
||||
# 触发条件快照 (signal/price/market 类型): 用于触发记录展示
|
||||
# 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。
|
||||
"conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [],
|
||||
"logic": rule.get("logic", "and") if rtype != "strategy" else "and",
|
||||
}
|
||||
events.append(ev)
|
||||
if self._alert_handler:
|
||||
@@ -458,11 +542,6 @@ class MonitorRuleEngine:
|
||||
if s is None:
|
||||
return []
|
||||
|
||||
# 需要历史数据的策略跳过 (实时监控不支持 history loader)
|
||||
if s.filter_history_fn:
|
||||
logger.debug("策略 %s 需要历史数据, 跳过实时监控", sid)
|
||||
return []
|
||||
|
||||
# 运行策略选股: 复用当前 enriched DataFrame 跳过数据加载
|
||||
overrides = {}
|
||||
if self._data_dir:
|
||||
@@ -471,17 +550,63 @@ class MonitorRuleEngine:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 声明 filter_history 的策略 (如反包) 需要多日历史窗口才能判定形态。
|
||||
# 旧实现因"实时监控不支持 history loader"直接跳过 → 反包等策略盘中永不触发。
|
||||
# 现接入 history_loader, 拼历史窗口 + 今日实时行情, 经 precomputed_history 喂给引擎。
|
||||
# loader 为 None (未装配) 时退回跳过, 保持旧行为, 不破坏无历史场景。
|
||||
run_kwargs: dict = {
|
||||
"as_of": _dt.date.today(),
|
||||
"overrides": overrides,
|
||||
}
|
||||
if s.filter_history_fn:
|
||||
if self._history_loader is None:
|
||||
logger.debug("策略 %s 需要历史数据但未注入 history_loader, 跳过实时监控", sid)
|
||||
return []
|
||||
try:
|
||||
today = _dt.date.today()
|
||||
lookback = max(1, getattr(s, "lookback_days", 30))
|
||||
hist_df = self._history_loader(today, lookback)
|
||||
if hist_df is None or hist_df.is_empty():
|
||||
logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid)
|
||||
return []
|
||||
# 历史窗口可能与今日已落盘数据重叠: 排掉 hist_df 中 date==today 的行,
|
||||
# 今日行情始终以实时 df 为准 (盘中逐轮更新, 最接近收盘真相)。
|
||||
# 否则 today 行重复会污染 filter_history 的 .over("symbol") 窗口判定。
|
||||
if "date" in hist_df.columns:
|
||||
hist_df = hist_df.filter(pl.col("date") != today)
|
||||
# 拼接历史窗口 + 今日实时行情 (filter_history 用 .over("symbol") 窗口, 多日天然可用)
|
||||
run_kwargs["precomputed_history"] = pl.concat(
|
||||
[hist_df, df], how="diagonal_relaxed"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("策略 %s 加载历史窗口失败, 跳过: %s", sid, e)
|
||||
return []
|
||||
else:
|
||||
# 普通策略: 复用当前 enriched DataFrame 跳过数据加载
|
||||
run_kwargs["precomputed"] = df
|
||||
|
||||
try:
|
||||
result = self._strategy_engine.run(
|
||||
sid,
|
||||
as_of=_dt.date.today(),
|
||||
precomputed=df,
|
||||
overrides=overrides,
|
||||
)
|
||||
result = self._strategy_engine.run(sid, **run_kwargs)
|
||||
except Exception as e:
|
||||
logger.warning("策略 %s 选股执行失败: %s", sid, e)
|
||||
return []
|
||||
|
||||
# 记录本轮完整选股结果 (供策略页实时回显: /cached 端点直接读取, 不落盘)。
|
||||
# 与下面的 diff 事件无关 — 无论是否产生 new_entry/dropped, 结果都该可用于回显。
|
||||
try:
|
||||
import math
|
||||
self._latest_strategy_results[sid] = {
|
||||
"total": result.total,
|
||||
"as_of": str(_dt.date.today()),
|
||||
"rows": [
|
||||
{k: (None if isinstance(v, float) and not math.isfinite(v) else v)
|
||||
for k, v in row.items()}
|
||||
for row in result.rows
|
||||
],
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
current_pool: set[str] = {r["symbol"] for r in result.rows}
|
||||
prev_pool = self._strategy_pools.get(sid)
|
||||
|
||||
@@ -577,9 +702,93 @@ class MonitorRuleEngine:
|
||||
results.append((sym, name, price, pct, hit_sigs))
|
||||
return results
|
||||
|
||||
def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]:
|
||||
"""评估连板梯队封单监控规则。
|
||||
|
||||
封单量从注入的临时列 _sealed_vol (手) 读取 (由 quote_service 评估前注入)。
|
||||
命中条件: 封单比较值 <= threshold (且封单 > 0, 排除无 depth 数据的股票)。
|
||||
涨停(direction=up) → 炸板预警; 跌停(direction=down) → 翘板预警。
|
||||
"""
|
||||
if "_sealed_vol" not in scoped.columns:
|
||||
return [] # 无封单数据 (depth 未拉取), 安全降级
|
||||
|
||||
metric = rule.get("metric", "sealed_vol")
|
||||
threshold = rule.get("threshold", 0)
|
||||
direction = rule.get("direction", "up")
|
||||
cooldown = rule.get("cooldown_seconds", 600)
|
||||
severity = rule.get("severity", "warn")
|
||||
|
||||
# 比较值: sealed_vol 直接用 (手), sealed_amount = 手 × 100股 × close
|
||||
if metric == "sealed_amount":
|
||||
cmp_expr = pl.col("_sealed_vol") * 100 * pl.col("close")
|
||||
unit = "元"
|
||||
else:
|
||||
cmp_expr = pl.col("_sealed_vol")
|
||||
unit = "手"
|
||||
|
||||
# 命中: 封单 > 0 (有数据) 且 比较值 <= 阈值
|
||||
hit = scoped.filter(
|
||||
pl.col("_sealed_vol").is_not_null()
|
||||
& (pl.col("_sealed_vol") > 0)
|
||||
& (cmp_expr <= threshold)
|
||||
)
|
||||
if hit.is_empty():
|
||||
return []
|
||||
|
||||
warn_label = "炸板预警" if direction == "up" else "翘板预警"
|
||||
events: list[dict] = []
|
||||
for row in hit.iter_rows(named=True):
|
||||
sym = row.get("symbol", "")
|
||||
key = (rule["id"], sym)
|
||||
last = self._last_fire.get(key)
|
||||
if last is not None and (now - last) < cooldown:
|
||||
continue
|
||||
self._last_fire[key] = now
|
||||
|
||||
name = row.get("name") or self._name_map.get(sym) or sym
|
||||
price = row.get("close")
|
||||
pct = row.get("change_pct")
|
||||
sealed_vol = row.get("_sealed_vol")
|
||||
# 预警封单值 (展示用)
|
||||
sealed_value = sealed_vol * 100 * (price or 0) if metric == "sealed_amount" else sealed_vol
|
||||
|
||||
# message 体现预警封单量 + 阈值
|
||||
if metric == "sealed_amount":
|
||||
sv_text = f"{sealed_value / 1e4:.0f}万{unit}"
|
||||
th_text = f"{threshold / 1e4:.0f}万{unit}"
|
||||
else:
|
||||
sv_text = f"{sealed_value:,.0f} {unit}"
|
||||
th_text = f"{threshold:,.0f} {unit}"
|
||||
message = f"{warn_label} · 封单 {sv_text} ≤ {th_text}"
|
||||
|
||||
events.append({
|
||||
"ts": int(now * 1000),
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"source": "ladder",
|
||||
"type": warn_label,
|
||||
"symbol": sym,
|
||||
"name": name,
|
||||
"message": message,
|
||||
"price": price,
|
||||
"change_pct": pct,
|
||||
"signals": [],
|
||||
"severity": severity,
|
||||
"conditions": [],
|
||||
"logic": "and",
|
||||
"sealed_value": sealed_value, # 预警封单量/额 (飞书+记录展示)
|
||||
"sealed_metric": metric,
|
||||
})
|
||||
return events
|
||||
|
||||
def _default_message(self, rule: dict, ev_type: str = "", sym: str = "",
|
||||
name: str = "", pct: Any = None) -> str:
|
||||
"""生成默认 message。策略类型按变更方向生成。"""
|
||||
name: str = "", pct: Any = None, price: Any = None,
|
||||
conditions: list[dict] | None = None) -> str:
|
||||
"""生成默认 message。
|
||||
|
||||
- strategy: 按变更方向生成 (进入/移出 + 涨跌幅)
|
||||
- signal/price/market: 条件摘要 + 现价 + 涨跌幅 (避免笼统的「信号触发」)
|
||||
"""
|
||||
rtype = rule.get("type", "signal")
|
||||
if rtype == "strategy":
|
||||
# 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分
|
||||
@@ -609,5 +818,40 @@ class MonitorRuleEngine:
|
||||
return f"策略「{sname}」移出 {name}{pct_text}"
|
||||
return f"策略「{sname}」变更"
|
||||
|
||||
name_map = {"signal": "信号触发", "price": "价格触发", "market": "市场异动"}
|
||||
return name_map.get(rtype, "监控触发")
|
||||
# signal / price / market: 条件摘要 + 现价 + 涨跌幅
|
||||
# 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2"
|
||||
cond_text = self._format_conditions_text(rule, conditions)
|
||||
price_text = f"现价 {price}" if price is not None else ""
|
||||
pct_text = ""
|
||||
if pct is not None:
|
||||
sign = "+" if pct >= 0 else ""
|
||||
pct_text = f"{sign}{pct * 100:.1f}%"
|
||||
tail = " · ".join(s for s in (price_text, pct_text) if s)
|
||||
if cond_text and tail:
|
||||
return f"{cond_text} · {tail}"
|
||||
return cond_text or tail or "监控触发"
|
||||
|
||||
@staticmethod
|
||||
def _format_conditions_text(rule: dict, conditions: list[dict] | None) -> str:
|
||||
"""把 rule.conditions 拼成可读文本 (用于 message / 推送)。
|
||||
|
||||
op=truth: 直接用信号中文名 (如 "MA20金叉")
|
||||
op=比较: 字段中文名 + 操作符 + 值 (如 "涨跌幅≥5")
|
||||
logic: and → "且", or → "或"
|
||||
"""
|
||||
conds = conditions if conditions is not None else list(rule.get("conditions", []))
|
||||
if not conds:
|
||||
return ""
|
||||
logic_word = "且" if rule.get("logic", "and") == "and" else "或"
|
||||
parts: list[str] = []
|
||||
for c in conds:
|
||||
field = c.get("field", "")
|
||||
op = c.get("op", "truth")
|
||||
value = c.get("value")
|
||||
label = _signal_cn_name(field) or field
|
||||
if op == "truth":
|
||||
parts.append(label)
|
||||
else:
|
||||
op_map = {"gte": "≥", "lte": "≤", "gt": ">", "lt": "<", "eq": "="}
|
||||
parts.append(f"{label}{op_map.get(op, op)}{value}")
|
||||
return f" {logic_word} ".join(parts)
|
||||
|
||||
@@ -26,12 +26,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ────────────────────────────────────────────────
|
||||
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market"}
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder"}
|
||||
SCOPES = {"symbols", "all", "sector"}
|
||||
LOGICS = {"and", "or"}
|
||||
DIRECTIONS = {"entry", "exit", "both"}
|
||||
SEVERITIES = {"info", "warn", "critical"}
|
||||
OPS = {">", ">=", "<", "<=", "==", "!="}
|
||||
# ladder 规则: 封单监控的指标 (量=手, 额=元)
|
||||
LADDER_METRICS = {"sealed_vol", "sealed_amount"}
|
||||
# ladder 规则: 方向 (up=涨停炸板预警, down=跌停翘板预警)
|
||||
LADDER_DIRECTIONS = {"up", "down"}
|
||||
|
||||
# 布尔信号列前缀 (op=truth 时 field 取这些)
|
||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
||||
@@ -107,6 +111,15 @@ def validate(rule: dict) -> None:
|
||||
raise ValueError("策略类型规则必须指定 strategy_id")
|
||||
if rule.get("direction", "entry") not in DIRECTIONS:
|
||||
raise ValueError(f"direction 必须是 {DIRECTIONS} 之一")
|
||||
elif rule.get("type") == "ladder":
|
||||
# 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions
|
||||
if rule.get("metric", "sealed_vol") not in LADDER_METRICS:
|
||||
raise ValueError(f"metric 必须是 {LADDER_METRICS} 之一")
|
||||
if rule.get("direction", "up") not in LADDER_DIRECTIONS:
|
||||
raise ValueError(f"direction 必须是 {LADDER_DIRECTIONS} 之一 (up=涨停炸板, down=跌停翘板)")
|
||||
thr = rule.get("threshold")
|
||||
if not isinstance(thr, (int, float)) or thr < 0:
|
||||
raise ValueError("threshold 必须是非负数字 (封单 ≤ 此值时报警)")
|
||||
else:
|
||||
# 信号/价格/市场类型: 需要 conditions
|
||||
conds = rule.get("conditions")
|
||||
@@ -158,8 +171,12 @@ def normalize(rule: dict) -> dict:
|
||||
r.setdefault("symbols", [])
|
||||
r.setdefault("sector", None)
|
||||
r.setdefault("strategy_id", None)
|
||||
r.setdefault("direction", "entry")
|
||||
# direction 默认值: ladder 用 "up", 其余用 "entry"
|
||||
r.setdefault("direction", "up" if r.get("type") == "ladder" else "entry")
|
||||
r.setdefault("conditions", [])
|
||||
# ladder 专属默认字段
|
||||
r.setdefault("metric", "sealed_vol")
|
||||
r.setdefault("threshold", 0)
|
||||
r.setdefault("logic", "and")
|
||||
r.setdefault("cooldown_seconds", 3600)
|
||||
r.setdefault("severity", "info")
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""数据源适配层 — 能力探测 / 调度 / Repository。"""
|
||||
"""TickFlow 适配层 — 能力探测 / 调度 / Repository。"""
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""数据源 SDK 封装(§5)。
|
||||
"""TickFlow SDK 封装(§5)。
|
||||
|
||||
进程内单例;Key 来源(优先级):secrets.json > .env。
|
||||
用户改 Key 后需要 `reset_clients()`,然后 `get_client()` 会拿新的。
|
||||
|
||||
5 档体系下服务器归属:
|
||||
- none 档(无 key / 无效 key) → 数据源 SDK .free()(free-api 服务器)
|
||||
- free 档(免费有效 key) → 数据源 SDK .free()(key 被 SDK 忽略,运行时走 free-api)
|
||||
- starter/pro/expert(付费 key) → 数据源 SDK (api_key=key, base_url)
|
||||
- none 档(无 key / 无效 key) → TickFlow.free()(free-api 服务器)
|
||||
- free 档(免费有效 key) → TickFlow.free()(key 被 SDK 忽略,运行时走 free-api)
|
||||
- starter/pro/expert(付费 key) → TickFlow(api_key=key, base_url)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,6 +18,7 @@ from app import secrets_store
|
||||
|
||||
_sync_client: TickFlow | None = None
|
||||
_async_client: AsyncTickFlow | None = None
|
||||
_paid_realtime_client: TickFlow | None = None
|
||||
|
||||
|
||||
# ===== 服务器归属判定 =====
|
||||
@@ -71,11 +72,27 @@ def get_async_client() -> AsyncTickFlow:
|
||||
return _async_client
|
||||
|
||||
|
||||
def get_paid_realtime_client() -> TickFlow | None:
|
||||
"""实时行情专用付费服务器客户端。
|
||||
|
||||
none/free 的历史日K仍走 get_client() 的 free-api;实时行情全部走付费服务器。
|
||||
Free 档如果有有效 key,也使用这里的 paid endpoint 调按标的实时接口。
|
||||
"""
|
||||
global _paid_realtime_client
|
||||
key = secrets_store.get_tickflow_key()
|
||||
if not key:
|
||||
return None
|
||||
if _paid_realtime_client is None:
|
||||
_paid_realtime_client = TickFlow(api_key=key, base_url=_base_url())
|
||||
return _paid_realtime_client
|
||||
|
||||
|
||||
def reset_clients() -> None:
|
||||
"""Key 变化后调用 — 让下一次 get_client() 拿新实例。"""
|
||||
global _sync_client, _async_client
|
||||
global _sync_client, _async_client, _paid_realtime_client
|
||||
_sync_client = None
|
||||
_async_client = None
|
||||
_paid_realtime_client = None
|
||||
|
||||
|
||||
def current_mode() -> str:
|
||||
|
||||
@@ -31,9 +31,8 @@ _CAPSET_CACHE_FILE = "capabilities.json"
|
||||
# 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。
|
||||
# v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量)
|
||||
# v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测)
|
||||
# v4: 5 档重构 —— 新增 none 档(无key/无效key),free 档重定义(走 free-api 服务器,
|
||||
# 仅历史日K)。判定改为复权因子分水岭:_classify_tier 接管档位判定。
|
||||
_CACHE_SCHEMA_VERSION = 4
|
||||
# v5: Free 档补充付费服务器 quote.by_symbol(10rpm/5标的),用于自选股实时监控。
|
||||
_CACHE_SCHEMA_VERSION = 5
|
||||
|
||||
# 探测用最小代价请求:挑流通性最好的 1 只标的试
|
||||
_PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市
|
||||
@@ -110,7 +109,7 @@ def _call_with_retry(fn, attempts: int = 3, backoff: float = 0.6) -> None:
|
||||
def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
|
||||
"""逐 capability 试探。需要 API key。
|
||||
|
||||
**关键**:探测始终在付费端点上进行,用 key 鉴权验证有效性。
|
||||
**关键**:探测始终在付费端点(api.tickflow.org)上进行,用 key 鉴权验证有效性。
|
||||
绝不能读旧 capabilities 缓存的档位来选服务器 —— 否则首次保存 key 时,
|
||||
旧缓存是 none 档 → get_client() 返回 free 服务器 → free 服务器忽略 key →
|
||||
乱填 key 也能拿到日K → 误判成 free 档(鸡生蛋蛋生鸡的循环依赖 bug)。
|
||||
@@ -122,7 +121,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
|
||||
|
||||
key = secrets_store.get_tickflow_key()
|
||||
# 探测专用客户端:强制走付费端点验证 key。
|
||||
# base_url 用用户自定义端点(若已配置测速切换),否则默认付费端点。
|
||||
# base_url 用用户自定义端点(若已配置测速切换),否则默认 api.tickflow.org。
|
||||
probe_base = _base_url() or PAID_ENDPOINT
|
||||
tf = TickFlow(api_key=key, base_url=probe_base)
|
||||
available: dict[Cap, CapabilityLimits] = {}
|
||||
@@ -278,7 +277,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
|
||||
_persist(capset, "None", log=probe_log, missing=[], extras=[], invalid_key=True)
|
||||
return capset
|
||||
if classified.is_free:
|
||||
# 免费有效 key:能力按 free 档(= none 档能力,走 free-api 服务器)
|
||||
# 免费有效 key:按 free 档能力持久化(日K free-api + 按标的实时)。
|
||||
capset = _tier_to_capset(tiers["free"])
|
||||
_persist(capset, "Free", log=probe_log + ["✓ 免费有效 key(运行时走 free-api 服务器)"], missing=[], extras=[])
|
||||
return capset
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""标的池(Universe)定义(§6.3)。
|
||||
|
||||
Phase 1 实现:
|
||||
- 常用指数成份(沪深 300 / 中证 500 / 上证 50)用数据源 `quote.pool` 端点拉取并缓存
|
||||
- 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存
|
||||
- 全 A 通过 instruments.batch 获取
|
||||
- 自选池 = 用户的 watchlist
|
||||
"""
|
||||
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index", "watchlist"]
|
||||
|
||||
# 数据源 universe id 是内部命名(见 tf.universes.list())。
|
||||
# TickFlow universe id 是它内部命名(见 tf.universes.list())。
|
||||
# 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。
|
||||
# 常见名:沪深300 / 中证500 / 上证50 / 全 A
|
||||
_POOL_NAME_HINTS = {
|
||||
@@ -70,7 +70,7 @@ def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]:
|
||||
|
||||
|
||||
def _fetch_pool(pool_id: PoolId) -> list[str]:
|
||||
"""从数据源拉取池成份。
|
||||
"""从 TickFlow 拉取池成份。
|
||||
|
||||
实现:先用 universes.list 找到 universe id,再 quotes.get_by_universes 拉成份。
|
||||
"""
|
||||
@@ -79,7 +79,7 @@ def _fetch_pool(pool_id: PoolId) -> list[str]:
|
||||
if pool_id in _POOL_NAME_HINTS:
|
||||
uid = _find_universe_id(_POOL_NAME_HINTS[pool_id])
|
||||
if not uid:
|
||||
logger.warning("无法在数据源 universes 列表里匹配到 %s", pool_id)
|
||||
logger.warning("无法在 TickFlow universes 列表里匹配到 %s", pool_id)
|
||||
return []
|
||||
try:
|
||||
df = tf.quotes.get_by_universes([uid], as_dataframe=True)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -32,17 +33,26 @@ class DataStore:
|
||||
self.data_dir = Path(data_dir or settings.data_dir)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 一次性数据迁移: 旧桌面版把数据放在 exe 同级的兄弟目录 TickFlowStockPanel_Data/,
|
||||
# 新版改为 {app}/data/。老用户首次启动时自动把旧数据搬过来, 无感升级。
|
||||
self._migrate_legacy_data_dir()
|
||||
|
||||
# 关键子目录(§7.2)
|
||||
for sub in (
|
||||
"kline_daily",
|
||||
"kline_daily_enriched",
|
||||
"kline_index_daily",
|
||||
"kline_index_enriched",
|
||||
"kline_etf_daily",
|
||||
"kline_etf_enriched",
|
||||
"kline_etf_minute",
|
||||
"kline_minute",
|
||||
"adj_factor",
|
||||
"adj_factor_etf",
|
||||
"financials",
|
||||
"instruments",
|
||||
"instruments_index",
|
||||
"instruments_etf",
|
||||
"instruments_ext",
|
||||
"kline_ext",
|
||||
"pools",
|
||||
@@ -62,6 +72,64 @@ class DataStore:
|
||||
self.db = duckdb.connect(database=":memory:")
|
||||
self._register_views()
|
||||
|
||||
def _migrate_legacy_data_dir(self) -> None:
|
||||
"""把旧桌面版数据目录 (<安装目录>/../TickFlowStockPanel_Data/) 迁移到新位置 (<安装目录>/data/)。
|
||||
|
||||
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
|
||||
新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录,
|
||||
若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。
|
||||
|
||||
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
|
||||
1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。
|
||||
2. 新旧目录都已有数据 (用户在两套路径都跑过) → 不自动搬, 仅记日志, 避免覆盖。
|
||||
3. 旧目录不存在 → 新装用户, 无需迁移。
|
||||
所有异常都吞掉只记警告 —— 数据迁移失败绝不能阻塞应用启动。
|
||||
"""
|
||||
# 仅打包桌面版需要迁移; 开发/Docker 模式 _PROJECT_ROOT/data 本就是唯一路径
|
||||
if not getattr(sys, "frozen", False):
|
||||
return
|
||||
|
||||
import shutil
|
||||
|
||||
try:
|
||||
legacy_dir = self.data_dir.parent / "TickFlowStockPanel_Data"
|
||||
if not legacy_dir.exists():
|
||||
return # 新装用户, 无旧数据
|
||||
|
||||
# 新 data/ 目录里已有实质性内容 → 用户已在新路径跑过, 不覆盖
|
||||
# (用 .parquet 作为"有真实数据"的判据, 避免空子目录误判)
|
||||
has_new_data = any(self.data_dir.rglob("*.parquet")) or any(
|
||||
self.data_dir.rglob("*.jsonl")
|
||||
)
|
||||
if has_new_data:
|
||||
logger.info(
|
||||
"legacy data dir %s exists but new %s already has data, skip migration",
|
||||
legacy_dir, self.data_dir,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info("migrating legacy data %s -> %s", legacy_dir, self.data_dir)
|
||||
# 逐项 move 而非整目录 move: data/ 可能已被 __init__ 创建了空子目录,
|
||||
# 直接 shutil.move(legacy, data) 会因目标已存在失败。
|
||||
for item in legacy_dir.iterdir():
|
||||
dest = self.data_dir / item.name
|
||||
if dest.exists():
|
||||
# 同名子目录 (如 kline_daily): 合并内容
|
||||
if dest.is_dir():
|
||||
shutil.move(str(item), str(dest / item.name))
|
||||
else:
|
||||
item.unlink() # 同名文件, 以新路径为准, 删旧
|
||||
else:
|
||||
shutil.move(str(item), str(dest))
|
||||
# 搬完后清理空的旧目录
|
||||
try:
|
||||
shutil.rmtree(legacy_dir)
|
||||
except OSError:
|
||||
logger.warning("legacy dir %s not empty, kept", legacy_dir)
|
||||
logger.info("legacy data migration done")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("legacy data migration failed (startup continues): %s", e)
|
||||
|
||||
def _register_views(self) -> None:
|
||||
"""把 Parquet 目录挂载为 DuckDB 视图(§7.3)。"""
|
||||
d = self.data_dir.as_posix()
|
||||
@@ -74,14 +142,24 @@ class DataStore:
|
||||
SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_index_enriched AS
|
||||
SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_etf_daily AS
|
||||
SELECT * FROM read_parquet('{d}/kline_etf_daily/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_etf_enriched AS
|
||||
SELECT * FROM read_parquet('{d}/kline_etf_enriched/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_etf_minute AS
|
||||
SELECT * FROM read_parquet('{d}/kline_etf_minute/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_minute AS
|
||||
SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW adj_factor AS
|
||||
SELECT * FROM read_parquet('{d}/adj_factor/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW adj_factor_etf AS
|
||||
SELECT * FROM read_parquet('{d}/adj_factor_etf/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW instruments AS
|
||||
SELECT * FROM read_parquet('{d}/instruments/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW instruments_index AS
|
||||
SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW instruments_etf AS
|
||||
SELECT * FROM read_parquet('{d}/instruments_etf/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW instruments_ext AS
|
||||
SELECT * FROM read_parquet('{d}/instruments_ext/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_ext AS
|
||||
@@ -104,6 +182,91 @@ class DataStore:
|
||||
self.db.execute(sql)
|
||||
except duckdb.IOException:
|
||||
logger.debug("view registration skipped (no parquet yet): %s", sql[:60])
|
||||
self._register_unified_views()
|
||||
|
||||
def _has_parquet(self, subdir: str) -> bool:
|
||||
return any((self.data_dir / subdir).rglob("*.parquet"))
|
||||
|
||||
def _register_unified_views(self) -> None:
|
||||
"""Register optional all-asset views when their backing parquet exists.
|
||||
|
||||
Physical storage remains split for performance and compatibility. These
|
||||
views are convenience read models for new APIs/features.
|
||||
"""
|
||||
daily_parts: list[str] = []
|
||||
enriched_parts: list[str] = []
|
||||
minute_parts: list[str] = []
|
||||
inst_parts: list[str] = []
|
||||
|
||||
if self._has_parquet("kline_daily"):
|
||||
daily_parts.append("""
|
||||
SELECT symbol, date, open, high, low, close, volume, amount,
|
||||
'stock' AS asset_type, 'tickflow' AS source
|
||||
FROM kline_daily
|
||||
""")
|
||||
if self._has_parquet("kline_index_daily"):
|
||||
daily_parts.append("""
|
||||
SELECT symbol, date, open, high, low, close, volume, amount,
|
||||
'index' AS asset_type, 'tickflow' AS source
|
||||
FROM kline_index_daily
|
||||
""")
|
||||
if self._has_parquet("kline_etf_daily"):
|
||||
daily_parts.append("""
|
||||
SELECT symbol, date, open, high, low, close, volume, amount,
|
||||
'etf' AS asset_type, 'tickflow' AS source
|
||||
FROM kline_etf_daily
|
||||
""")
|
||||
|
||||
if self._has_parquet("kline_daily_enriched"):
|
||||
enriched_parts.append("SELECT *, 'stock' AS asset_type, 'tickflow' AS source FROM kline_enriched")
|
||||
if self._has_parquet("kline_index_enriched"):
|
||||
enriched_parts.append("SELECT *, 'index' AS asset_type, 'tickflow' AS source FROM kline_index_enriched")
|
||||
if self._has_parquet("kline_etf_enriched"):
|
||||
enriched_parts.append("SELECT *, 'etf' AS asset_type, 'tickflow' AS source FROM kline_etf_enriched")
|
||||
|
||||
if self._has_parquet("kline_minute"):
|
||||
minute_parts.append("""
|
||||
SELECT symbol, datetime, open, high, low, close, volume, amount,
|
||||
'stock' AS asset_type, 'tickflow' AS source
|
||||
FROM kline_minute
|
||||
""")
|
||||
if self._has_parquet("kline_etf_minute"):
|
||||
minute_parts.append("""
|
||||
SELECT symbol, datetime, open, high, low, close, volume, amount,
|
||||
'etf' AS asset_type, 'tickflow' AS source
|
||||
FROM kline_etf_minute
|
||||
""")
|
||||
|
||||
if self._has_parquet("instruments"):
|
||||
inst_parts.append("""
|
||||
SELECT symbol, name, code, exchange, 'stock' AS asset_type, 'tickflow' AS source
|
||||
FROM instruments
|
||||
""")
|
||||
if self._has_parquet("instruments_index"):
|
||||
inst_parts.append("""
|
||||
SELECT symbol, name, code, NULL AS exchange, 'index' AS asset_type, 'tickflow' AS source
|
||||
FROM instruments_index
|
||||
WHERE coalesce(asset_type, 'index') != 'etf'
|
||||
""")
|
||||
if self._has_parquet("instruments_etf"):
|
||||
inst_parts.append("""
|
||||
SELECT symbol, name, code, NULL AS exchange, 'etf' AS asset_type, 'tickflow' AS source
|
||||
FROM instruments_etf
|
||||
""")
|
||||
|
||||
unions = {
|
||||
"kline_daily_all": daily_parts,
|
||||
"kline_enriched_all": enriched_parts,
|
||||
"kline_minute_all": minute_parts,
|
||||
"instruments_all": inst_parts,
|
||||
}
|
||||
for name, parts in unions.items():
|
||||
if not parts:
|
||||
continue
|
||||
try:
|
||||
self.db.execute(f"CREATE OR REPLACE VIEW {name} AS " + " UNION ALL BY NAME ".join(parts))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("unified view %s skipped: %s", name, e)
|
||||
|
||||
|
||||
class KlineRepository:
|
||||
@@ -119,18 +282,27 @@ class KlineRepository:
|
||||
self._enriched_cache_date: date | None = None
|
||||
self._live_agg_cache: pl.DataFrame | None = None # 预计算聚合表 (~5500行)
|
||||
self._live_agg_cache_date: date | None = None
|
||||
self._live_agg_check_date: date | None = None # 上次跨日校验时的 today (快路径节流)
|
||||
self._instruments_cache: pl.DataFrame | None = None
|
||||
# 完整 enriched 历史 (含所有指标, 供 filter_history 策略使用)
|
||||
self._enriched_history_cache: pl.DataFrame | None = None # ~100万行
|
||||
self._enriched_history_start: date | None = None
|
||||
self._index_instruments_cache: pl.DataFrame | None = None
|
||||
self._etf_enriched_cache: pl.DataFrame | None = None
|
||||
self._etf_enriched_cache_date: date | None = None
|
||||
self._etf_live_agg_cache: pl.DataFrame | None = None
|
||||
self._etf_live_agg_cache_date: date | None = None
|
||||
self._etf_instruments_cache: pl.DataFrame | None = None
|
||||
|
||||
# parquet glob 路径
|
||||
self._enriched_glob = str(store.data_dir / "kline_daily_enriched" / "**" / "*.parquet")
|
||||
self._index_enriched_glob = str(store.data_dir / "kline_index_enriched" / "**" / "*.parquet")
|
||||
self._etf_enriched_glob = str(store.data_dir / "kline_etf_enriched" / "**" / "*.parquet")
|
||||
self._minute_glob = str(store.data_dir / "kline_minute" / "**" / "*.parquet")
|
||||
self._etf_minute_glob = str(store.data_dir / "kline_etf_minute" / "**" / "*.parquet")
|
||||
self._inst_glob = str(store.data_dir / "instruments" / "**" / "*.parquet")
|
||||
self._index_inst_glob = str(store.data_dir / "instruments_index" / "**" / "*.parquet")
|
||||
self._etf_inst_glob = str(store.data_dir / "instruments_etf" / "**" / "*.parquet")
|
||||
|
||||
def execute_all(self, sql: str, params: list | None = None) -> list[tuple]:
|
||||
"""线程安全的 SELECT → fetchall。DuckDB 单 connection 非线程安全,所有读路径须走此方法。"""
|
||||
@@ -150,6 +322,7 @@ class KlineRepository:
|
||||
"""刷新 Polars 缓存。在 pipeline 完成后、服务启动时调用。"""
|
||||
self._refresh_instruments()
|
||||
self._refresh_index_instruments()
|
||||
self._refresh_etf_instruments()
|
||||
self._refresh_enriched()
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
@@ -165,8 +338,14 @@ class KlineRepository:
|
||||
self._enriched_history_start = None
|
||||
self._live_agg_cache = None
|
||||
self._live_agg_cache_date = None
|
||||
self._live_agg_check_date = None
|
||||
self._instruments_cache = None
|
||||
self._index_instruments_cache = None
|
||||
self._etf_enriched_cache = None
|
||||
self._etf_enriched_cache_date = None
|
||||
self._etf_live_agg_cache = None
|
||||
self._etf_live_agg_cache_date = None
|
||||
self._etf_instruments_cache = None
|
||||
|
||||
def _refresh_enriched(self) -> None:
|
||||
"""从 parquet 加载 enriched 最新日到内存 + 构建聚合表。
|
||||
@@ -462,6 +641,47 @@ class KlineRepository:
|
||||
|
||||
return df_hist, agg_a
|
||||
|
||||
def _refresh_etf_enriched(self) -> None:
|
||||
"""从 ETF enriched parquet 加载最新日到内存缓存。"""
|
||||
try:
|
||||
enriched_dir = self.store.data_dir / "kline_etf_enriched"
|
||||
dates = sorted(
|
||||
p.name[5:] for p in enriched_dir.glob("date=*")
|
||||
if p.is_dir() and p.name.startswith("date=")
|
||||
) if enriched_dir.exists() else []
|
||||
if not dates:
|
||||
self._etf_enriched_cache = None
|
||||
self._etf_enriched_cache_date = None
|
||||
return
|
||||
latest = date.fromisoformat(dates[-1])
|
||||
target_parquet = enriched_dir / f"date={dates[-1]}" / "part.parquet"
|
||||
df_latest = pl.read_parquet(target_parquet)
|
||||
if df_latest.is_empty():
|
||||
return
|
||||
|
||||
from datetime import timedelta
|
||||
from app.indicators.pipeline import compute_indicators, compute_signals
|
||||
start_full = latest - timedelta(days=300)
|
||||
read_cols = [c for c in ["symbol", "date", "open", "high", "low", "close",
|
||||
"volume", "amount", "raw_close", "raw_high", "raw_low"]
|
||||
if c in df_latest.columns]
|
||||
df_hist = (
|
||||
pl.scan_parquet(self._etf_enriched_glob,
|
||||
cast_options=pl.ScanCastOptions(integer_cast="allow-float"))
|
||||
.filter(pl.col("date") >= start_full)
|
||||
.select(read_cols)
|
||||
.sort(["symbol", "date"])
|
||||
.collect()
|
||||
)
|
||||
if df_hist.is_empty():
|
||||
self._etf_enriched_cache = df_latest.sort(["symbol"])
|
||||
else:
|
||||
df_full = compute_signals(compute_indicators(df_hist))
|
||||
self._etf_enriched_cache = df_full.filter(pl.col("date") == latest).sort(["symbol"])
|
||||
self._etf_enriched_cache_date = latest
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("ETF enriched 缓存刷新跳过: %s", e)
|
||||
|
||||
def _refresh_instruments(self) -> None:
|
||||
"""加载 instruments 到内存。"""
|
||||
try:
|
||||
@@ -482,6 +702,28 @@ class KlineRepository:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("index instruments 缓存刷新跳过: %s", e)
|
||||
|
||||
def _refresh_etf_instruments(self) -> None:
|
||||
"""加载 ETF instruments 到内存;兼容旧版 instruments_index 中的 ETF。"""
|
||||
parts: list[pl.DataFrame] = []
|
||||
try:
|
||||
df = pl.scan_parquet(self._etf_inst_glob).collect()
|
||||
if not df.is_empty():
|
||||
parts.append(df)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("etf instruments 缓存刷新跳过(new): %s", e)
|
||||
try:
|
||||
legacy = self.get_index_instruments()
|
||||
if not legacy.is_empty() and "asset_type" in legacy.columns:
|
||||
legacy = legacy.filter(pl.col("asset_type") == "etf")
|
||||
if not legacy.is_empty():
|
||||
parts.append(legacy)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("etf instruments legacy fallback skipped: %s", e)
|
||||
if parts:
|
||||
df_all = pl.concat(parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
|
||||
self._etf_instruments_cache = df_all
|
||||
logger.info("ETF instruments 缓存已加载: %d 只", len(df_all))
|
||||
|
||||
def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]:
|
||||
"""返回缓存的 enriched 最新日 DataFrame + 日期。如无缓存则懒加载。"""
|
||||
if self._enriched_cache is None:
|
||||
@@ -490,6 +732,18 @@ class KlineRepository:
|
||||
return pl.DataFrame(), self._enriched_cache_date
|
||||
return self._enriched_cache, self._enriched_cache_date
|
||||
|
||||
def get_enriched_latest_asset(self, asset_type: str) -> tuple[pl.DataFrame, date | None]:
|
||||
"""按资产类型返回最新 enriched 缓存。stock 保持旧缓存语义。"""
|
||||
if asset_type == "stock":
|
||||
return self.get_enriched_latest()
|
||||
if asset_type == "etf":
|
||||
if self._etf_enriched_cache is None:
|
||||
self._refresh_etf_enriched()
|
||||
if self._etf_enriched_cache is None:
|
||||
return pl.DataFrame(), self._etf_enriched_cache_date
|
||||
return self._etf_enriched_cache, self._etf_enriched_cache_date
|
||||
return pl.DataFrame(), None
|
||||
|
||||
def get_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame | None:
|
||||
"""返回预计算的 enriched 历史数据 (仅 lookback 范围, 不含 warmup)。
|
||||
|
||||
@@ -544,9 +798,36 @@ class KlineRepository:
|
||||
return df.sort(["symbol", "date"])
|
||||
|
||||
def get_live_agg(self) -> pl.DataFrame:
|
||||
"""返回盘中实时指标预计算聚合表。如无缓存则懒加载。"""
|
||||
"""返回盘中实时指标预计算聚合表。如无缓存则懒加载。
|
||||
|
||||
live_agg 的核心列 _prev_consec_up/down (昨日连板数) 取自基准日 enriched。
|
||||
基准日由 _live_agg_baseline_date 决定: 盘中(today 有实时分区) 取上一交易日,
|
||||
非盘中(磁盘最新日 < today) 取该最新日本身。一旦跨日, 期望基准日会前移,
|
||||
旧缓存会把连板数整体少算一档, 故这里除首次懒加载外还要校验基准日是否仍
|
||||
符合当前预期, 不符则重建 (无需等盘后管道刷缓存)。
|
||||
|
||||
性能: get_live_agg 被每轮实时行情调用 (expert 档 1s 一次)。跨日只在
|
||||
date.today() 翻天时发生, 故先用 today 做廉价的 fast-path (μs 级),
|
||||
仅当 today 变化时才查磁盘确认 (DuckDB 扫 132 万行约 100ms+) 并按需重建。
|
||||
"""
|
||||
if self._live_agg_cache is None:
|
||||
self._refresh_enriched()
|
||||
self._live_agg_check_date = date.today() # 刚建过, 当天不必再查磁盘
|
||||
else:
|
||||
today = date.today()
|
||||
if self._live_agg_check_date != today:
|
||||
# today 翻天了 (次日开盘首次轮询): 校验基准日是否需要前移重建。
|
||||
# 同一天内多次调用直接跳过, 避免每轮都扫 parquet。
|
||||
self._live_agg_check_date = today
|
||||
disk_latest = self._latest_enriched_date_duckdb()
|
||||
if disk_latest is not None:
|
||||
expected = self._live_agg_baseline_date(disk_latest)
|
||||
if self._live_agg_cache_date != expected:
|
||||
logger.info(
|
||||
"live_agg 跨日失效, 重建: 缓存基准=%s, 期望基准=%s",
|
||||
self._live_agg_cache_date, expected,
|
||||
)
|
||||
self._refresh_enriched()
|
||||
if self._live_agg_cache is None:
|
||||
return pl.DataFrame()
|
||||
return self._live_agg_cache
|
||||
@@ -567,6 +848,27 @@ class KlineRepository:
|
||||
return pl.DataFrame()
|
||||
return self._index_instruments_cache
|
||||
|
||||
def get_etf_instruments(self) -> pl.DataFrame:
|
||||
"""返回缓存的 ETF instruments DataFrame;兼容旧版 instruments_index 中的 ETF。"""
|
||||
if self._etf_instruments_cache is None:
|
||||
self._refresh_etf_instruments()
|
||||
if self._etf_instruments_cache is None:
|
||||
return pl.DataFrame()
|
||||
return self._etf_instruments_cache
|
||||
|
||||
def get_instruments_asset(self, asset_type: str) -> pl.DataFrame:
|
||||
"""按资产类型返回 instruments;老 stock 路径保持原样。"""
|
||||
if asset_type == "stock":
|
||||
return self.get_instruments()
|
||||
if asset_type == "index":
|
||||
df = self.get_index_instruments()
|
||||
if not df.is_empty() and "asset_type" in df.columns:
|
||||
return df.filter(pl.col("asset_type") != "etf")
|
||||
return df
|
||||
if asset_type == "etf":
|
||||
return self.get_etf_instruments()
|
||||
return pl.DataFrame()
|
||||
|
||||
def get_index_symbol_set(self) -> set[str]:
|
||||
"""返回已缓存指数 symbol 集合。"""
|
||||
df = self.get_index_instruments()
|
||||
@@ -656,6 +958,45 @@ class KlineRepository:
|
||||
df = df.select(existing)
|
||||
return df
|
||||
|
||||
def get_etf_daily(
|
||||
self,
|
||||
symbol: str,
|
||||
start: date,
|
||||
end: date,
|
||||
columns: list[str] | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""ETF 日K查询 — 优先读独立 ETF enriched,兼容旧版 index enriched 中的 ETF。"""
|
||||
from datetime import timedelta
|
||||
|
||||
warmup_start = start - timedelta(days=150)
|
||||
df = self._scan_etf_daily_symbol(symbol, warmup_start, end, None)
|
||||
if df.is_empty():
|
||||
# 旧版 ETF 曾存入 kline_index_enriched;没有独立数据时回退读取。
|
||||
df = self._scan_index_daily_symbol(symbol, warmup_start, end, None)
|
||||
if not df.is_empty():
|
||||
df = self._compute_index_enriched_range(df)
|
||||
df = df.filter((pl.col("date") >= start) & (pl.col("date") <= end))
|
||||
if columns and not df.is_empty():
|
||||
existing = [c for c in columns if c in df.columns]
|
||||
df = df.select(existing)
|
||||
return df
|
||||
|
||||
def get_daily_asset(
|
||||
self,
|
||||
asset_type: str,
|
||||
symbol: str,
|
||||
start: date,
|
||||
end: date,
|
||||
columns: list[str] | None = None,
|
||||
) -> pl.DataFrame:
|
||||
if asset_type == "stock":
|
||||
return self.get_daily(symbol, start, end, columns)
|
||||
if asset_type == "index":
|
||||
return self.get_index_daily(symbol, start, end, columns)
|
||||
if asset_type == "etf":
|
||||
return self.get_etf_daily(symbol, start, end, columns)
|
||||
return pl.DataFrame()
|
||||
|
||||
def get_minute(
|
||||
self,
|
||||
symbol: str,
|
||||
@@ -770,6 +1111,23 @@ class KlineRepository:
|
||||
logger.warning("指数日K查询失败: %s", e)
|
||||
return pl.DataFrame()
|
||||
|
||||
def _scan_etf_daily_symbol(self, symbol: str, start: date, end: date, columns: list[str] | None) -> pl.DataFrame:
|
||||
try:
|
||||
lf = pl.scan_parquet(self._etf_enriched_glob,
|
||||
cast_options=pl.ScanCastOptions(integer_cast="allow-float")).filter(
|
||||
(pl.col("symbol") == symbol)
|
||||
& (pl.col("date") >= start)
|
||||
& (pl.col("date") <= end)
|
||||
).sort("date")
|
||||
if columns:
|
||||
schema_names = lf.collect_schema().names()
|
||||
existing = [c for c in columns if c in schema_names]
|
||||
lf = lf.select(existing)
|
||||
return lf.collect()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("ETF 日K查询跳过: %s", e)
|
||||
return pl.DataFrame()
|
||||
|
||||
def _merge_cached_and_scan(
|
||||
self,
|
||||
cached: pl.DataFrame,
|
||||
@@ -911,6 +1269,39 @@ class KlineRepository:
|
||||
df_storage = df.select(storage_cols)
|
||||
self._write_daily_partition(df_storage, "kline_index_enriched")
|
||||
|
||||
def append_etf_daily(self, df: pl.DataFrame) -> None:
|
||||
"""按日分区写入 ETF 日K数据 (merge-upsert)。"""
|
||||
if df.is_empty():
|
||||
return
|
||||
self._write_daily_partition(df, "kline_etf_daily")
|
||||
|
||||
def append_etf_enriched(self, df: pl.DataFrame) -> None:
|
||||
"""按日分区写入 ETF enriched 数据。磁盘仅写入基础行情窄表。"""
|
||||
if df.is_empty():
|
||||
return
|
||||
from app.indicators.pipeline import ENRICHED_STORAGE_COLS
|
||||
storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns]
|
||||
df_storage = df.select(storage_cols)
|
||||
self._write_daily_partition(df_storage, "kline_etf_enriched")
|
||||
|
||||
def append_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None:
|
||||
"""按资产类型写入日K;stock/index 保持旧目录兼容。"""
|
||||
if asset_type == "stock":
|
||||
self.append_daily(df)
|
||||
elif asset_type == "index":
|
||||
self.append_index_daily(df)
|
||||
elif asset_type == "etf":
|
||||
self.append_etf_daily(df)
|
||||
|
||||
def append_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None:
|
||||
"""按资产类型写入 enriched;stock/index 保持旧目录兼容。"""
|
||||
if asset_type == "stock":
|
||||
self.append_enriched(df)
|
||||
elif asset_type == "index":
|
||||
self.append_index_enriched(df)
|
||||
elif asset_type == "etf":
|
||||
self.append_etf_enriched(df)
|
||||
|
||||
def save_index_instruments(self, df: pl.DataFrame) -> None:
|
||||
"""保存指数标的维表。"""
|
||||
if df.is_empty() or "symbol" not in df.columns:
|
||||
@@ -919,8 +1310,21 @@ class KlineRepository:
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out)
|
||||
self._index_instruments_cache = None
|
||||
self._etf_instruments_cache = None
|
||||
self._refresh_index_instruments()
|
||||
|
||||
def save_etf_instruments(self, df: pl.DataFrame) -> None:
|
||||
"""保存 ETF 标的维表到独立目录。"""
|
||||
if df.is_empty() or "symbol" not in df.columns:
|
||||
return
|
||||
if "asset_type" not in df.columns:
|
||||
df = df.with_columns(pl.lit("etf").alias("asset_type"))
|
||||
out = self.store.data_dir / "instruments_etf" / "instruments_etf.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out)
|
||||
self._etf_instruments_cache = None
|
||||
self._refresh_etf_instruments()
|
||||
|
||||
def refresh_index_views(self) -> None:
|
||||
"""刷新指数相关 DuckDB 视图。"""
|
||||
d = self.store.data_dir.as_posix()
|
||||
@@ -929,15 +1333,23 @@ class KlineRepository:
|
||||
SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_index_enriched AS
|
||||
SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_etf_daily AS
|
||||
SELECT * FROM read_parquet('{d}/kline_etf_daily/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW kline_etf_enriched AS
|
||||
SELECT * FROM read_parquet('{d}/kline_etf_enriched/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW instruments_index AS
|
||||
SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""",
|
||||
f"""CREATE OR REPLACE VIEW instruments_etf AS
|
||||
SELECT * FROM read_parquet('{d}/instruments_etf/**/*.parquet', union_by_name=true)""",
|
||||
]
|
||||
for sql in statements:
|
||||
try:
|
||||
with self._lock:
|
||||
self.db.execute(sql)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("index view refresh skipped: %s", e)
|
||||
logger.debug("index/etf view refresh skipped: %s", e)
|
||||
with self._lock:
|
||||
self.store._register_unified_views()
|
||||
|
||||
def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None:
|
||||
"""按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。"""
|
||||
@@ -955,11 +1367,92 @@ class KlineRepository:
|
||||
date_df = date_df.sort(["symbol", "date"])
|
||||
date_df.write_parquet(out)
|
||||
|
||||
def merge_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None:
|
||||
"""按 symbol 合并当天指定资产日K分区。用于少量自选实时,不覆盖全市场。"""
|
||||
if df.is_empty() or "date" not in df.columns:
|
||||
return
|
||||
table = {
|
||||
"stock": "kline_daily",
|
||||
"index": "kline_index_daily",
|
||||
"etf": "kline_etf_daily",
|
||||
}.get(asset_type)
|
||||
if not table:
|
||||
return
|
||||
base = self.store.data_dir / table
|
||||
dt = df["date"][0]
|
||||
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
|
||||
out = base / f"date={ds}" / "part.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
date_df = df.sort(["symbol", "date"])
|
||||
if out.exists():
|
||||
existing = pl.read_parquet(out)
|
||||
date_df = pl.concat([existing, date_df], how="diagonal_relaxed").unique(
|
||||
subset=["symbol", "date"], keep="last"
|
||||
)
|
||||
date_df.sort(["symbol", "date"]).write_parquet(out)
|
||||
|
||||
def merge_live_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None:
|
||||
"""按 symbol 合并当天 enriched 分区和内存缓存。用于少量自选实时。"""
|
||||
if df.is_empty() or "date" not in df.columns:
|
||||
return
|
||||
dt = df["date"][0]
|
||||
if asset_type == "stock":
|
||||
table = "kline_daily_enriched"
|
||||
existing_cache = self._enriched_cache if self._enriched_cache_date == dt else pl.DataFrame()
|
||||
elif asset_type == "etf":
|
||||
table = "kline_etf_enriched"
|
||||
existing_cache = self._etf_enriched_cache if self._etf_enriched_cache_date == dt else pl.DataFrame()
|
||||
elif asset_type == "index":
|
||||
table = "kline_index_enriched"
|
||||
existing_cache = pl.DataFrame()
|
||||
else:
|
||||
return
|
||||
|
||||
merged_cache = df
|
||||
if existing_cache is not None and not existing_cache.is_empty():
|
||||
merged_cache = pl.concat([existing_cache, df], how="diagonal_relaxed").unique(
|
||||
subset=["symbol", "date"], keep="last"
|
||||
)
|
||||
merged_cache = merged_cache.sort(["symbol"])
|
||||
if asset_type == "stock":
|
||||
self._enriched_cache = merged_cache
|
||||
self._enriched_cache_date = dt
|
||||
elif asset_type == "etf":
|
||||
self._etf_enriched_cache = merged_cache
|
||||
self._etf_enriched_cache_date = dt
|
||||
|
||||
from app.indicators.pipeline import ENRICHED_STORAGE_COLS
|
||||
storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns]
|
||||
df_storage = df.select(storage_cols).sort(["symbol"])
|
||||
base = self.store.data_dir / table
|
||||
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
|
||||
out = base / f"date={ds}" / "part.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
if out.exists():
|
||||
existing = pl.read_parquet(out)
|
||||
df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique(
|
||||
subset=["symbol", "date"], keep="last"
|
||||
)
|
||||
df_storage.sort(["symbol"]).write_parquet(out)
|
||||
|
||||
def flush_live_daily(self, df: pl.DataFrame) -> None:
|
||||
"""覆写当天 kline_daily 分区 (实时行情落盘, 非merge)。"""
|
||||
if df.is_empty() or "date" not in df.columns:
|
||||
return
|
||||
base = self.store.data_dir / "kline_daily"
|
||||
self.flush_live_daily_asset("stock", df)
|
||||
|
||||
def flush_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None:
|
||||
"""覆写当天指定资产日K分区 (实时行情落盘, 非merge)。"""
|
||||
if df.is_empty() or "date" not in df.columns:
|
||||
return
|
||||
table = {
|
||||
"stock": "kline_daily",
|
||||
"index": "kline_index_daily",
|
||||
"etf": "kline_etf_daily",
|
||||
}.get(asset_type)
|
||||
if not table:
|
||||
return
|
||||
base = self.store.data_dir / table
|
||||
dt = df["date"][0]
|
||||
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
|
||||
out = base / f"date={ds}" / "part.parquet"
|
||||
@@ -971,17 +1464,30 @@ class KlineRepository:
|
||||
|
||||
内存缓存保留完整指标列供各服务使用,磁盘仅写入 14 列存储列。
|
||||
"""
|
||||
self.flush_live_enriched_asset("stock", df)
|
||||
|
||||
def flush_live_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None:
|
||||
"""覆写当天指定资产 enriched 分区 (实时 enriched 落盘, 非merge)。"""
|
||||
if df.is_empty() or "date" not in df.columns:
|
||||
return
|
||||
# 内存缓存: 保留完整 66 列
|
||||
self._enriched_cache = df.sort(["symbol"])
|
||||
dt = df["date"][0]
|
||||
self._enriched_cache_date = dt
|
||||
# 磁盘写入: 仅 14 列存储列
|
||||
if asset_type == "stock":
|
||||
self._enriched_cache = df.sort(["symbol"])
|
||||
self._enriched_cache_date = dt
|
||||
table = "kline_daily_enriched"
|
||||
elif asset_type == "etf":
|
||||
self._etf_enriched_cache = df.sort(["symbol"])
|
||||
self._etf_enriched_cache_date = dt
|
||||
table = "kline_etf_enriched"
|
||||
elif asset_type == "index":
|
||||
table = "kline_index_enriched"
|
||||
else:
|
||||
return
|
||||
|
||||
from app.indicators.pipeline import ENRICHED_STORAGE_COLS
|
||||
storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns]
|
||||
df_storage = df.select(storage_cols).sort(["symbol"])
|
||||
base = self.store.data_dir / "kline_daily_enriched"
|
||||
base = self.store.data_dir / table
|
||||
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
|
||||
out = base / f"date={ds}" / "part.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""管理员创建的访问 UUID 持久化存储。
|
||||
|
||||
位置:`data/user_data/access_uuids.json`,权限 0600。
|
||||
与 secrets.json 分离,避免凭据文件过大且便于独立管理。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "access_uuids.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _load() -> list[dict]:
|
||||
p = _path()
|
||||
if p.exists():
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("access_uuids.json malformed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _save(records: list[dict]) -> list[dict]:
|
||||
p = _path()
|
||||
p.write_text(json.dumps(records, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(p, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return records
|
||||
|
||||
|
||||
def list_uuids() -> list[dict]:
|
||||
"""返回所有 UUID 记录(按创建时间倒序)。"""
|
||||
records = _load()
|
||||
records.sort(key=lambda r: r.get("created_at", 0), reverse=True)
|
||||
return records
|
||||
|
||||
|
||||
def exists(uuid: str) -> bool:
|
||||
"""检查 UUID 是否存在且启用。"""
|
||||
normalized = uuid.strip()
|
||||
return any(r.get("uuid") == normalized and r.get("enabled", True) for r in _load())
|
||||
|
||||
|
||||
def create(label: str = "") -> dict:
|
||||
"""创建一个新的访问 UUID。"""
|
||||
records = _load()
|
||||
new_uuid = str(uuid.uuid4())
|
||||
record = {
|
||||
"uuid": new_uuid,
|
||||
"label": (label or "").strip(),
|
||||
"enabled": True,
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
records.append(record)
|
||||
_save(records)
|
||||
return record
|
||||
|
||||
|
||||
def delete(uuid: str) -> bool:
|
||||
"""删除指定 UUID。"""
|
||||
records = _load()
|
||||
original_len = len(records)
|
||||
records = [r for r in records if r.get("uuid") != uuid.strip()]
|
||||
if len(records) == original_len:
|
||||
return False
|
||||
_save(records)
|
||||
return True
|
||||
|
||||
|
||||
def toggle(uuid: str, enabled: bool) -> bool:
|
||||
"""启用/禁用指定 UUID。"""
|
||||
records = _load()
|
||||
found = False
|
||||
for r in records:
|
||||
if r.get("uuid") == uuid.strip():
|
||||
r["enabled"] = enabled
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
_save(records)
|
||||
return True
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "stock-panel-backend"
|
||||
version = "0.1.44"
|
||||
description = "A 股选股 + 监控 + 回测面板"
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.70"
|
||||
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
@@ -19,7 +19,7 @@ dependencies = [
|
||||
"pyarrow>=16.0",
|
||||
"pandas>=2.2", # 仅在 BacktestService 边界使用,见 §7.4 / ADR-19
|
||||
"fastexcel>=0.10", # Polars 读取 xlsx/xls
|
||||
# A 股数据源 SDK
|
||||
# TickFlow 官方 SDK
|
||||
"tickflow[all]>=0.1.23",
|
||||
# Scheduling
|
||||
"apscheduler>=3.10",
|
||||
@@ -29,9 +29,19 @@ dependencies = [
|
||||
# AI(可选,但默认装上)
|
||||
"openai>=1.40", # OpenAI 兼容适配器复用 openai SDK
|
||||
"httpx>=0.27",
|
||||
"platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写)
|
||||
"winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心)
|
||||
"plyer>=2.1", # 系统通知跨平台兜底 (macOS/Linux)
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Legacy CPU runtime: install the Polars compatibility kernel on
|
||||
# machines without AVX2/FMA support.
|
||||
# Enable with: uv sync --extra legacy-cpu
|
||||
legacy-cpu = [
|
||||
"polars[rtcompat]>=1.0",
|
||||
]
|
||||
|
||||
# 回测依赖 vectorbt → numba → llvmlite,体积大且 macOS/Intel 上无预构建 wheel 时
|
||||
# 需要 brew install cmake 现场编译。挪到可选 extras,主依赖瘦身。
|
||||
# 启用:`uv sync --extra backtest`
|
||||
@@ -39,6 +49,12 @@ backtest = [
|
||||
"vectorbt>=0.26",
|
||||
]
|
||||
|
||||
# 桌面客户端依赖: pywebview 桌面窗口。打包用 (PyInstaller), 生产/Docker 不需要。
|
||||
# 启用:`uv sync --extra desktop`
|
||||
desktop = [
|
||||
"pywebview>=5.0",
|
||||
]
|
||||
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.ai_provider import normalize_openai_base_url
|
||||
|
||||
|
||||
def test_normalize_openai_base_url_adds_v1_for_root_gateway():
|
||||
assert normalize_openai_base_url("http://ai.zedbox.cn:8080") == "http://ai.zedbox.cn:8080/v1"
|
||||
|
||||
|
||||
def test_normalize_openai_base_url_preserves_v1_base():
|
||||
assert normalize_openai_base_url("http://ai.zedbox.cn:8080/v1") == "http://ai.zedbox.cn:8080/v1"
|
||||
|
||||
|
||||
def test_normalize_openai_base_url_strips_chat_completions_path():
|
||||
assert normalize_openai_base_url("http://ai.zedbox.cn:8080/v1/chat/completions") == "http://ai.zedbox.cn:8080/v1"
|
||||
Generated
+282
-3
@@ -105,6 +105,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bottle"
|
||||
version = "0.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.5.20"
|
||||
@@ -114,6 +123,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy' and sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
@@ -215,6 +250,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clr-loader"
|
||||
version = "0.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -1442,6 +1489,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotly"
|
||||
version = "6.7.0"
|
||||
@@ -1464,6 +1520,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plyer"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/85/f61425aa9be1f9108eec1c13861c1e11c9a04eb786eb4832a8f7188317df/plyer-2.1.0.tar.gz", hash = "sha256:65b7dfb7e11e07af37a8487eb2aa69524276ef70dad500b07228ce64736baa61", size = 121371, upload-time = "2022-11-12T13:36:48.978Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/89/a41c2643fc8eabeb84791acb9d0e4d139b1e4b53473cc4dae947b5fa33ed/plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113", size = 142266, upload-time = "2022-11-12T13:36:47.181Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polars"
|
||||
version = "1.40.1"
|
||||
@@ -1476,6 +1541,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/91/74fc60d94488685a92ac9d49d7ec55f3e91fe9b77942a6235a5fa7f249c3/polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd", size = 828723, upload-time = "2026-04-22T19:14:25.452Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
rtcompat = [
|
||||
{ name = "polars-runtime-compat" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polars-runtime-32"
|
||||
version = "1.40.1"
|
||||
@@ -1492,6 +1562,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ad/b33c3022a394f3eb55c3310597cec615412a8a33880055eee191d154a628/polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537", size = 45822104, upload-time = "2026-04-22T19:14:54.192Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polars-runtime-compat"
|
||||
version = "1.40.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/f8340030f94a45231ad02abff171c8c10f45df0984e5a4fd0f39a48500f4/polars_runtime_compat-1.40.1.tar.gz", hash = "sha256:6149aa764439cec26a5f883fb9921a50f61a0f6c4549df51c735626701a73a18", size = 2935443, upload-time = "2026-04-22T19:16:00.906Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c7/978481a2a2f5b50636d485bc176e16084fa1bb3b1d7e7e34f580cef240dc/polars_runtime_compat-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a683d287237f1dcc3dee95b5b2b6408cec318abbbb412ca49206492513be862f", size = 52016973, upload-time = "2026-04-22T19:15:25.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/c9/e308b0bbb331330a762f5f495597d34e9933c965ca6d4026a2eb481ef4ad/polars_runtime_compat-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c83750f5593cec088134614fbf783fd10c5a92030d71b35f3dea0fff682d92c6", size = 46246682, upload-time = "2026-04-22T19:15:28.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/54/eaecb4747695e2e200ed6d13ce40dd7b0cdc7499d0b9af1e64e83af0e46d/polars_runtime_compat-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffa15c4a8b7f67911412c849dc3d3aac313f682d834eb3f374d0f6cb7e080efc", size = 50123217, upload-time = "2026-04-22T19:15:32.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/87/0a881905d94341111d38e6a7ae94674511e6797cf3be2500998f06963edf/polars_runtime_compat-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0373dabf942135d13b453d50be7a84b14420b495bd1242570545f58866396e7", size = 55869626, upload-time = "2026-04-22T19:15:37.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6e/ac57e39bb94ebd63d6714895881ee0461e39039daa208916e5cad93174f9/polars_runtime_compat-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bcdc488472d2c57dd3315a897456b47cdac222a1520fcf3d8d38410a5bf47ec1", size = 50287716, upload-time = "2026-04-22T19:15:40.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f3/b75afc9f79cd4cadff52bdc4566119892b378f739a295573bf6bd0190a6c/polars_runtime_compat-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:34d03962169fc7c0ef6f6efd324d0a51350e7b5abf3b0cd118eb7a32e4d947ec", size = 53796312, upload-time = "2026-04-22T19:15:44.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/68/0df61011d894d1c94c915b39423c9aab79b3f7b18b836031ed32987227e0/polars_runtime_compat-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:d9ccfe58eeb776567cf9556a83b980b9face9f0b1bb629bf2cd2334f4d9daf57", size = 51696665, upload-time = "2026-04-22T19:15:48.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/01/1bc7e868d3e4055b1ae756ad8ccc2a5d2bce57d7c88c3b4427babd43450b/polars_runtime_compat-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:0c662e6acf5d4e3784eee8e8a1ec6eb185132ac90689cb84034132b5787903b1", size = 45701223, upload-time = "2026-04-22T19:15:52.189Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
@@ -1504,6 +1590,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proxy-tools"
|
||||
version = "0.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" }
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
@@ -1600,6 +1692,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
@@ -1740,6 +1841,114 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-core"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-cocoa"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-quartz"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-security"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ac/f2ff946edfaf16b4ce5e31afac5e519f83705c0f4842fd25134ecb8f2f4a/pyobjc_framework_security-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce461296b003b2ba17c8b65f6339f9d2fd5dcfa2b3b52ddc0a696334cc8974c5", size = 41306, upload-time = "2026-06-19T16:17:16.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5b/2719bc4062e6c27083191fd20e365ae02d0bf1c22f4d1a88211e3d96b369/pyobjc_framework_security-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76ff6e44e62d3e15651540493879bf16687d862c4f10f3cadade757811c8b8d0", size = 41300, upload-time = "2026-06-19T16:17:17.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/90/dccd4cd6877ef208957dc1f3675287d8614a4dcd2a3ee0a5e56f5fb5a1ba/pyobjc_framework_security-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:990013baba29d6f985d8950b23701129b2597b3d16f628b785fe97596d8a8de3", size = 41299, upload-time = "2026-06-19T16:17:18.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/af/f9e8040e0c3ef6a50392a46ad1df482a666aa615180d40730b00282ff81f/pyobjc_framework_security-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:066a3e5e9d368e7a6ba8dd52be2077a634ef12a54fbfcc78b3b8154a8f988a1d", size = 42179, upload-time = "2026-06-19T16:17:19.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/3c/76e2a8bb8d5fe48f0e8e25c6abec1609f3667cc39935017badfe9e9603f2/pyobjc_framework_security-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:5319ae49b8874363ab51c6ff4d85d4ea0cfa6d836fe0306e901ba9ae560b880d", size = 41370, upload-time = "2026-06-19T16:17:20.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6e/7120956e9833b2c70757eec1f65f57c191e00662cf74c4545d88315643fa/pyobjc_framework_security-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:21618431e0dbfbd3d4029445e3118af88e5d7e52ddecf9a2d17c759c51628d85", size = 42926, upload-time = "2026-06-19T16:17:21.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/0bafc557523e5755f74dd5363386a1e9b03f611e2e36df0737a508cd5ab4/pyobjc_framework_security-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:fa192e9df479375e6242adcadb9a44f32907dd7fe1207608710cd3af65fe3c84", size = 41376, upload-time = "2026-06-19T16:17:22.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/33/33d266117e46fef148caa4f986b3d896cb9bfd76bef48bd761cb60c758ee/pyobjc_framework_security-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:07cd044a7996f9a897040c49055fa3bdf565acac4a25b834a72e60602376146d", size = 42944, upload-time = "2026-06-19T16:17:23.371Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-uniformtypeidentifiers"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/44/18a7b3c3b4f9f6784fddf64ed5a2c148577d0300705a50e8ab81da8fc71d/pyobjc_framework_uniformtypeidentifiers-12.2.1-py2.py3-none-any.whl", hash = "sha256:ea08413ad895a7dfea13670e26548bcf5b00154084cdfb5d8f96603320e77cf3", size = 5042, upload-time = "2026-06-19T16:18:54.085Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-webkit"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d3/2ab99d3975dd4624dd943e5a7c8d37e40258d3c9fcf4f26baf09a24e6c9b/pyobjc_framework_webkit-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:af5c4ccdf03845adac082823a3b4341b5b2fe62d2d664550afa705b5286a06fc", size = 50264, upload-time = "2026-06-19T16:19:30.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/47/7a2099eb2e062c6230a9440f1795cf34056ca5e16ef25c8aad7c059b8734/pyobjc_framework_webkit-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7e04dcc08cdc59380113ea1232af75a0a04c2426418ebe967b4c0045c973f776", size = 50372, upload-time = "2026-06-19T16:19:31.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a4/202ec288808011d3f459d000d593e88b1118f2d1d5a4dfaaf5232f2c2ac2/pyobjc_framework_webkit-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:23bee8bf7077f91da4e3ae54a00c7f5e4414319e15f98be8584dbd67c4043fae", size = 50387, upload-time = "2026-06-19T16:19:32.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/a4/f796e94b43a66704b6ae17c747c7b97fd4b79348f1cfa9bef7b008aaa718/pyobjc_framework_webkit-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:00ffb254f97e9ffdd0a82c1faa61a07f6072ba900fa8aba70c83c21198b52e4e", size = 50853, upload-time = "2026-06-19T16:19:33.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f6/d24716fef19ccc3d880e99029458803f0174c05df310d991eb97ea3a0799/pyobjc_framework_webkit-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:67030258c3cd66e8495ccfccef3d2d58010ff0209284c5115e5afdb0e9fd6de1", size = 50499, upload-time = "2026-06-19T16:19:34.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/6c/817119a52efcc229a30ceff56a0641005a431806a1f555e0571626ba313a/pyobjc_framework_webkit-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5d91527c9950c79269dd0d70f2bb8668c298dd06930637c1c063ce5f274a87e5", size = 50967, upload-time = "2026-06-19T16:19:35.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/59/5fac0754d53b2a72aed6f424dfc72e5fa245f83cb57c2e00d02e45390fca/pyobjc_framework_webkit-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:657825081484c9920c50b76b469b9583f116225b0449c9d95c46cbc8c640adc8", size = 50498, upload-time = "2026-06-19T16:19:36.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0c/e997e33d99d4ad91da2cf70f0e51ac39b03c58ba210548e9e944bbb421be/pyobjc_framework_webkit-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:f46adcc6227873f2b14d74b2e789c937f227722274ab59b9fa3c04c6ecb46dd5", size = 50958, upload-time = "2026-06-19T16:19:37.424Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
@@ -1808,6 +2017,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pythonnet"
|
||||
version = "3.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "clr-loader", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.2"
|
||||
@@ -1817,6 +2039,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywebview"
|
||||
version = "6.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bottle" },
|
||||
{ name = "proxy-tools" },
|
||||
{ name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pythonnet", marker = "sys_platform == 'win32'" },
|
||||
{ name = "qtpy", marker = "sys_platform == 'openbsd6'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1872,6 +2116,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qtpy"
|
||||
version = "2.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2026.5.9"
|
||||
@@ -2234,8 +2490,8 @@ all = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stock-panel-backend"
|
||||
version = "0.1.44"
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.66"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
@@ -2245,6 +2501,8 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "openai" },
|
||||
{ name = "pandas" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "plyer" },
|
||||
{ name = "polars" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pydantic" },
|
||||
@@ -2255,18 +2513,25 @@ dependencies = [
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "tickflow", extra = ["all"] },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "winotify", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
backtest = [
|
||||
{ name = "vectorbt" },
|
||||
]
|
||||
desktop = [
|
||||
{ name = "pywebview" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
legacy-cpu = [
|
||||
{ name = "polars", extra = ["rtcompat"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
@@ -2278,7 +2543,10 @@ requires-dist = [
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
|
||||
{ name = "openai", specifier = ">=1.40" },
|
||||
{ name = "pandas", specifier = ">=2.2" },
|
||||
{ name = "platformdirs", specifier = ">=4.0" },
|
||||
{ name = "plyer", specifier = ">=2.1" },
|
||||
{ name = "polars", specifier = ">=1.0" },
|
||||
{ name = "polars", extras = ["rtcompat"], marker = "extra == 'legacy-cpu'", specifier = ">=1.0" },
|
||||
{ name = "pyarrow", specifier = ">=16.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.4" },
|
||||
@@ -2286,14 +2554,16 @@ requires-dist = [
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.6" },
|
||||
{ name = "pywebview", marker = "extra == 'desktop'", specifier = ">=5.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" },
|
||||
{ name = "sse-starlette", specifier = ">=2.0" },
|
||||
{ name = "tickflow", extras = ["all"], specifier = ">=0.1.23" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||
{ name = "vectorbt", marker = "extra == 'backtest'", specifier = ">=0.26" },
|
||||
{ name = "winotify", marker = "sys_platform == 'win32'", specifier = ">=1.1" },
|
||||
]
|
||||
provides-extras = ["backtest", "dev"]
|
||||
provides-extras = ["legacy-cpu", "backtest", "desktop", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
@@ -2636,3 +2906,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winotify"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/b0/1b304fdd8fd810f1f8e81a2708f8bf72e0987de1a763f0ee81f6d08bcae7/winotify-1.1.0.tar.gz", hash = "sha256:f8a0d6ff00cb2c1b3dcdfe825431f46f6aa5dc8ce84ffc59e8fda8c7e36687fe", size = 10101, upload-time = "2022-02-07T12:34:46.236Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/85/6cc4c738080d60b62cad59d0f32386b8277d40e2bf8c06fd1e4101a17238/winotify-1.1.0-py3-none-any.whl", hash = "sha256:13aa9b1196b02ab3e699645b4407371ca73348421f8662565100d70c7cf552d9", size = 15034, upload-time = "2022-02-07T12:34:44.341Z" },
|
||||
]
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
# tickflow-stock-panel - one-shot launcher for backend + frontend (Windows / PowerShell)
|
||||
#
|
||||
# Usage:
|
||||
# .\dev.ps1
|
||||
# .\dev.ps1 -BackendPort 8000 -FrontendPort 5173
|
||||
# $env:BACKEND_PORT='8000'; .\dev.ps1
|
||||
#
|
||||
# Ctrl-C closes both processes.
|
||||
#
|
||||
# If you see "running scripts is disabled":
|
||||
# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$BackendPort = 0,
|
||||
[int]$FrontendPort = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Port precedence: CLI arg > env var > default
|
||||
if ($BackendPort -le 0) { $BackendPort = if ($env:BACKEND_PORT) { [int]$env:BACKEND_PORT } else { 3018 } }
|
||||
if ($FrontendPort -le 0) { $FrontendPort = if ($env:FRONTEND_PORT) { [int]$env:FRONTEND_PORT } else { 3011 } }
|
||||
|
||||
# Force UTF-8 console output so child process logs aren't garbled
|
||||
try {
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
||||
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
||||
} catch {}
|
||||
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$BackendDir = Join-Path $Root 'backend'
|
||||
$FrontendDir = Join-Path $Root 'frontend'
|
||||
|
||||
function Log-Info($m) { Write-Host "[dev] $m" -ForegroundColor DarkGray }
|
||||
function Log-Ok ($m) { Write-Host "[dev] $m" -ForegroundColor Green }
|
||||
function Log-Warn($m) { Write-Host "[dev] $m" -ForegroundColor Yellow }
|
||||
function Log-Err ($m) { Write-Host "[dev] $m" -ForegroundColor Red }
|
||||
|
||||
# ===== 1. Dependency check =====
|
||||
function Require-Cmd($cmd, $hint) {
|
||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||
Log-Err "$cmd not found"
|
||||
Write-Host " install via: $hint"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Require-Cmd 'uv' 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex" OR winget install --id=astral-sh.uv'
|
||||
Require-Cmd 'pnpm' 'npm i -g pnpm OR corepack enable; corepack prepare pnpm@9 --activate'
|
||||
|
||||
# ===== 2. Port check - kill anything listening on the target ports =====
|
||||
function Free-Port($name, $port) {
|
||||
$conns = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue
|
||||
if (-not $conns) { return }
|
||||
$pids = @($conns.OwningProcess | Where-Object { $_ -gt 0 } | Sort-Object -Unique)
|
||||
if ($pids.Count -eq 0) { return }
|
||||
|
||||
# Filter to PIDs that still exist as running processes.
|
||||
# A zombie TCP endpoint can linger after the process is already dead.
|
||||
$alive = @($pids | Where-Object {
|
||||
try { [System.Diagnostics.Process]::GetProcessById($_) | Out-Null; $true }
|
||||
catch { $false }
|
||||
})
|
||||
|
||||
if ($alive.Count -eq 0) {
|
||||
# All processes are dead but kernel still holds the socket (zombie endpoint).
|
||||
# On Windows this can linger for minutes, but uvicorn/vite can still bind
|
||||
# via SO_REUSEADDR — no point waiting, just proceed.
|
||||
Log-Warn "port ${port} (${name}) - zombie socket (processes gone), starting anyway"
|
||||
return
|
||||
}
|
||||
|
||||
Log-Warn "port $port ($name) is in use, killing PID: $($alive -join ', ')"
|
||||
# Use taskkill /F /T to kill the entire process tree (parent + children),
|
||||
# not just the parent. Stop-Process only kills one process, leaving child
|
||||
# processes (e.g. uvicorn spawned by uv) as orphans holding the socket.
|
||||
foreach ($p in $alive) {
|
||||
# Suppress stderr properly for Windows PowerShell (5.x)
|
||||
$null = & cmd /c "taskkill /F /T /PID $p 2>nul"
|
||||
# Fallback: if taskkill failed, try Stop-Process
|
||||
try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
|
||||
# Wait up to 5 seconds for the kernel to release the TCP endpoint
|
||||
for ($i = 0; $i -lt 10; $i++) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$still = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue
|
||||
if (-not $still) {
|
||||
Log-Ok "port $port freed"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# Port still stuck — process might be dead with zombie socket
|
||||
$anyAlive = $still | Where-Object {
|
||||
try { [System.Diagnostics.Process]::GetProcessById($_.OwningProcess) | Out-Null; $true }
|
||||
catch { $false }
|
||||
}
|
||||
if ($anyAlive.Count -eq 0) {
|
||||
Log-Warn "port ${port} - processes gone but socket lingers, starting anyway"
|
||||
} else {
|
||||
Log-Err "port ${port} still in use by live process(es). Inspect: Get-NetTCPConnection -LocalPort ${port}"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Free-Port 'backend' $BackendPort
|
||||
Free-Port 'frontend' $FrontendPort
|
||||
|
||||
# ===== 3. First-time dependency install =====
|
||||
if (-not (Test-Path (Join-Path $BackendDir '.venv'))) {
|
||||
Log-Info 'first run - installing Python deps (1-2 min)...'
|
||||
Push-Location $BackendDir
|
||||
try { & uv sync } finally { Pop-Location }
|
||||
if ($LASTEXITCODE -ne 0) { Log-Err 'uv sync failed'; exit 1 }
|
||||
Log-Ok 'backend deps installed'
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $FrontendDir 'node_modules'))) {
|
||||
Log-Info 'first run - installing Node deps...'
|
||||
Push-Location $FrontendDir
|
||||
try { & pnpm install } finally { Pop-Location }
|
||||
if ($LASTEXITCODE -ne 0) { Log-Err 'pnpm install failed'; exit 1 }
|
||||
Log-Ok 'frontend deps installed'
|
||||
}
|
||||
|
||||
# ===== 4. Banner (ASCII so it renders on any codepage) =====
|
||||
Write-Host ''
|
||||
Write-Host '+----------------------------------------------+' -ForegroundColor Blue
|
||||
Write-Host '| tickflow-stock-panel |' -ForegroundColor Blue
|
||||
Write-Host '| |' -ForegroundColor Blue
|
||||
Write-Host "| backend http://localhost:$BackendPort" -ForegroundColor Blue
|
||||
Write-Host "| frontend http://localhost:$FrontendPort" -ForegroundColor Blue
|
||||
Write-Host '| |' -ForegroundColor Blue
|
||||
Write-Host '| Ctrl-C closes both |' -ForegroundColor Blue
|
||||
Write-Host '+----------------------------------------------+' -ForegroundColor Blue
|
||||
Write-Host ''
|
||||
|
||||
# ===== 5. Launch jobs =====
|
||||
# Each job writes its $PID to a temp file so the main thread can find the
|
||||
# child powershell.exe and taskkill /T the whole process tree on exit.
|
||||
$backendPidFile = [System.IO.Path]::GetTempFileName()
|
||||
$frontendPidFile = [System.IO.Path]::GetTempFileName()
|
||||
|
||||
$backendJob = Start-Job -Name 'backend' -ScriptBlock {
|
||||
param($pidFile, $dir, $port)
|
||||
$PID | Out-File -FilePath $pidFile -Encoding ascii -Force
|
||||
$env:PYTHONUNBUFFERED = '1'
|
||||
Set-Location $dir
|
||||
& .\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --host 0.0.0.0 --port $port 2>&1
|
||||
} -ArgumentList $backendPidFile, $BackendDir, $BackendPort
|
||||
|
||||
$frontendJob = Start-Job -Name 'frontend' -ScriptBlock {
|
||||
param($pidFile, $dir, $port)
|
||||
$PID | Out-File -FilePath $pidFile -Encoding ascii -Force
|
||||
Set-Location $dir
|
||||
& pnpm dev --host 0.0.0.0 --port $port 2>&1
|
||||
} -ArgumentList $frontendPidFile, $FrontendDir, $FrontendPort
|
||||
|
||||
# Wait up to 5 seconds for the PID files to materialise
|
||||
function Read-JobPid($file) {
|
||||
for ($i = 0; $i -lt 50; $i++) {
|
||||
try {
|
||||
$c = (Get-Content $file -ErrorAction SilentlyContinue) -as [string]
|
||||
if ($c -and $c.Trim()) { return [int]$c.Trim() }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$backendChildPid = Read-JobPid $backendPidFile
|
||||
$frontendChildPid = Read-JobPid $frontendPidFile
|
||||
|
||||
# ===== 6. Cleanup =====
|
||||
$script:cleaning = $false
|
||||
function Cleanup-All {
|
||||
if ($script:cleaning) { return }
|
||||
$script:cleaning = $true
|
||||
Write-Host ''
|
||||
Log-Info 'shutting down...'
|
||||
|
||||
foreach ($p in @($backendChildPid, $frontendChildPid)) {
|
||||
if ($p) {
|
||||
# /T kills the whole process tree (the job's powershell + uvicorn/vite)
|
||||
$null = & cmd /c "taskkill /F /T /PID $p 2>nul"
|
||||
}
|
||||
}
|
||||
foreach ($j in @($backendJob, $frontendJob)) {
|
||||
if ($j) {
|
||||
Stop-Job $j -ErrorAction SilentlyContinue
|
||||
Remove-Job $j -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
foreach ($f in @($backendPidFile, $frontendPidFile)) {
|
||||
Remove-Item $f -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Log-Ok 'bye'
|
||||
}
|
||||
|
||||
# ===== 7. Main loop - pump output, handle Ctrl-C =====
|
||||
# Treat Ctrl-C as input so try/finally is guaranteed to run.
|
||||
$prevCtrlC = [Console]::TreatControlCAsInput
|
||||
try {
|
||||
[Console]::TreatControlCAsInput = $true
|
||||
|
||||
while ($true) {
|
||||
if ([Console]::KeyAvailable) {
|
||||
$key = [Console]::ReadKey($true)
|
||||
if (($key.Modifiers -band [ConsoleModifiers]::Control) -and $key.Key -eq 'C') {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$bOut = Receive-Job $backendJob -ErrorAction SilentlyContinue
|
||||
if ($bOut) {
|
||||
foreach ($line in $bOut) {
|
||||
Write-Host '[backend ] ' -NoNewline -ForegroundColor Blue
|
||||
Write-Host $line
|
||||
}
|
||||
}
|
||||
|
||||
$fOut = Receive-Job $frontendJob -ErrorAction SilentlyContinue
|
||||
if ($fOut) {
|
||||
foreach ($line in $fOut) {
|
||||
Write-Host '[frontend] ' -NoNewline -ForegroundColor Green
|
||||
Write-Host $line
|
||||
}
|
||||
}
|
||||
|
||||
if ($backendJob.State -ne 'Running' -or $frontendJob.State -ne 'Running') {
|
||||
Log-Warn 'one of the processes exited; closing the other...'
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 150
|
||||
}
|
||||
}
|
||||
finally {
|
||||
[Console]::TreatControlCAsInput = $prevCtrlC
|
||||
Cleanup-All
|
||||
}
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# tickflow-stock-panel — 一键启动前后端
|
||||
#
|
||||
# 用法:
|
||||
# ./dev.sh # 默认 backend:3018 frontend:3011
|
||||
# BACKEND_PORT=8000 ./dev.sh # 改后端端口
|
||||
# FRONTEND_PORT=5173 ./dev.sh # 改前端端口
|
||||
#
|
||||
# Ctrl-C 同时关闭两端。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$ROOT/backend"
|
||||
FRONTEND_DIR="$ROOT/frontend"
|
||||
BACKEND_PORT="${BACKEND_PORT:-3018}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-3011}"
|
||||
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
GRAY='\033[0;90m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GRAY}[dev]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[dev]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[dev]${NC} $*"; }
|
||||
err() { echo -e "${RED}[dev]${NC} $*" >&2; }
|
||||
|
||||
# ===== 1. 依赖检查 =====
|
||||
require_cmd() {
|
||||
local cmd="$1" hint="$2"
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
err "$cmd 未安装"
|
||||
echo " 安装方式:$hint"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd uv "curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
require_cmd pnpm "npm i -g pnpm 或 corepack enable && corepack prepare pnpm@9 --activate"
|
||||
|
||||
# ===== 2. 端口占用检查 —— 占用就直接 kill =====
|
||||
free_port() {
|
||||
local name="$1" port="$2"
|
||||
local pids
|
||||
pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -z "$pids" ]; then
|
||||
return 0
|
||||
fi
|
||||
warn "端口 $port($name)被占用,kill 现有进程 PID: $(echo "$pids" | xargs)"
|
||||
# 先 TERM
|
||||
echo "$pids" | xargs kill 2>/dev/null || true
|
||||
sleep 1
|
||||
# 还活着就 KILL
|
||||
pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
warn "TERM 没杀掉,改用 KILL -9"
|
||||
echo "$pids" | xargs kill -9 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
# 再确认一次
|
||||
pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
err "端口 $port 仍被占用 — kill 失败。请手动处理:lsof -i :$port"
|
||||
exit 1
|
||||
fi
|
||||
ok "端口 $port 已释放"
|
||||
}
|
||||
free_port backend "$BACKEND_PORT"
|
||||
free_port frontend "$FRONTEND_PORT"
|
||||
|
||||
# ===== 3. 首次依赖安装 =====
|
||||
if [ ! -d "$BACKEND_DIR/.venv" ]; then
|
||||
info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..."
|
||||
( cd "$BACKEND_DIR" && uv sync )
|
||||
ok "后端依赖装好了"
|
||||
fi
|
||||
|
||||
if [ ! -d "$FRONTEND_DIR/node_modules" ]; then
|
||||
info "前端首次启动 — 安装 Node 依赖..."
|
||||
( cd "$FRONTEND_DIR" && pnpm install )
|
||||
ok "前端依赖装好了"
|
||||
fi
|
||||
|
||||
# ===== 4. 启动 + 日志前缀 =====
|
||||
PIDS=()
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
info "关闭服务..."
|
||||
for pid in "${PIDS[@]:-}"; do
|
||||
if [ -n "$pid" ]; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
# 等子进程退出,避免孤儿
|
||||
wait 2>/dev/null || true
|
||||
ok "已退出"
|
||||
exit 0
|
||||
}
|
||||
trap cleanup INT TERM
|
||||
|
||||
# 用 awk 加前缀(macOS sed 没有 -u line-buffered,改用 awk + fflush 兼容)
|
||||
prefix_awk() {
|
||||
awk -v p="$1" '{ print p $0; fflush() }'
|
||||
}
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}╭──────────────────────────────────────────────╮${NC}"
|
||||
echo -e "${BLUE}│${NC} ${GREEN}tickflow-stock-panel${NC} ${BLUE}│${NC}"
|
||||
echo -e "${BLUE}│${NC} ${BLUE}│${NC}"
|
||||
echo -e "${BLUE}│${NC} backend ${YELLOW}http://localhost:$BACKEND_PORT${NC} ${BLUE}│${NC}"
|
||||
echo -e "${BLUE}│${NC} frontend ${YELLOW}http://localhost:$FRONTEND_PORT${NC} ${BLUE}│${NC}"
|
||||
echo -e "${BLUE}│${NC} ${BLUE}│${NC}"
|
||||
echo -e "${BLUE}│${NC} Ctrl-C 同时关闭两端 ${BLUE}│${NC}"
|
||||
echo -e "${BLUE}╰──────────────────────────────────────────────╯${NC}"
|
||||
echo
|
||||
|
||||
(
|
||||
cd "$BACKEND_DIR"
|
||||
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port "$BACKEND_PORT" 2>&1 \
|
||||
| prefix_awk "$(printf "${BLUE}[backend ]${NC} ")"
|
||||
) &
|
||||
PIDS+=("$!")
|
||||
|
||||
(
|
||||
cd "$FRONTEND_DIR"
|
||||
pnpm dev --host 0.0.0.0 --port "$FRONTEND_PORT" 2>&1 \
|
||||
| prefix_awk "$(printf "${GREEN}[frontend]${NC} ")"
|
||||
) &
|
||||
PIDS+=("$!")
|
||||
|
||||
# 等任一退出(bash 4.3+)或全部退出(老 bash)
|
||||
if wait -n 2>/dev/null; then
|
||||
warn "其中一个进程退出,正在关闭另一个..."
|
||||
cleanup
|
||||
else
|
||||
# 老 bash 没有 wait -n,退化为 wait 全部
|
||||
wait
|
||||
fi
|
||||
@@ -1,19 +1,18 @@
|
||||
# Phase 0 单 service:FastAPI 启动后既跑 API 又托管前端 dist。
|
||||
# 见 ADR-17 / §8.1。
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runtime
|
||||
args:
|
||||
- PYTHON_IMAGE=${PYTHON_IMAGE:-python:3.11-slim}
|
||||
- NODE_IMAGE=${NODE_IMAGE:-node:20-alpine}
|
||||
container_name: Stock_Panel
|
||||
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
|
||||
container_name: TickFlow_Stock_Panel
|
||||
ports:
|
||||
- "3018:3018"
|
||||
environment:
|
||||
- ACCESS_UUID=${ACCESS_UUID:-}
|
||||
- "${PORT:-3018}:3018"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./tiers.yaml:/app/tiers.yaml:ro
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 公网部署:如何设置访问密码
|
||||
|
||||
面板部署在公网服务器时,首次设置访问密码有限制 —— **必须从本机或内网访问**,以防公网上陌生人抢先设置密码锁死你的面板。
|
||||
|
||||
如果你在公网浏览器直接打开页面,会看到提示:
|
||||
|
||||
> 首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作
|
||||
|
||||
有两种方式解决,任选其一。
|
||||
|
||||
---
|
||||
|
||||
## 方式一:环境变量预置密码(最简单,推荐)
|
||||
|
||||
在 `.env` 文件(或 Docker / 系统环境变量)里设置 `AUTH_PASSWORD`:
|
||||
|
||||
```bash
|
||||
# 编辑服务器上的 .env (通常在项目根目录或 backend/ 下)
|
||||
AUTH_PASSWORD=你的密码
|
||||
```
|
||||
|
||||
然后重启服务。启动时会自动:
|
||||
1. 读取 `AUTH_PASSWORD`
|
||||
2. 用 PBKDF2 哈希后写入 `auth.json`(`chmod 600`,只存哈希不存明文)
|
||||
3. **之后这个环境变量就不再被读取** —— 是一次性的初始化
|
||||
|
||||
设完后即可用公网地址 + 这个密码正常登录。后续改密码请用页面 UI(`设置 → 修改密码`),不受环境变量影响。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- **密码至少 6 位**,否则会被跳过并记一条 warning 日志
|
||||
- **仅在未设过密码时生效**。已设过密码后,改这里不会覆盖(避免重启时重置你在 UI 改的密码)
|
||||
- `.env` 文件权限保持 `600`,**不要提交到 Git**
|
||||
- 明文密码只存在于 `.env` / 环境变量中,落盘的是哈希,安全性等同 `auth.json`
|
||||
|
||||
### 重置密码(忘密码时)
|
||||
|
||||
如果忘了密码想重置:删除或清空 `data/user_data/auth.json`,重启服务,会回到"未设密码"状态,此时 `AUTH_PASSWORD` 会重新生效。
|
||||
|
||||
```bash
|
||||
# 停服后执行, 清空后重启
|
||||
rm data/user_data/auth.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式二:SSH 端口转发
|
||||
|
||||
不用改配置,在你**自己电脑**的终端执行(不是服务器上):
|
||||
|
||||
```bash
|
||||
ssh -L 3018:127.0.0.1:3018 用户名@服务器IP
|
||||
```
|
||||
|
||||
例如服务器是 `123.45.67.89`、用户名 `root`、面板端口 `3018`:
|
||||
|
||||
```bash
|
||||
ssh -L 3018:127.0.0.1:3018 root@123.45.67.89
|
||||
```
|
||||
|
||||
保持这个 SSH 连接**不要关**,然后在**自己电脑的浏览器**打开:
|
||||
|
||||
```
|
||||
http://127.0.0.1:3018
|
||||
```
|
||||
|
||||
此时后端看到的客户端 IP 是 `127.0.0.1`(本机),能通过校验,正常显示设置密码界面。
|
||||
|
||||
**设完密码后**,SSH 连接可以断开 —— 密码已存进服务器,之后直接用公网地址 + 刚设的密码访问即可。
|
||||
|
||||
### 端口说明
|
||||
|
||||
上面的 `3018` 是默认端口。如果你用 `PORT` 环境变量改过端口(比如 `PORT=8080`),两处都要替换:
|
||||
|
||||
```bash
|
||||
ssh -L 8080:127.0.0.1:8080 root@123.45.67.89
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 原理说明
|
||||
|
||||
- **为什么限制本机/内网?** 面板部署到公网后,任何人都能访问 URL。如果不限制,攻击者可以在你之前打开页面、设置一个密码,把你的面板锁死。
|
||||
- **本机/内网如何判断?** 后端检查客户端 IP 是否属于 `127.0.0.1 / ::1 / 10.x / 192.168.x / 172.16-31.x`。
|
||||
- **SSH 转发为什么有效?** `-L` 把本机端口通过 SSH 隧道转发到服务器的 `127.0.0.1`,等同于在服务器本地访问,客户端 IP 变成 `127.0.0.1`,通过校验。
|
||||
- **反向代理注意:** 若面板在 Nginx 等反代之后,需正确配置 `X-Forwarded-For` 头,后端据此取真实客户端 IP。
|
||||
|
||||
---
|
||||
|
||||
## 两种方式怎么选
|
||||
|
||||
| | 环境变量 | SSH 转发 |
|
||||
|---|---|---|
|
||||
| 操作 | 改一行配置 + 重启 | 一条 ssh 命令 |
|
||||
| 需要改配置 | 是 | 否 |
|
||||
| 适合 | Docker / 自动化部署 / 不熟 SSH | 临时设密码 / 能 SSH 到服务器 |
|
||||
| 后续改密码 | UI(`设置 → 修改密码`) | 同左 |
|
||||
|
||||
推荐**方式一(环境变量)**,一次配置即可,Docker 部署尤其方便。
|
||||
@@ -1,11 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="theme-color" content="#8B5CF6" />
|
||||
<title>A股工作台</title>
|
||||
<title>TickFlow Stock Panel · Quant Terminal</title>
|
||||
<link rel="preconnect" href="https://rsms.me/" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
Generated
-3041
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stock-panel-frontend",
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.44",
|
||||
"version": "0.1.70",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
function getStoredToken(): string | null {
|
||||
try { return localStorage.getItem('access_token') } catch { return null }
|
||||
}
|
||||
|
||||
export function AccessGuard({ children, requireAdmin = false }: { children: React.ReactNode; requireAdmin?: boolean }) {
|
||||
const location = useLocation()
|
||||
const token = getStoredToken()
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['access-auth-status', token],
|
||||
queryFn: () => api.accessAuthStatus(),
|
||||
// 即使 token 为空也查询一次,用于确认服务端是否启用了门控
|
||||
enabled: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-screen w-full flex items-center justify-center bg-base text-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 未启用门控,直接放行
|
||||
if (!status?.enabled) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 未验证,跳转到登录页
|
||||
if (!status.verified) {
|
||||
return <Navigate to="/verify" state={{ from: location.pathname }} replace />
|
||||
}
|
||||
|
||||
// 需要管理员权限但当前非管理员
|
||||
if (requireAdmin && status.role !== 'admin') {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
function getStoredRole(): string | null {
|
||||
try { return localStorage.getItem('access_role') } catch { return null }
|
||||
}
|
||||
|
||||
export function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['access-auth-status'],
|
||||
queryFn: () => api.accessAuthStatus(),
|
||||
enabled: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-screen w-full flex items-center justify-center bg-base text-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 未启用门控或本地角色为 admin,均放行
|
||||
const localRole = getStoredRole()
|
||||
if (!status?.enabled || localRole === 'admin' || status.role === 'admin') {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
@@ -161,10 +161,10 @@ export function AlertToastContainer() {
|
||||
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 flex items-center gap-2 pl-0.5">
|
||||
<div className="mt-1 flex items-center gap-1.5 pl-0.5">
|
||||
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
|
||||
{/* message 已含「条件摘要 · 现价 · 涨跌幅」(后端生成), 直接展示避免重复 */}
|
||||
{ev.message && <span className="text-[11px] text-foreground/70 truncate flex-1">{ev.message}</span>}
|
||||
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts, EChartsOption } from 'echarts'
|
||||
import { useChartTheme, type ChartTheme } from '@/lib/chartTheme'
|
||||
|
||||
export interface OHLC {
|
||||
date: string
|
||||
@@ -294,25 +293,19 @@ interface Props {
|
||||
activeIndicators?: string[]
|
||||
}
|
||||
|
||||
function getTHEME(ct: ChartTheme) {
|
||||
return {
|
||||
bull: '#C74040',
|
||||
bear: '#2D9B65',
|
||||
bullAlpha: 'rgba(240,68,56,0.7)',
|
||||
bearAlpha: 'rgba(18,183,106,0.7)',
|
||||
ma5: '#A1A1AA',
|
||||
ma10: '#3B82F6',
|
||||
ma20: '#F97316',
|
||||
ma60: '#8B5CF6',
|
||||
text: ct.text,
|
||||
grid: ct.splitLine,
|
||||
border: ct.axisLine,
|
||||
bg: 'transparent',
|
||||
tooltipBg: ct.tooltipBg,
|
||||
tooltipBorder: ct.tooltipBorder,
|
||||
tooltipText: ct.tooltipText,
|
||||
crosshair: ct.crosshair,
|
||||
}
|
||||
const THEME = {
|
||||
bull: '#C74040',
|
||||
bear: '#2D9B65',
|
||||
bullAlpha: 'rgba(240,68,56,0.7)',
|
||||
bearAlpha: 'rgba(18,183,106,0.7)',
|
||||
ma5: '#A1A1AA',
|
||||
ma10: '#3B82F6',
|
||||
ma20: '#F97316',
|
||||
ma60: '#8B5CF6',
|
||||
text: '#A1A1AA',
|
||||
grid: 'rgba(255,255,255,0.04)',
|
||||
border: '#27272A',
|
||||
bg: 'transparent',
|
||||
}
|
||||
|
||||
/** 可见蜡烛超过此数量时,涨停/炸板标签切换为小圆点。 */
|
||||
@@ -328,7 +321,6 @@ function buildSubInfoGraphics(
|
||||
infoIdx: number,
|
||||
activeIndicators: string[],
|
||||
subStartTop: number,
|
||||
theme: ReturnType<typeof getTHEME>,
|
||||
): any[] {
|
||||
const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null
|
||||
const graphics: any[] = []
|
||||
@@ -357,7 +349,7 @@ function buildSubInfoGraphics(
|
||||
id: `sub-sep-${key}`,
|
||||
type: 'line',
|
||||
shape: { x1: 0, y1: curTop, x2: 2000, y2: curTop },
|
||||
style: { stroke: theme.grid, lineWidth: 1 },
|
||||
style: { stroke: 'rgba(255,255,255,0.08)', lineWidth: 1 },
|
||||
silent: true, z: 0,
|
||||
})
|
||||
graphics.push({
|
||||
@@ -422,7 +414,6 @@ function buildOption(
|
||||
containerHeight: number,
|
||||
infoIdx: number,
|
||||
linkedPrice: number | null | undefined,
|
||||
theme: ReturnType<typeof getTHEME>,
|
||||
): EChartsOption {
|
||||
const candleData = data.map(d => [d.open, d.close, d.low, d.high])
|
||||
|
||||
@@ -438,7 +429,7 @@ function buildOption(
|
||||
const isSell = m.kind === 'sell'
|
||||
|
||||
if (m.above) {
|
||||
const dotColor = m.color ?? (isBuy ? '#FACC15' : theme.text)
|
||||
const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text)
|
||||
if (compact) {
|
||||
markPointData.push({
|
||||
name: m.date, coord: [m.date, d.high],
|
||||
@@ -466,11 +457,11 @@ function buildOption(
|
||||
symbol: 'arrow', symbolSize: 12,
|
||||
symbolRotate: isBuy ? 0 : 180,
|
||||
symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'],
|
||||
itemStyle: { color: isBuy ? theme.bull : isSell ? theme.bear : theme.text },
|
||||
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text },
|
||||
label: {
|
||||
show: !!m.label, formatter: m.label ?? '',
|
||||
position: isBuy ? 'bottom' : 'top', distance: 8,
|
||||
color: theme.text, fontSize: 10,
|
||||
color: THEME.text, fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
},
|
||||
})
|
||||
@@ -506,8 +497,8 @@ function buildOption(
|
||||
grids.push({ left, right, top: topPad, height: candleAvail })
|
||||
xAxes.push({
|
||||
type: 'category', data: dates, boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: theme.border } },
|
||||
axisLabel: { color: theme.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
|
||||
axisLine: { lineStyle: { color: THEME.border } },
|
||||
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
})
|
||||
@@ -517,8 +508,8 @@ function buildOption(
|
||||
boundaryGap: [0.03, 0.03],
|
||||
splitArea: { show: false },
|
||||
axisLine: { show: false }, axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: theme.grid } },
|
||||
axisLabel: { color: theme.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
|
||||
splitLine: { lineStyle: { color: THEME.grid } },
|
||||
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
|
||||
})
|
||||
xAxisIndices.push(0)
|
||||
|
||||
@@ -533,9 +524,9 @@ function buildOption(
|
||||
show: !!r.label,
|
||||
position: 'insideTop',
|
||||
distance: 8,
|
||||
color: theme.tooltipText,
|
||||
backgroundColor: theme.tooltipBg,
|
||||
borderColor: theme.tooltipBorder,
|
||||
color: '#DBEAFE',
|
||||
backgroundColor: 'rgba(15,23,42,0.72)',
|
||||
borderColor: 'rgba(59,130,246,0.35)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
padding: [2, 6],
|
||||
@@ -550,7 +541,7 @@ function buildOption(
|
||||
.filter(line => Number.isFinite(line.value))
|
||||
.map(line => {
|
||||
const lineStyle = {
|
||||
color: line.color ?? theme.text,
|
||||
color: line.color ?? THEME.text,
|
||||
type: 'dashed' as const,
|
||||
width: 1,
|
||||
opacity: 0.92,
|
||||
@@ -559,8 +550,8 @@ function buildOption(
|
||||
show: !!line.label,
|
||||
formatter: line.label ?? '',
|
||||
position: 'insideEndTop' as const,
|
||||
color: line.color ?? theme.text,
|
||||
backgroundColor: theme.tooltipBg,
|
||||
color: line.color ?? THEME.text,
|
||||
backgroundColor: 'rgba(15,23,42,0.72)',
|
||||
borderRadius: 4,
|
||||
padding: [2, 6],
|
||||
fontSize: 10,
|
||||
@@ -586,7 +577,7 @@ function buildOption(
|
||||
color: '#3B82F6',
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
backgroundColor: theme.tooltipBg,
|
||||
backgroundColor: 'rgba(24,24,27,0.85)',
|
||||
borderColor: '#3B82F6',
|
||||
borderWidth: 1,
|
||||
padding: [1, 4],
|
||||
@@ -600,8 +591,8 @@ function buildOption(
|
||||
name: 'K', type: 'candlestick', data: candleData,
|
||||
animation: false,
|
||||
itemStyle: {
|
||||
color: theme.bull, color0: theme.bear,
|
||||
borderColor: theme.bull, borderColor0: theme.bear,
|
||||
color: THEME.bull, color0: THEME.bear,
|
||||
borderColor: THEME.bull, borderColor0: THEME.bear,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
markPoint: markPointData.length > 0 ? { data: markPointData, animation: false } : undefined,
|
||||
@@ -617,10 +608,10 @@ function buildOption(
|
||||
silent: true,
|
||||
lineStyle: { width: 1, color }, itemStyle: { color },
|
||||
})
|
||||
series.push(maLine('ma5', theme.ma5, 'MA5'))
|
||||
series.push(maLine('ma10', theme.ma10, 'MA10'))
|
||||
series.push(maLine('ma20', theme.ma20, 'MA20'))
|
||||
series.push(maLine('ma60', theme.ma60, 'MA60'))
|
||||
series.push(maLine('ma5', THEME.ma5, 'MA5'))
|
||||
series.push(maLine('ma10', THEME.ma10, 'MA10'))
|
||||
series.push(maLine('ma20', THEME.ma20, 'MA20'))
|
||||
series.push(maLine('ma60', THEME.ma60, 'MA60'))
|
||||
}
|
||||
|
||||
// BOLL 布林带 — 需在 activeIndicators 中激活
|
||||
@@ -651,7 +642,7 @@ function buildOption(
|
||||
top: chartTop,
|
||||
height: def.height,
|
||||
show: true,
|
||||
borderColor: theme.grid,
|
||||
borderColor: 'rgba(255,255,255,0.06)',
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
@@ -669,9 +660,9 @@ function buildOption(
|
||||
gridIndex: gridIdx,
|
||||
splitNumber: 2,
|
||||
axisLine: { show: false }, axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: theme.grid } },
|
||||
splitLine: { lineStyle: { color: THEME.grid } },
|
||||
axisLabel: {
|
||||
show: true, color: theme.text, fontSize: 9,
|
||||
show: true, color: THEME.text, fontSize: 9,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
},
|
||||
})
|
||||
@@ -688,14 +679,14 @@ function buildOption(
|
||||
|
||||
// 子图信息栏 graphic
|
||||
const subStartTop = topPad + candleAvail + candleBottomPad
|
||||
const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop, theme)
|
||||
const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop)
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: theme.bg,
|
||||
backgroundColor: THEME.bg,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross', crossStyle: { color: theme.crosshair } },
|
||||
axisPointer: { type: 'cross', crossStyle: { color: '#555' } },
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
textStyle: { fontSize: 0 },
|
||||
@@ -704,8 +695,7 @@ function buildOption(
|
||||
axisPointer: {
|
||||
link: [{ xAxisIndex: 'all' }],
|
||||
label: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
color: theme.tooltipText,
|
||||
backgroundColor: '#333',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: 10,
|
||||
},
|
||||
@@ -746,9 +736,6 @@ export function EChartsCandlestick({
|
||||
visibleBars = 60,
|
||||
activeIndicators = [],
|
||||
}: Props) {
|
||||
const chartTheme = useChartTheme()
|
||||
const theme = useMemo(() => getTHEME(chartTheme), [chartTheme])
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<ECharts | null>(null)
|
||||
const dataRef = useRef(data)
|
||||
@@ -767,8 +754,6 @@ export function EChartsCandlestick({
|
||||
const chartHeightRef = useRef(300)
|
||||
const subTotalHRef = useRef(0)
|
||||
const getInfoBarHTMLRef = useRef<() => string>(() => '')
|
||||
const themeRef = useRef(theme)
|
||||
themeRef.current = theme
|
||||
|
||||
// 强制刷新信息栏 DOM 的回调
|
||||
const infoBarRef = useRef<HTMLDivElement>(null)
|
||||
@@ -780,7 +765,7 @@ export function EChartsCandlestick({
|
||||
const chart = chartRef.current
|
||||
if (!chart) return
|
||||
const subStartTop = chartHeightRef.current - subTotalHRef.current
|
||||
const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop, themeRef.current)
|
||||
const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop)
|
||||
if (infoGraphics.length > 0) {
|
||||
chart.setOption({ graphic: infoGraphics }, { lazyUpdate: true })
|
||||
}
|
||||
@@ -829,19 +814,19 @@ export function EChartsCandlestick({
|
||||
const prev = idx > 0 ? data[idx - 1] : null
|
||||
const chg = prev ? d.close - prev.close : 0
|
||||
const isUp = chg >= 0
|
||||
const clr = isUp ? theme.bull : theme.bear
|
||||
const clr = isUp ? THEME.bull : THEME.bear
|
||||
const floatShares = stockInfo?.float_shares
|
||||
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
|
||||
|
||||
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
|
||||
html += `<span style="color:${theme.text}">${d.date}</span>`
|
||||
html += `<span style="color:${theme.text}">开</span>`
|
||||
html += `<span style="color:${d.open >= d.close ? theme.bear : theme.bull}">${d.open.toFixed(2)}</span>`
|
||||
html += `<span style="color:${theme.text}">高</span>`
|
||||
html += `<span style="color:${theme.bull}">${d.high.toFixed(2)}</span>`
|
||||
html += `<span style="color:${theme.text}">低</span>`
|
||||
html += `<span style="color:${theme.bear}">${d.low.toFixed(2)}</span>`
|
||||
html += `<span style="color:${theme.text}">收</span>`
|
||||
html += `<span style="color:${THEME.text}">${d.date}</span>`
|
||||
html += `<span style="color:${THEME.text}">开</span>`
|
||||
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
|
||||
html += `<span style="color:${THEME.text}">高</span>`
|
||||
html += `<span style="color:${THEME.bull}">${d.high.toFixed(2)}</span>`
|
||||
html += `<span style="color:${THEME.text}">低</span>`
|
||||
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
|
||||
html += `<span style="color:${THEME.text}">收</span>`
|
||||
html += `<span style="color:${clr};font-weight:600">${d.close.toFixed(2)}</span>`
|
||||
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
|
||||
if (prev) {
|
||||
@@ -849,18 +834,18 @@ export function EChartsCandlestick({
|
||||
html += `<span style="color:${clr};margin-left:8px">${isUp ? '+' : ''}${chgPct.toFixed(2)}%</span>`
|
||||
}
|
||||
if (turnoverRate != null) {
|
||||
html += `<span style="color:${theme.text}">换手</span>`
|
||||
html += `<span style="color:${theme.text}">${turnoverRate.toFixed(2)}%</span>`
|
||||
html += `<span style="color:${THEME.text}">换手</span>`
|
||||
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
|
||||
}
|
||||
html += `</div>`
|
||||
|
||||
// 第二行: MA + BOLL
|
||||
if (showMA) {
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
|
||||
if (d.ma5 != null) html += `<span style="color:${theme.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
|
||||
if (d.ma10 != null) html += `<span style="color:${theme.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
|
||||
if (d.ma20 != null) html += `<span style="color:${theme.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
|
||||
if (d.ma60 != null) html += `<span style="color:${theme.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
|
||||
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
|
||||
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
|
||||
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
|
||||
if (d.ma60 != null) html += `<span style="color:${THEME.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
|
||||
if (d.boll_upper != null && activeIndicators.includes('boll')) {
|
||||
html += `<span style="color:#E879F9">BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}</span>`
|
||||
}
|
||||
@@ -868,7 +853,7 @@ export function EChartsCandlestick({
|
||||
}
|
||||
|
||||
return html
|
||||
}, [data, stockInfo, showMA, activeIndicators, theme])
|
||||
}, [data, stockInfo, showMA, activeIndicators])
|
||||
getInfoBarHTMLRef.current = getInfoBarHTML
|
||||
|
||||
// data 变化时重置 infoIdx
|
||||
@@ -976,7 +961,7 @@ export function EChartsCandlestick({
|
||||
const isBuy = m.kind === 'buy'
|
||||
const isSell = m.kind === 'sell'
|
||||
if (m.above) {
|
||||
const dotColor = m.color ?? (isBuy ? '#FACC15' : theme.text)
|
||||
const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text)
|
||||
if (compact) {
|
||||
markPointData.push({
|
||||
name: m.date, coord: [m.date, d.high],
|
||||
@@ -1004,11 +989,11 @@ export function EChartsCandlestick({
|
||||
symbol: 'arrow', symbolSize: 12,
|
||||
symbolRotate: isBuy ? 0 : 180,
|
||||
symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'],
|
||||
itemStyle: { color: isBuy ? theme.bull : isSell ? theme.bear : theme.text },
|
||||
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text },
|
||||
label: {
|
||||
show: !!m.label, formatter: m.label ?? '',
|
||||
position: isBuy ? 'bottom' : 'top', distance: 8,
|
||||
color: theme.text, fontSize: 10,
|
||||
color: THEME.text, fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
},
|
||||
})
|
||||
@@ -1036,7 +1021,6 @@ export function EChartsCandlestick({
|
||||
activeIndicators, chartHeight,
|
||||
infoIdxRef.current,
|
||||
linkedPrice,
|
||||
theme,
|
||||
)
|
||||
|
||||
chart.setOption(option, true)
|
||||
@@ -1054,7 +1038,7 @@ export function EChartsCandlestick({
|
||||
if (infoEl) {
|
||||
infoEl.innerHTML = getInfoBarHTML()
|
||||
}
|
||||
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML, theme])
|
||||
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML])
|
||||
|
||||
// 渲染信息栏容器 (内容由 JS 直接写入)
|
||||
const initialHTML = useMemo(() => {
|
||||
@@ -1064,16 +1048,16 @@ export function EChartsCandlestick({
|
||||
const floatShares = stockInfo?.float_shares
|
||||
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
|
||||
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
|
||||
html += `<span style="color:${theme.text}">${d.date}</span>`
|
||||
html += `<span style="color:${theme.text}">开</span>`
|
||||
html += `<span style="color:${d.open >= d.close ? theme.bear : theme.bull}">${d.open.toFixed(2)}</span>`
|
||||
html += `<span style="color:${theme.text}">高</span>`
|
||||
html += `<span style="color:${theme.bull}">${d.high.toFixed(2)}</span>`
|
||||
html += `<span style="color:${theme.text}">低</span>`
|
||||
html += `<span style="color:${theme.bear}">${d.low.toFixed(2)}</span>`
|
||||
html += `<span style="color:${theme.text}">收</span>`
|
||||
html += `<span style="color:${THEME.text}">${d.date}</span>`
|
||||
html += `<span style="color:${THEME.text}">开</span>`
|
||||
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
|
||||
html += `<span style="color:${THEME.text}">高</span>`
|
||||
html += `<span style="color:${THEME.bull}">${d.high.toFixed(2)}</span>`
|
||||
html += `<span style="color:${THEME.text}">低</span>`
|
||||
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
|
||||
html += `<span style="color:${THEME.text}">收</span>`
|
||||
const prevClose0 = data[idx-1]?.close ?? d.close
|
||||
const clr0 = d.close >= prevClose0 ? theme.bull : theme.bear
|
||||
const clr0 = d.close >= prevClose0 ? THEME.bull : THEME.bear
|
||||
html += `<span style="color:${clr0};font-weight:600">${d.close.toFixed(2)}</span>`
|
||||
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
|
||||
if (idx > 0) {
|
||||
@@ -1081,29 +1065,30 @@ export function EChartsCandlestick({
|
||||
html += `<span style="color:${clr0};margin-left:8px">${chgPct0 >= 0 ? '+' : ''}${chgPct0.toFixed(2)}%</span>`
|
||||
}
|
||||
if (turnoverRate != null) {
|
||||
html += `<span style="color:${theme.text}">换手</span>`
|
||||
html += `<span style="color:${theme.text}">${turnoverRate.toFixed(2)}%</span>`
|
||||
html += `<span style="color:${THEME.text}">换手</span>`
|
||||
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
|
||||
}
|
||||
html += `</div>`
|
||||
if (showMA) {
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
|
||||
if (d.ma5 != null) html += `<span style="color:${theme.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
|
||||
if (d.ma10 != null) html += `<span style="color:${theme.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
|
||||
if (d.ma20 != null) html += `<span style="color:${theme.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
|
||||
if (d.ma60 != null) html += `<span style="color:${theme.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
|
||||
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
|
||||
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
|
||||
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
|
||||
if (d.ma60 != null) html += `<span style="color:${THEME.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
|
||||
if (d.boll_upper != null && activeIndicators.includes('boll')) {
|
||||
html += `<span style="color:#E879F9">BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}</span>`
|
||||
}
|
||||
html += `</div>`
|
||||
}
|
||||
return html
|
||||
}, [data, stockInfo, showMA, activeIndicators, theme])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 主图信息栏 — 内容由 JS 直接操作 innerHTML */}
|
||||
{showInfoBar && (
|
||||
<div ref={infoBarRef} style={{ backgroundColor: theme.tooltipBg }}
|
||||
<div ref={infoBarRef} style={{ backgroundColor: 'rgba(39,39,42,0.6)' }}
|
||||
dangerouslySetInnerHTML={{ __html: initialHTML }} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,26 +2,19 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts, EChartsOption } from 'echarts'
|
||||
import type { MinuteKlineRow } from '@/lib/api'
|
||||
import { useChartTheme, type ChartTheme } from '@/lib/chartTheme'
|
||||
|
||||
type YMode = 'adaptive' | 'limit'
|
||||
|
||||
function getTHEME(ct: ChartTheme) {
|
||||
return {
|
||||
line: '#3B82F6',
|
||||
areaFill: 'rgba(59,130,246,0.40)',
|
||||
avgLine: '#F59E0B',
|
||||
refLine: ct.crosshair,
|
||||
volUp: 'rgba(240,68,56,0.6)',
|
||||
volDown: 'rgba(18,183,106,0.6)',
|
||||
text: ct.text,
|
||||
grid: ct.splitLine,
|
||||
border: ct.axisLine,
|
||||
tooltipBg: ct.tooltipBg,
|
||||
tooltipBorder: ct.tooltipBorder,
|
||||
tooltipText: ct.tooltipText,
|
||||
crosshair: ct.crosshair,
|
||||
}
|
||||
const THEME = {
|
||||
line: '#3B82F6',
|
||||
areaFill: 'rgba(59,130,246,0.40)',
|
||||
avgLine: '#F59E0B',
|
||||
refLine: 'rgba(255,255,255,0.25)',
|
||||
volUp: 'rgba(240,68,56,0.6)',
|
||||
volDown: 'rgba(18,183,106,0.6)',
|
||||
text: '#A1A1AA',
|
||||
grid: 'rgba(255,255,255,0.04)',
|
||||
border: '#27272A',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -115,18 +108,7 @@ function getLimitPrices(prevClose: number, symbol?: string): {
|
||||
return { limitUp, limitDown, upPct, downPct }
|
||||
}
|
||||
|
||||
function buildOption(
|
||||
data: MinuteKlineRow[],
|
||||
prevClose: number | undefined,
|
||||
avgPrices: number[],
|
||||
lineColor: string,
|
||||
areaColor: string,
|
||||
yMode: YMode,
|
||||
theme: ReturnType<typeof getTHEME>,
|
||||
symbol?: string,
|
||||
showLimitLines = true,
|
||||
showAvgLine = true,
|
||||
): EChartsOption {
|
||||
function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, symbol?: string, showLimitLines = true, showAvgLine = true): EChartsOption {
|
||||
// 将数据映射到全天时间轴上的正确位置
|
||||
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
|
||||
const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
|
||||
@@ -147,7 +129,7 @@ function buildOption(
|
||||
volumes[idx] = {
|
||||
value: data[i].volume,
|
||||
itemStyle: {
|
||||
color: data[i].close > data[i].open ? theme.volUp : data[i].close < data[i].open ? theme.volDown : volNeutral,
|
||||
color: data[i].close > data[i].open ? THEME.volUp : data[i].close < data[i].open ? THEME.volDown : volNeutral,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -168,7 +150,7 @@ function buildOption(
|
||||
if (prevClose != null) {
|
||||
markLineData.push({
|
||||
yAxis: prevClose,
|
||||
lineStyle: { color: theme.refLine, type: 'dashed', width: 1 },
|
||||
lineStyle: { color: THEME.refLine, type: 'dashed', width: 1 },
|
||||
label: { show: false },
|
||||
symbol: 'none',
|
||||
})
|
||||
@@ -255,16 +237,16 @@ function buildOption(
|
||||
type: 'cross',
|
||||
label: {
|
||||
show: true,
|
||||
backgroundColor: theme.tooltipBg,
|
||||
borderColor: theme.tooltipBorder,
|
||||
backgroundColor: 'rgba(39,39,42,0.9)',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
padding: [2, 5],
|
||||
color: theme.text,
|
||||
color: '#A1A1AA',
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
},
|
||||
crossStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
|
||||
lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
|
||||
crossStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
|
||||
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
|
||||
},
|
||||
},
|
||||
axisPointer: {
|
||||
@@ -281,14 +263,14 @@ function buildOption(
|
||||
boundaryGap: false,
|
||||
axisPointer: {
|
||||
show: true,
|
||||
lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
|
||||
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
|
||||
label: {
|
||||
show: true,
|
||||
backgroundColor: theme.tooltipBg,
|
||||
borderColor: theme.tooltipBorder,
|
||||
backgroundColor: 'rgba(39,39,42,0.9)',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
padding: [2, 4],
|
||||
color: theme.text,
|
||||
color: '#A1A1AA',
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
formatter: (params: any) => {
|
||||
@@ -298,7 +280,7 @@ function buildOption(
|
||||
},
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: theme.text,
|
||||
color: THEME.text,
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
formatter: xAxisLabelFormatter,
|
||||
@@ -307,7 +289,7 @@ function buildOption(
|
||||
axisTick: { show: false },
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: { color: theme.grid },
|
||||
lineStyle: { color: 'rgba(255,255,255,0.04)' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -330,7 +312,7 @@ function buildOption(
|
||||
splitArea: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: theme.grid } },
|
||||
splitLine: { lineStyle: { color: THEME.grid } },
|
||||
axisPointer: {
|
||||
label: {
|
||||
formatter: (params: any) => {
|
||||
@@ -340,7 +322,7 @@ function buildOption(
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: theme.text,
|
||||
color: THEME.text,
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
formatter: (v: number) => v.toFixed(2),
|
||||
@@ -378,7 +360,7 @@ function buildOption(
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: theme.text,
|
||||
color: THEME.text,
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
formatter: (v: number) => {
|
||||
@@ -409,7 +391,7 @@ function buildOption(
|
||||
smooth: false,
|
||||
symbol: 'none',
|
||||
cursor: 'crosshair',
|
||||
lineStyle: { width: 1, color: theme.avgLine },
|
||||
lineStyle: { width: 1, color: THEME.avgLine },
|
||||
connectNulls: true,
|
||||
}] : []),
|
||||
{
|
||||
@@ -425,9 +407,6 @@ function buildOption(
|
||||
}
|
||||
|
||||
export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, onPriceHover, showLimitLines = true, showAvgLine = true }: Props) {
|
||||
const chartTheme = useChartTheme()
|
||||
const theme = useMemo(() => getTHEME(chartTheme), [chartTheme])
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<ECharts | null>(null)
|
||||
const roRef = useRef<ResizeObserver | null>(null)
|
||||
@@ -515,11 +494,11 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
|
||||
}
|
||||
fullDayToDataIdx.current = mapping
|
||||
|
||||
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, theme, symbol, showLimitLines, showAvgLine), true)
|
||||
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine), true)
|
||||
} else {
|
||||
chart.clear()
|
||||
}
|
||||
}, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine, theme])
|
||||
}, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -569,7 +548,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
<div style={{ backgroundColor: theme.tooltipBg }}>
|
||||
<div style={{ backgroundColor: 'rgba(39,39,42,0.6)' }}>
|
||||
{/* 第一行: 日期 + OHLC */}
|
||||
<div className="flex items-center gap-x-2 px-2 font-mono text-[11px] select-none flex-wrap" style={{ height: 20 }}>
|
||||
{!d && <span className="text-muted">—</span>}
|
||||
@@ -596,8 +575,8 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
|
||||
<span style={{ color: priceClr }}>{d.close.toFixed(2)}</span>
|
||||
</span>
|
||||
{showAvgLine && <span className="flex items-center gap-x-1">
|
||||
<span style={{ display: 'inline-block', width: 14, height: 2, background: theme.avgLine }} />
|
||||
<span style={{ color: theme.avgLine }}>{avg?.toFixed(2)}</span>
|
||||
<span style={{ display: 'inline-block', width: 14, height: 2, background: THEME.avgLine }} />
|
||||
<span style={{ color: THEME.avgLine }}>{avg?.toFixed(2)}</span>
|
||||
</span>}
|
||||
<span className="text-muted">量</span>
|
||||
<span className="text-secondary">{d.volume.toFixed(0)}</span>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({})
|
||||
const [switching, setSwitching] = useState<string | null>(null)
|
||||
|
||||
// 动态加载端点清单 —— 前端无法跨域直连数据源官网,走后端代理
|
||||
// 动态加载端点清单 —— 前端无法跨域直连 tickflow.org,走后端代理
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: QK.endpoints,
|
||||
queryFn: api.listEndpoints,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Clock } from 'lucide-react'
|
||||
import type { StockRef } from '@/lib/useLastStock'
|
||||
|
||||
/**
|
||||
* "上次查看"个股胶囊 —— 显示在 PageHeader 右侧。
|
||||
* 上方名称、下方代码,小字体二排;点击恢复该个股的查看。
|
||||
*/
|
||||
export function LastStockChip({
|
||||
stock,
|
||||
onSelect,
|
||||
}: {
|
||||
stock: StockRef | null
|
||||
onSelect?: (symbol: string, name: string) => void
|
||||
}) {
|
||||
if (!stock) return null
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect?.(stock.symbol, stock.name)}
|
||||
title={`继续查看 ${stock.name}`}
|
||||
className="group inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-elevated/40 px-2 py-1 hover:border-border hover:bg-elevated transition-colors"
|
||||
>
|
||||
<Clock className="h-3 w-3 text-muted shrink-0" />
|
||||
<span className="flex flex-col items-start leading-tight">
|
||||
<span className="text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors max-w-[7em] truncate">
|
||||
{stock.name}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-muted">{stock.symbol}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { motion } from 'framer-motion'
|
||||
import { useQuoteStream } from '@/lib/useQuoteStream'
|
||||
import { ToastContainer } from '@/components/Toast'
|
||||
import { AlertToastContainer } from '@/components/AlertToast'
|
||||
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
|
||||
import { AiReportBubble } from '@/components/financials/AiReportBubble'
|
||||
import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost'
|
||||
import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble'
|
||||
import {
|
||||
useCapabilities,
|
||||
useSettings,
|
||||
@@ -18,23 +22,37 @@ import {
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { tierRank } from '@/lib/capability-labels'
|
||||
import {
|
||||
Star,
|
||||
ScanSearch,
|
||||
History,
|
||||
FileText,
|
||||
Settings,
|
||||
Key,
|
||||
Database,
|
||||
Timer,
|
||||
Loader2,
|
||||
LayoutDashboard,
|
||||
Tags,
|
||||
TrendingUp,
|
||||
Flame,
|
||||
BarChart3,
|
||||
Sparkles,
|
||||
Layers3,
|
||||
Landmark,
|
||||
Cable,
|
||||
RadioTower,
|
||||
CheckCircle2,
|
||||
BookOpenCheck,
|
||||
ExternalLink,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { useTheme } from './ThemeProvider'
|
||||
import { api, type IndexQuote } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
|
||||
|
||||
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
||||
const BRAND = '#8B5CF6'
|
||||
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
|
||||
|
||||
const CORE_INDEXES = [
|
||||
{ symbol: '000001.SH', name: '上证指数' },
|
||||
@@ -46,8 +64,20 @@ const CORE_INDEXES = [
|
||||
type CoreIndex = (typeof CORE_INDEXES)[number]
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||
{ to: '/data', label: '数据', icon: Database },
|
||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||
{ to: '/watchlist', label: '自选', icon: Star },
|
||||
{ to: '/screener', label: '策略', icon: ScanSearch },
|
||||
{ to: '/backtest', label: '回测', icon: History },
|
||||
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
||||
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
|
||||
{ to: '/financials', label: '财务分析', icon: FileText },
|
||||
{ to: '/monitor', label: '监控中心', icon: RadioTower },
|
||||
{ to: '/review', label: '复盘', icon: BookOpenCheck },
|
||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||
{ to: '/trading', label: '交易', icon: Cable },
|
||||
{ to: '/data', label: '数据', icon: Database },
|
||||
] as const
|
||||
|
||||
function fmtIndexValue(v: number | null | undefined) {
|
||||
@@ -130,7 +160,7 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||
labelTextStyle: { color: '#71717a' },
|
||||
},
|
||||
free: {
|
||||
desc: '基础日K · 单股查询',
|
||||
desc: '基础日K · 自选实时',
|
||||
tagBg: { background: 'rgba(113,113,122,0.3)' },
|
||||
dotStyle: { background: '#71717a' },
|
||||
labelTextStyle: { color: '#a1a1aa' },
|
||||
@@ -156,8 +186,8 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||
}
|
||||
|
||||
const t = tierConfig[base] || tierConfig.none
|
||||
// none 档显示中文「无」,无 label 时显示「无档」
|
||||
const displayLabel = isNone ? '无' : (label || '无')
|
||||
// none 档显示英文「None」,无 label 时也显示「None」
|
||||
const displayLabel = isNone ? 'None' : (label || 'None')
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -173,7 +203,7 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground">数据源</span>
|
||||
<span className="text-xs font-medium text-foreground">TickFlow</span>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ ...t.dotStyle, ...(base === 'expert' ? { animation: 'pulse 2s infinite' } : {}) }}
|
||||
@@ -228,13 +258,17 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
|
||||
|
||||
export function Layout() {
|
||||
// ===== 共享 hooks (替代内联 useQuery) =====
|
||||
const { resolved } = useTheme()
|
||||
const isDark = resolved === 'dark'
|
||||
const { data: caps } = useCapabilities()
|
||||
const { data: settingsState } = useSettings()
|
||||
const { data: versionData } = useVersion()
|
||||
const { data: prefs } = usePreferences()
|
||||
const { data: quoteStatus } = useQuoteStatus()
|
||||
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
|
||||
const { data: quoteStatus } = useQuoteStatus({ poll: true })
|
||||
const { data: analysisMenus } = useQuery({
|
||||
queryKey: QK.analysisMenus,
|
||||
queryFn: api.analysisMenus,
|
||||
})
|
||||
|
||||
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
|
||||
const { data: pipelineJobs } = useQuery({
|
||||
queryKey: QK.pipelineJobs,
|
||||
@@ -263,6 +297,8 @@ export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const version = versionData?.version
|
||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
||||
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
|
||||
const [dismissFreeHint, setDismissFreeHint] = useState(false)
|
||||
const indicesPinned = prefs?.indices_nav_pinned ?? true
|
||||
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
|
||||
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
|
||||
@@ -281,8 +317,10 @@ export function Layout() {
|
||||
const toggleQuote = useToggleRealtimeQuotes()
|
||||
const isRunning = quoteStatus?.running ?? false
|
||||
const isTrading = quoteStatus?.is_trading_hours ?? false
|
||||
// none/free 档(无实时行情权限)→ rank < starter(1)
|
||||
const isFreeTier = tierRank(caps?.label ?? '') < 1
|
||||
const tier = tierRank(caps?.label ?? '')
|
||||
const isNoneTier = tier < 0
|
||||
const isWatchlistMode = tier === 0
|
||||
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
|
||||
|
||||
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
|
||||
const alertsTotalQuery = useQuery({
|
||||
@@ -298,8 +336,10 @@ export function Layout() {
|
||||
if (alertsTotal != null) setAlertTotal(alertsTotal)
|
||||
}, [alertsTotal])
|
||||
|
||||
// 当前仅开放看板和数据业务,扩展分析菜单暂不展示
|
||||
const analysisNav: { to: string; label: string; icon: typeof LayoutDashboard }[] = []
|
||||
// 合并内置页面 + 可见的扩展分析菜单
|
||||
const analysisNav = (analysisMenus?.items ?? [])
|
||||
.filter(m => m.visible)
|
||||
.map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 }))
|
||||
|
||||
const allNav = [...nav, ...analysisNav]
|
||||
const savedOrder = prefs?.nav_order ?? []
|
||||
@@ -325,7 +365,12 @@ export function Layout() {
|
||||
queryKey: QK.capabilities,
|
||||
queryFn: api.capabilities,
|
||||
})
|
||||
if (tierRank(fresh.label ?? '') < 1) return
|
||||
const freshTier = tierRank(fresh.label ?? '')
|
||||
if (freshTier < 0) return
|
||||
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
|
||||
navigate('/watchlist')
|
||||
return
|
||||
}
|
||||
}
|
||||
await toggleQuote.mutateAsync(enabled)
|
||||
// 仅在交易时段立即获取一次行情
|
||||
@@ -342,20 +387,20 @@ export function Layout() {
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Logo
|
||||
size={28}
|
||||
className={cn('shrink-0', isDark && 'drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]')}
|
||||
className="shrink-0 drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]"
|
||||
style={{ color: BRAND }}
|
||||
/>
|
||||
<div
|
||||
className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight"
|
||||
style={{ textShadow: isDark ? `0 0 10px ${BRAND}44` : 'none' }}
|
||||
style={{ textShadow: `0 0 10px ${BRAND}44` }}
|
||||
>
|
||||
<div>A股</div>
|
||||
<div>工作台</div>
|
||||
<div>TickFlow</div>
|
||||
<div>Stock Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
||||
量化工具
|
||||
Quant · Terminal
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -363,14 +408,12 @@ export function Layout() {
|
||||
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
||||
/>
|
||||
|
||||
{settingsState?.mode === 'none' && (
|
||||
<TierBadge
|
||||
label={caps?.label ?? ''}
|
||||
hasKey={false}
|
||||
/>
|
||||
)}
|
||||
<TierBadge
|
||||
label={caps?.label ?? ''}
|
||||
hasKey={settingsState?.mode !== 'none'}
|
||||
/>
|
||||
<AIConfigBadge
|
||||
configured={settingsState?.has_ai_key}
|
||||
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
|
||||
model={settingsState?.ai_model}
|
||||
/>
|
||||
</div>
|
||||
@@ -393,6 +436,12 @@ export function Layout() {
|
||||
<>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{/* 个股分析 Beta 标识 */}
|
||||
{(to === '/stock-analysis' || to === '/review') && (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
|
||||
{to === '/data' && isDataSyncing && (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||
@@ -410,13 +459,27 @@ export function Layout() {
|
||||
|
||||
{/* 全局行情开关 */}
|
||||
<div className="border-t border-border px-3 py-2.5 shrink-0">
|
||||
{isFreeTier ? (
|
||||
/* Free 档位 — 显示升级提示 */
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary truncate">实时行情</span>
|
||||
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
|
||||
需 Starter+
|
||||
</span>
|
||||
{isNoneTier ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary truncate">实时行情</span>
|
||||
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
|
||||
Free+
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[10px] leading-snug text-muted">
|
||||
免费注册
|
||||
<a
|
||||
href={TICKFLOW_REGISTER_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
|
||||
>
|
||||
TickFlow
|
||||
<ExternalLink className="h-2.5 w-2.5 self-center" />
|
||||
</a>
|
||||
开启个股监控
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Starter+ — 开关 + 跳转设置 */
|
||||
@@ -430,14 +493,14 @@ export function Layout() {
|
||||
: 'bg-muted'
|
||||
}`} />
|
||||
<span className="text-xs text-secondary truncate">
|
||||
实时行情
|
||||
实时行情 · {realtimeModeLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => navigate('/settings?tab=monitoring')}
|
||||
className="text-secondary hover:text-foreground transition-colors shrink-0"
|
||||
title="实时监控设置"
|
||||
>
|
||||
<Timer className="h-3 w-3" />
|
||||
<Settings className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -457,16 +520,28 @@ export function Layout() {
|
||||
)}
|
||||
|
||||
{/* 状态提示 */}
|
||||
{realtimeEnabled && !isFreeTier && (
|
||||
<div className="mt-1.5 text-[10px] leading-snug">
|
||||
{realtimeEnabled && !isNoneTier && (
|
||||
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
|
||||
{isWatchlistMode && !dismissFreeHint && (
|
||||
<div className="flex items-start gap-1 text-amber-400/80">
|
||||
<span className="flex-1">监控自选股前 5 只,全市场监控需 Starter+</span>
|
||||
<button
|
||||
onClick={() => setDismissFreeHint(true)}
|
||||
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
|
||||
title="关闭提示"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isRunning && isTrading ? (
|
||||
<span className="text-accent">行情运行中</span>
|
||||
<div className="text-accent">行情运行中</div>
|
||||
) : realtimeEnabled && !isTrading ? (
|
||||
<span className="text-warning/70">非交易时段,将在交易时间自动开启</span>
|
||||
<div className="text-warning/70">非交易时段,将在交易时间自动开启</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{showSidebarQuotes && !isFreeTier && (
|
||||
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
|
||||
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
|
||||
)}
|
||||
</div>
|
||||
@@ -504,6 +579,10 @@ export function Layout() {
|
||||
</motion.main>
|
||||
<ToastContainer />
|
||||
<AlertToastContainer />
|
||||
<AiAnalysisHost />
|
||||
<AiReportBubble />
|
||||
<StockAnalysisHost />
|
||||
<StockAnalysisBubble />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) {
|
||||
className={className}
|
||||
style={style}
|
||||
role="img"
|
||||
aria-label="Stock Panel"
|
||||
aria-label="TickFlow Stock Panel"
|
||||
>
|
||||
{/* 左方括号 */}
|
||||
<path
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Repeat, Sparkles, ArrowDownUp, RefreshCw, AlertCircle } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fmtPct } from '@/lib/format'
|
||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 12
|
||||
const ROW_HEIGHT = 30 // 每行高度(px), 与单元格样式配合
|
||||
const OVERSCAN = 8 // 上下额外渲染行数, 减少滚动时的白屏闪烁
|
||||
const MIN_DAYS = 7
|
||||
const MAX_DAYS = 30
|
||||
|
||||
// 涨幅 → 背景色梯度(A 股语义: 红涨绿跌)。强度越大色越深, 一眼看出强势/弱势概念
|
||||
function pctBgClass(pct: number): string {
|
||||
if (pct >= 0.05) return 'bg-bull/25'
|
||||
if (pct >= 0.03) return 'bg-bull/18'
|
||||
if (pct >= 0.01) return 'bg-bull/10'
|
||||
if (pct > -0.01) return ''
|
||||
if (pct > -0.03) return 'bg-bear/10'
|
||||
if (pct > -0.05) return 'bg-bear/18'
|
||||
return 'bg-bear/25'
|
||||
}
|
||||
|
||||
// 把 "2026-07-01" 格式化成 "7/01" 紧凑显示(表头窄列)
|
||||
function shortDate(s: string): string {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
|
||||
if (!m) return s
|
||||
return `${Number(m[2])}/${m[3]}`
|
||||
}
|
||||
|
||||
// 排名 → 前景色(A 股语义: 红=强, 绿=弱)。前 10 红, 后 10 绿, 中间默认强调色。
|
||||
// total 兜底: 概念总数未知时只判前 10, 不判后 10。
|
||||
function rankColorClass(rank: number, total: number): string {
|
||||
if (rank <= 10) return 'text-bull'
|
||||
if (total > 20 && rank > total - 10) return 'text-bear'
|
||||
return 'text-accent'
|
||||
}
|
||||
|
||||
export function RpsRotationDialog({ onClose }: Props) {
|
||||
const [days, setDays] = useState(DEFAULT_DAYS)
|
||||
const [reversed, setReversed] = useState(false) // false=高→低, true=低→高
|
||||
const [selected, setSelected] = useState<string | null>(null) // 点中的概念名, 高亮追踪
|
||||
|
||||
// ---- AI 轮动分析状态 (组件内, 不建全局 store: 切页即关对话框) ----
|
||||
const [analysis, setAnalysis] = useState('') // 累积的 Markdown 报告
|
||||
const [analyzing, setAnalyzing] = useState(false) // 生成中
|
||||
const [analysisError, setAnalysisError] = useState('') // 错误信息
|
||||
const [analysisMeta, setAnalysisMeta] = useState<{ summary?: string } | null>(null)
|
||||
const [focus, setFocus] = useState('') // 用户追加的关注点
|
||||
|
||||
const runAnalysis = useCallback(async (daysParam: number, focusParam: string) => {
|
||||
setAnalyzing(true)
|
||||
setAnalysis('')
|
||||
setAnalysisError('')
|
||||
setAnalysisMeta(null)
|
||||
try {
|
||||
for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam)) {
|
||||
if (ev.type === 'meta') setAnalysisMeta({ summary: ev.summary })
|
||||
else if (ev.type === 'delta') setAnalysis(a => a + (ev.content ?? ''))
|
||||
else if (ev.type === 'error') setAnalysisError(ev.message ?? '未知错误')
|
||||
// done: 无操作
|
||||
}
|
||||
} catch (e) {
|
||||
setAnalysisError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: QK.rpsRotation(days),
|
||||
queryFn: () => api.rpsRotation(days),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const dates = data?.dates ?? []
|
||||
const columns = data?.columns ?? {}
|
||||
const conceptCount = data?.concept_count ?? 0
|
||||
|
||||
// 行数 = 最长那列的长度(理论上每天概念数应一致, 取最大兜底)
|
||||
const rowCount = useMemo(
|
||||
() => dates.reduce((m, d) => Math.max(m, columns[d]?.length ?? 0), 0),
|
||||
[dates, columns],
|
||||
)
|
||||
|
||||
// 行索引: 翻转时不重排数据, 只翻转访问索引(省一次大数组操作)
|
||||
const getRowIndex = useCallback(
|
||||
(displayIdx: number) => (reversed ? rowCount - 1 - displayIdx : displayIdx),
|
||||
[reversed, rowCount],
|
||||
)
|
||||
|
||||
// ---- 手写虚拟滚动 ----
|
||||
// 监听滚动容器 scrollTop, 只渲染 [firstIdx, lastIdx] 范围内的行。
|
||||
// 387 行只画可视的 ~25 行 + overscan, DOM 恒定 ~30 行 × N 列, 滚动 60fps。
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
// AI 报告区滚动容器: 流式生成时自动滚到底部
|
||||
const analysisRef = useRef<HTMLDivElement>(null)
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 25 })
|
||||
|
||||
// 流式生成中: analysis 每次追加都把报告区滚到底部, 跟踪最新文字
|
||||
useEffect(() => {
|
||||
if (!analyzing) return
|
||||
const el = analysisRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [analysis, analyzing])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const scrollTop = el.scrollTop
|
||||
const viewportH = el.clientHeight
|
||||
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN)
|
||||
const end = Math.min(rowCount, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN)
|
||||
setVisibleRange(prev => (prev.start === start && prev.end === end ? prev : { start, end }))
|
||||
}, [rowCount])
|
||||
|
||||
useEffect(() => {
|
||||
// rowCount 变化(切天数/数据到达)时重算可视范围
|
||||
handleScroll()
|
||||
}, [handleScroll, rowCount])
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
// 选中概念的追踪行: 找出它在每个日期列的(排名, 涨幅)。
|
||||
// 每列已按涨幅降序排好, 故排名 = 该概念在数组里的索引 + 1。
|
||||
// 未入选该日(概念当天无数据)显示空, 便于横向看排名变化。
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!selected) return null
|
||||
const cells: ({ rank: number; pct: number } | null)[] = []
|
||||
for (const d of dates) {
|
||||
const col = columns[d] ?? []
|
||||
const idx = col.findIndex(([name]) => name === selected)
|
||||
cells.push(idx >= 0 ? { rank: idx + 1, pct: col[idx][1] } : null)
|
||||
}
|
||||
return cells
|
||||
}, [selected, dates, columns])
|
||||
|
||||
const renderRows = useMemo(() => {
|
||||
const rows: JSX.Element[] = []
|
||||
for (let displayIdx = visibleRange.start; displayIdx < visibleRange.end; displayIdx++) {
|
||||
const rawIdx = getRowIndex(displayIdx)
|
||||
const cells = dates.map((d) => {
|
||||
const cell = columns[d]?.[rawIdx]
|
||||
if (!cell) {
|
||||
return (
|
||||
<td key={d} className="px-2 py-1 text-center text-muted/40">
|
||||
<span className="text-[10px]">—</span>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
const [name, pct] = cell
|
||||
const isSelected = selected === name
|
||||
return (
|
||||
<td
|
||||
key={d}
|
||||
onClick={() => setSelected(prev => prev === name ? null : name)}
|
||||
className={cn(
|
||||
'px-2 py-1 cursor-pointer whitespace-nowrap text-center align-middle transition-colors',
|
||||
pctBgClass(pct),
|
||||
isSelected && 'ring-1 ring-inset ring-accent bg-accent/20',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-0.5 leading-tight">
|
||||
<span className={cn(
|
||||
'text-[11px] max-w-[84px] truncate',
|
||||
isSelected ? 'text-accent font-medium' : 'text-secondary',
|
||||
)} title={name}>{name}</span>
|
||||
<span className={cn(
|
||||
'text-[10px] tabular-nums',
|
||||
pct > 0 ? 'text-bull' : pct < 0 ? 'text-bear' : 'text-muted',
|
||||
)}>{fmtPct(pct)}</span>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})
|
||||
rows.push(
|
||||
<tr
|
||||
key={displayIdx}
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
className="border-b border-border/30"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-surface px-2 text-center text-[10px] text-muted tabular-nums border-r border-border/40">
|
||||
{displayIdx + 1}
|
||||
</td>
|
||||
{cells}
|
||||
</tr>,
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}, [visibleRange, getRowIndex, dates, columns, selected])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-[92vw] max-w-[1100px] h-[88vh] bg-surface border border-border rounded-card shadow-xl flex flex-col"
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-foreground">概念涨幅轮动</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
{conceptCount > 0 ? `${dates.length} 天 · ${conceptCount} 个概念` : '暂无数据'}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
|
||||
<X className="h-4 w-4 text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 上半区: AI 轮动分析 */}
|
||||
<div className="shrink-0 border-b border-border flex flex-col max-h-[42%]">
|
||||
{/* 标题栏: 标题 + meta 摘要 + focus 输入 + 触发按钮 */}
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-elevated/30 shrink-0">
|
||||
<Sparkles className={cn('h-3.5 w-3.5 text-accent/60', analyzing && 'animate-pulse')} />
|
||||
<span className="text-[11px] text-muted shrink-0">AI 轮动分析</span>
|
||||
{analysisMeta?.summary && (
|
||||
<span className="text-[11px] text-accent/80 truncate">{analysisMeta.summary}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={e => setFocus(e.target.value)}
|
||||
placeholder="关注点(可选)"
|
||||
disabled={analyzing}
|
||||
className="w-28 px-2 py-0.5 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => runAnalysis(days, focus)}
|
||||
disabled={analyzing}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] transition-colors cursor-pointer border',
|
||||
analyzing
|
||||
? 'opacity-60 cursor-not-allowed border-border text-muted'
|
||||
: 'bg-accent/10 text-accent border-accent/30 hover:bg-accent/20',
|
||||
)}
|
||||
>
|
||||
{analyzing
|
||||
? <><RefreshCw className="h-3 w-3 animate-spin" />分析中</>
|
||||
: analysis
|
||||
? <><RefreshCw className="h-3 w-3" />重新分析</>
|
||||
: <><Sparkles className="h-3 w-3" />生成分析</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 报告内容区: 四态渲染 */}
|
||||
<div ref={analysisRef} className="flex-1 min-h-0 overflow-auto">
|
||||
{analysisError ? (
|
||||
<div className="flex items-center gap-2 px-4 py-4 text-[11px] text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{analysisError}</span>
|
||||
<button
|
||||
onClick={() => runAnalysis(days, focus)}
|
||||
className="ml-auto text-accent hover:underline shrink-0"
|
||||
>重试</button>
|
||||
</div>
|
||||
) : analysis || analyzing ? (
|
||||
<div className="px-4 py-2.5 text-[12px] leading-relaxed">
|
||||
<MarkdownRenderer content={analysis} />
|
||||
{analyzing && (
|
||||
<span className="inline-block w-1.5 h-3.5 bg-accent animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-4 text-center text-[11px] text-muted/60">
|
||||
点击「生成分析」,AI 将从主线研判 / 新晋强势 / 退潮预警 / 机构vs游资 等角度分析最近 {days} 天的概念轮动
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted">天数</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_DAYS}
|
||||
max={MAX_DAYS}
|
||||
step={1}
|
||||
value={days}
|
||||
onChange={e => setDays(Number(e.target.value))}
|
||||
className="w-24 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] text-secondary tabular-nums w-5">{days}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setReversed(r => !r)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] transition-colors cursor-pointer border',
|
||||
reversed
|
||||
? 'bg-accent/10 text-accent border-accent/30'
|
||||
: 'border-border text-muted hover:text-secondary hover:bg-elevated',
|
||||
)}
|
||||
title="翻转排序(高↔低)"
|
||||
>
|
||||
<ArrowDownUp className="h-3 w-3" />
|
||||
{reversed ? '低→高' : '高→低'}
|
||||
</button>
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="text-[11px] text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
取消追踪「{selected}」
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下半区: 涨幅轮动矩阵(虚拟滚动) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-[11px] text-danger">
|
||||
加载失败,请稍后重试
|
||||
</div>
|
||||
) : rowCount === 0 ? (
|
||||
<div className="flex items-center justify-center py-16 text-[11px] text-muted">
|
||||
暂无概念数据,请先在「概念分析」页配置并获取概念数据源
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-auto"
|
||||
>
|
||||
<table className="min-w-full border-collapse">
|
||||
{/* 表头: 日期列, 最新在最左 */}
|
||||
<thead className="sticky top-0 z-20 bg-surface">
|
||||
<tr>
|
||||
<th className="sticky left-0 z-30 bg-surface px-2 py-1.5 text-[10px] font-normal text-muted border-b border-r border-border/40">
|
||||
#
|
||||
</th>
|
||||
{dates.map(d => (
|
||||
<th
|
||||
key={d}
|
||||
className="px-2 py-1.5 text-[10px] font-normal text-muted border-b border-border/40 whitespace-nowrap text-center"
|
||||
title={d}
|
||||
>
|
||||
{shortDate(d)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* 选中概念追踪行: 在日期表头下方单独一行, 横向展示它在各日的排名+涨幅 */}
|
||||
<AnimatePresence>
|
||||
{selected && selectedRow && (
|
||||
<motion.tr
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="border-b border-accent/20 bg-accent/5"
|
||||
>
|
||||
<td className="sticky left-0 z-30 bg-surface px-2 py-1 text-center border-r border-border/40">
|
||||
<span className="text-[10px] text-accent truncate block max-w-[44px]" title={selected}>
|
||||
{selected}
|
||||
</span>
|
||||
</td>
|
||||
{selectedRow.map((cell, i) => (
|
||||
<td key={i} className="px-2 py-1 text-center whitespace-nowrap align-middle">
|
||||
{cell ? (
|
||||
<div className="flex flex-col items-center gap-0.5 leading-tight">
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium tabular-nums',
|
||||
rankColorClass(cell.rank, conceptCount),
|
||||
)}>
|
||||
#{cell.rank}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-[10px] tabular-nums',
|
||||
cell.pct > 0 ? 'text-bull' : cell.pct < 0 ? 'text-bear' : 'text-muted',
|
||||
)}>
|
||||
{fmtPct(cell.pct)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</motion.tr>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* 顶部占位: 把滚动位置撑起来 */}
|
||||
{visibleRange.start > 0 && (
|
||||
<tr style={{ height: visibleRange.start * ROW_HEIGHT }}>
|
||||
<td colSpan={dates.length + 1} />
|
||||
</tr>
|
||||
)}
|
||||
{renderRows}
|
||||
{/* 底部占位 */}
|
||||
{visibleRange.end < rowCount && (
|
||||
<tr style={{ height: (rowCount - visibleRange.end) * ROW_HEIGHT }}>
|
||||
<td colSpan={dates.length + 1} />
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="px-4 py-1.5 border-t border-border shrink-0">
|
||||
<span className="text-[10px] text-muted">
|
||||
每列各自按当日涨幅排序 · 点击单元格追踪概念在各日的排名变化
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ function SealedDirBlock({ title, color, counts, rawTotal }: {
|
||||
</div>
|
||||
<div className="flex gap-3 px-1 text-[10px]">
|
||||
<span className={`flex items-center gap-0.5 text-${color}`}><span className={`h-1 w-1 rounded-full bg-${color}`} />真封 {real}</span>
|
||||
<span className="flex items-center gap-0.5 text-yellow-600 dark:text-yellow-500"><span className="h-1 w-1 rounded-full bg-yellow-500" />假 {fake}</span>
|
||||
<span className="flex items-center gap-0.5 text-yellow-500"><span className="h-1 w-1 rounded-full bg-yellow-500" />假 {fake}</span>
|
||||
{pending > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-muted"><span className="h-1 w-1 rounded-full bg-muted" />待 {pending}</span>
|
||||
)}
|
||||
@@ -77,11 +77,11 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea
|
||||
<div className="relative inline-flex items-center">
|
||||
<button
|
||||
onClick={() => setShowHint(v => !v)}
|
||||
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-100 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 cursor-help transition-all hover:bg-yellow-200 dark:hover:bg-yellow-500/20 hover:border-yellow-400 dark:hover:border-yellow-500/50"
|
||||
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
|
||||
<span className="text-[10px] font-medium text-yellow-700 dark:text-yellow-600 leading-none">{label}</span>
|
||||
<HelpCircle className="h-3 w-3 text-yellow-600/70 dark:text-yellow-500/70 group-hover:text-yellow-600 dark:group-hover:text-yellow-500 transition-colors" />
|
||||
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
|
||||
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showHint && (
|
||||
|
||||
@@ -42,7 +42,7 @@ export function StockIntradayChart({
|
||||
})
|
||||
|
||||
const minuteRows: MinuteKlineRow[] = useMemo(() => minute.data?.rows ?? [], [minute.data?.rows])
|
||||
// source=none 表示本地无数据且数据源也拉不到 (停牌/复牌延迟/非交易日)
|
||||
// source=none 表示本地无数据且 TickFlow 也拉不到 (停牌/复牌延迟/非交易日)
|
||||
// 此时不弹"是否获取"询问窗, 只做静态提示, 避免误导用户去拉明知拉不到的数据
|
||||
const sourceIsNone = minute.data?.source === 'none'
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
export type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
resolved: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
if (theme !== 'system') return theme
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function updateMetaThemeColor(resolved: ResolvedTheme) {
|
||||
if (typeof document === 'undefined') return
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (!meta) return
|
||||
// 暗色保持品牌紫, 浅色使用浅色背景避免状态栏突兀
|
||||
meta.setAttribute('content', resolved === 'dark' ? '#8B5CF6' : '#FAFAFA')
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const saved = storage.theme.get('system')
|
||||
return saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system'
|
||||
})
|
||||
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(theme))
|
||||
|
||||
useEffect(() => {
|
||||
const nextResolved = resolveTheme(theme)
|
||||
setResolved(nextResolved)
|
||||
|
||||
const root = document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(nextResolved)
|
||||
storage.theme.set(theme)
|
||||
updateMetaThemeColor(nextResolved)
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => {
|
||||
const nextResolved = resolveTheme('system')
|
||||
setResolved(nextResolved)
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(nextResolved)
|
||||
updateMetaThemeColor(nextResolved)
|
||||
}
|
||||
media.addEventListener('change', handler)
|
||||
return () => media.removeEventListener('change', handler)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (next: Theme) => {
|
||||
setThemeState(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useTheme must be used within ThemeProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 回测预热期徽标 — 点击弹出说明气泡。
|
||||
*
|
||||
* 解释「回测开头几个月没有交易」这一高频疑问: 技术指标需要历史数据预热,
|
||||
* 系统会自动在回测起点之前多取约 120 天 (≈4 个月) 数据; 若本地数据恰好从
|
||||
* 起点才开始, 开头几个月指标算不出、信号不触发, 属正常现象。
|
||||
*
|
||||
* 实现要点:
|
||||
* - 点击触发 (非 hover), 移动端友好
|
||||
* - 用 createPortal 渲染到 body, 绕开父容器 overflow 裁剪 (回测配置面板有 overflow-y-auto)
|
||||
* - 全屏透明遮罩点击关闭 + ESC 关闭
|
||||
* - 气泡位置 = 锚点 rect 实时计算, 自动判断向左/向右展开避免溢出屏幕
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
interface Pos { top: number; left: number }
|
||||
|
||||
export function WarmupBadge() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const [pos, setPos] = useState<Pos>({ top: 0, left: 0 })
|
||||
|
||||
// 打开时根据锚点 rect 计算气泡位置 (向下方弹出)
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !anchorRef.current) return
|
||||
const rect = anchorRef.current.getBoundingClientRect()
|
||||
const POPUP_W = 272
|
||||
const GAP = 8
|
||||
// 优先左对齐锚点; 右侧不够则右对齐; 兜底贴左边
|
||||
let left = rect.left
|
||||
if (left + POPUP_W > window.innerWidth - 8) {
|
||||
left = rect.right - POPUP_W
|
||||
}
|
||||
left = Math.max(8, left)
|
||||
setPos({ top: rect.bottom + GAP, left })
|
||||
}, [open])
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="inline-flex items-center gap-0.5 rounded-full px-1.5 text-[10px] text-amber-500/70 transition-colors hover:bg-amber-400/10 hover:text-amber-500"
|
||||
title="为什么开头可能没交易?"
|
||||
>
|
||||
<Info className="h-3 w-3" strokeWidth={1.5} />
|
||||
预热 ≥120 天
|
||||
</button>
|
||||
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* 全屏透明遮罩: 点击关闭 */}
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
{/* 气泡: 绝对定位到 body, 绕开 overflow 裁剪 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, width: 272 }}
|
||||
className="z-[70] rounded-btn border border-border bg-surface p-3 text-[11px] leading-relaxed text-secondary shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-1.5 font-medium text-foreground">为什么开头几个月可能没有交易?</div>
|
||||
<p className="text-muted">
|
||||
技术指标 (MA / MACD / RSI 等) 需要历史数据才能算出。系统会自动在回测起点之前多取约
|
||||
<span className="font-medium text-amber-300"> 120 天 (≈4 个月)</span> 数据做预热。
|
||||
</p>
|
||||
<p className="mt-1.5 text-muted">
|
||||
若本地数据恰好从回测起点才开始, 开头几个月指标算不出、信号不触发,
|
||||
<span className="text-secondary"> 属正常现象, 不是 bug</span>。等数据攒够后自然开始产生交易。
|
||||
</p>
|
||||
<div className="mt-2 border-t border-border/60 pt-2 text-muted">
|
||||
<span className="text-secondary">解决:</span> 把历史数据补到回测起点之前至少半年, 或把起点往后挪。
|
||||
</div>
|
||||
{/* 小箭头指向锚点 */}
|
||||
<div
|
||||
className="absolute -top-1 h-2 w-2 rotate-45 border-l border-t border-border bg-surface"
|
||||
style={{ left: 12 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
Database,
|
||||
DownloadCloud,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Tags,
|
||||
@@ -449,3 +452,50 @@ export function ConfigButton({ onClick }: { onClick: () => void }) {
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置预设 (概念/行业) 数据获取空状态。
|
||||
*
|
||||
* 当检测到内置预设存在但无数据时, 展示图标 + 提示 + 「获取数据」按钮,
|
||||
* 让用户手动触发拉取 (POST /api/ext-data/presets/{id}/fetch), 而非自动拉取。
|
||||
*/
|
||||
export function PresetFetchState({
|
||||
title,
|
||||
hint,
|
||||
isLoading,
|
||||
error,
|
||||
onFetch,
|
||||
}: {
|
||||
title: string
|
||||
hint: string
|
||||
isLoading: boolean
|
||||
error: unknown
|
||||
onFetch: () => void
|
||||
}) {
|
||||
const errMsg = error instanceof Error ? error.message : error ? String(error) : ''
|
||||
return (
|
||||
<div className="h-full grid place-items-center px-8 py-16">
|
||||
<div className="text-center max-w-md">
|
||||
<DownloadCloud className="mx-auto h-10 w-10 text-muted" strokeWidth={1.5} />
|
||||
<h2 className="mt-4 text-base font-medium text-foreground">{title}</h2>
|
||||
<p className="mt-2 text-sm text-secondary leading-relaxed">{hint}</p>
|
||||
<button
|
||||
onClick={onFetch}
|
||||
disabled={isLoading}
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:brightness-110 disabled:opacity-60"
|
||||
>
|
||||
{isLoading ? (
|
||||
<><RefreshCw className="h-4 w-4 animate-spin" /> 获取中...</>
|
||||
) : (
|
||||
<><DownloadCloud className="h-4 w-4" /> 获取数据</>
|
||||
)}
|
||||
</button>
|
||||
{errMsg && (
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-bear">
|
||||
<AlertCircle className="h-3.5 w-3.5" /> {errMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { PipelineJob } from '@/lib/api'
|
||||
export const STAGE_LABELS: Record<string, string> = {
|
||||
init: '初始化',
|
||||
resolve_universe: '解析标的池',
|
||||
sync_instruments: '同步标的维表',
|
||||
sync_instruments: '同步个股维表',
|
||||
sync_daily: '同步日 K',
|
||||
sync_adj: '同步除权因子',
|
||||
compute_enriched: '计算技术指标',
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Check, GripVertical } from 'lucide-react'
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
export type CardKey =
|
||||
| 'instruments' | 'daily' | 'adj_factor' | 'enriched'
|
||||
| 'index' | 'etf' | 'minute' | 'financials'
|
||||
|
||||
interface CardDef {
|
||||
key: CardKey
|
||||
label: string
|
||||
desc: string
|
||||
/** 档位能力不足时该卡片是否默认隐藏(减少干扰) */
|
||||
defaultHiddenIfNoCap: boolean
|
||||
/** 无条件默认隐藏(用户可在设置里手动开启) */
|
||||
defaultHidden?: boolean
|
||||
}
|
||||
|
||||
/** 数据画像卡片定义 —— 默认顺序即此数组顺序 */
|
||||
export const DATA_CARD_DEFS: CardDef[] = [
|
||||
{ key: 'instruments', label: '个股维表', desc: 'A 股股票元数据', defaultHiddenIfNoCap: false },
|
||||
{ key: 'daily', label: '日 K', desc: 'A 股日K线数据', defaultHiddenIfNoCap: false },
|
||||
{ key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true },
|
||||
{ key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false },
|
||||
{ key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false },
|
||||
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true },
|
||||
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true },
|
||||
{ key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true },
|
||||
]
|
||||
|
||||
const DEFAULT_ORDER = DATA_CARD_DEFS.map(d => d.key)
|
||||
/** 恢复默认时显示的卡片数量(按默认顺序取前 N 张) */
|
||||
const DEFAULT_VISIBLE_COUNT = 5
|
||||
|
||||
const CAP_KEY_MAP: Partial<Record<CardKey, string>> = {
|
||||
adj_factor: 'adj_factor',
|
||||
minute: 'kline.minute.batch',
|
||||
financials: 'financial',
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取卡片显隐状态。结合档位能力决定默认值:
|
||||
* - 用户显式设置过 → 用设置值
|
||||
* - 未设置 + defaultHidden → 隐藏(无条件默认隐藏)
|
||||
* - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏
|
||||
* - 其他 → 显示
|
||||
*/
|
||||
export function getCardVisibility(
|
||||
caps: Record<string, unknown> | undefined,
|
||||
): Record<string, boolean> {
|
||||
const has = (capKey: string) => !capKey || !!caps?.[capKey]
|
||||
const override = storage.dataCardVisible.get({})
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const def of DATA_CARD_DEFS) {
|
||||
if (def.key in override) {
|
||||
result[def.key] = override[def.key]
|
||||
} else if (def.defaultHidden) {
|
||||
result[def.key] = false
|
||||
} else {
|
||||
result[def.key] = def.defaultHiddenIfNoCap ? has(CAP_KEY_MAP[def.key] ?? '') : true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取卡片显示顺序。
|
||||
* - 用户拖拽设置过 → 用设置值(过滤掉已不存在的 key, 补齐新增的 key)
|
||||
* - 未设置 → 用 DATA_CARD_DEFS 默认顺序
|
||||
*/
|
||||
export function getCardOrder(): CardKey[] {
|
||||
const saved = storage.dataCardOrder.get([])
|
||||
if (!saved.length) return [...DEFAULT_ORDER]
|
||||
const known = new Set<CardKey>(DEFAULT_ORDER)
|
||||
const ordered = saved.filter(k => known.has(k as CardKey)) as CardKey[]
|
||||
// 补齐新增的 key(默认顺序里新增的卡片追加到末尾)
|
||||
for (const k of DEFAULT_ORDER) {
|
||||
if (!ordered.includes(k)) ordered.push(k)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
export function PageSettingsModal({
|
||||
caps,
|
||||
}: {
|
||||
caps: Record<string, unknown> | undefined
|
||||
}) {
|
||||
const [visible, setVisible] = useState<Record<string, boolean>>(() => getCardVisibility(caps))
|
||||
const [order, setOrder] = useState<CardKey[]>(() => getCardOrder())
|
||||
|
||||
const persistVisible = (next: Record<string, boolean>) => {
|
||||
setVisible(next)
|
||||
storage.dataCardVisible.set(next)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
const persistOrder = (next: CardKey[]) => {
|
||||
setOrder(next)
|
||||
storage.dataCardOrder.set(next)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
|
||||
const toggle = (key: CardKey) => persistVisible({ ...visible, [key]: !(visible[key] ?? true) })
|
||||
|
||||
const reset = () => {
|
||||
// 恢复默认: 默认顺序 + 仅勾选前 5 张卡片, 其余隐藏
|
||||
const defaultOrder = [...DEFAULT_ORDER]
|
||||
const defaultVisible: Record<string, boolean> = {}
|
||||
defaultOrder.forEach((k, i) => { defaultVisible[k] = i < DEFAULT_VISIBLE_COUNT })
|
||||
storage.dataCardVisible.set(defaultVisible)
|
||||
storage.dataCardOrder.set(defaultOrder)
|
||||
setVisible(defaultVisible)
|
||||
setOrder(defaultOrder)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIdx = order.indexOf(active.id as CardKey)
|
||||
const newIdx = order.indexOf(over.id as CardKey)
|
||||
if (oldIdx < 0 || newIdx < 0) return
|
||||
persistOrder(arrayMove(order, oldIdx, newIdx))
|
||||
}
|
||||
|
||||
// 按 order 排序卡片定义
|
||||
const defByKey = new Map(DATA_CARD_DEFS.map(d => [d.key, d]))
|
||||
const orderedDefs = order.map(k => defByKey.get(k)!).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
拖动手柄调整卡片顺序,勾选控制显隐。未勾选的卡片将隐藏,不影响数据本身。
|
||||
</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={order} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1.5">
|
||||
{orderedDefs.map((def) => {
|
||||
const on = visible[def.key] ?? true
|
||||
return (
|
||||
<SortableCardRow
|
||||
key={def.key}
|
||||
id={def.key}
|
||||
label={def.label}
|
||||
desc={def.desc}
|
||||
on={on}
|
||||
onToggle={() => toggle(def.key)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-2 py-0.5 rounded-btn text-[10px] text-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 可拖拽的卡片行 ──
|
||||
function SortableCardRow({
|
||||
id, label, desc, on, onToggle,
|
||||
}: {
|
||||
id: CardKey
|
||||
label: string
|
||||
desc: string
|
||||
on: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center gap-2 rounded-card border px-3 py-2 transition-colors ${
|
||||
isDragging ? 'bg-elevated shadow-lg' : ''
|
||||
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30'}`}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
|
||||
title="拖动排序"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{/* 显隐勾选 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
on ? 'bg-accent border-accent' : 'bg-base border-border'
|
||||
}`}
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
>
|
||||
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-foreground">{label}</div>
|
||||
<div className="text-[10px] text-muted leading-snug">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
type PullKey = 'pipeline_pull_a_share' | 'pipeline_pull_etf' | 'pipeline_pull_index'
|
||||
|
||||
interface ScopeItem {
|
||||
key: PullKey
|
||||
label: string
|
||||
desc: string
|
||||
defaultOn: boolean
|
||||
}
|
||||
|
||||
const ITEMS: ScopeItem[] = [
|
||||
{ key: 'pipeline_pull_a_share', label: 'A股', desc: '沪深京 A 股日K(约 5500 只)', defaultOn: true },
|
||||
{ key: 'pipeline_pull_index', label: '指数', desc: '主要市场指数(默认全量约 600 只)', defaultOn: true },
|
||||
{ key: 'pipeline_pull_etf', label: 'ETF', desc: '场内交易基金(约 1500 只,首次较慢)', defaultOn: false },
|
||||
]
|
||||
|
||||
export function PipelineScopeConfig() {
|
||||
const qc = useQueryClient()
|
||||
const prefs = useQuery({ queryKey: QK.preferences, queryFn: api.preferences })
|
||||
|
||||
const updateToggle = useMutation({
|
||||
mutationFn: (cfg: Partial<Record<PullKey, boolean>>) => api.updatePipelinePullTypes(cfg),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||
},
|
||||
})
|
||||
|
||||
const getValue = (key: PullKey, def: boolean) => prefs.data?.[key] ?? def
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
勾选盘后管道每次自动拉取的数据类型。仅影响后续同步,已存储的历史数据不受影响。
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{ITEMS.map((item) => {
|
||||
const locked = item.key === 'pipeline_pull_a_share'
|
||||
const on = locked || getValue(item.key, item.defaultOn)
|
||||
return (
|
||||
<div key={item.key}>
|
||||
<label
|
||||
className={`flex items-start gap-2.5 rounded-card border px-3 py-2.5 transition-colors ${
|
||||
locked ? 'cursor-default' : 'cursor-pointer'
|
||||
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!locked) updateToggle.mutate({ [item.key]: !on } as never)
|
||||
}}
|
||||
disabled={locked || updateToggle.isPending}
|
||||
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
on ? 'bg-accent border-accent' : 'bg-base border-border'
|
||||
} ${locked ? 'opacity-80' : ''}`}
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
>
|
||||
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground">{item.label}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted leading-snug mt-0.5">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{updateToggle.isPending && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />保存中…
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted leading-relaxed pt-1">
|
||||
数据通道基于免费接口,所有档位均可拉取。
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { api, type EnrichedField } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
const TABLE_TITLES: Record<string, string> = {
|
||||
instruments: '标的维表',
|
||||
instruments: '个股维表',
|
||||
daily: '日 K',
|
||||
adj_factor: '除权因子',
|
||||
enriched: 'Enriched',
|
||||
@@ -12,6 +12,9 @@ const TABLE_TITLES: Record<string, string> = {
|
||||
index_instruments: '指数维表',
|
||||
index_daily: '指数日 K',
|
||||
index_enriched: '指数 Enriched',
|
||||
etf_instruments: 'ETF 维表',
|
||||
etf_daily: 'ETF 日 K',
|
||||
etf_enriched: 'ETF Enriched',
|
||||
}
|
||||
|
||||
function categorize(name: string): string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user