diff --git a/serve/.dockerignore b/serve/.dockerignore new file mode 100644 index 0000000..53a9d46 --- /dev/null +++ b/serve/.dockerignore @@ -0,0 +1,28 @@ +# Dependencies +frontend/node_modules +backend/.venv +**/__pycache__ + +# Build outputs +frontend/dist +dist +build + +# Git +.git +.gitignore + +# IDE +.vscode +.idea + +# OS +.DS_Store + +# Logs +*.log + +# Local env files +.env +.env.* +!.env.example diff --git a/serve/.env.example b/serve/.env.example new file mode 100644 index 0000000..05f0773 --- /dev/null +++ b/serve/.env.example @@ -0,0 +1,30 @@ +# 注意:当前项目已改为硬编码配置(backend/app/config.py),Docker 启动不再需要 .env。 +# 此文件仅保留作为本地开发/调试时的备用参考。 + +# ===== 数据源 API Key ===== +# 留空或不填则启用 Free 试用模式,仅能拿历史日 K 单股数据 +TICKFLOW_API_KEY= + +# ===== AI(可选,留空跳过 AI 功能) ===== +AI_PROVIDER=openai_compat # openai_compat | ollama +AI_BASE_URL=https://api.deepseek.com/v1 +AI_API_KEY= +AI_MODEL=deepseek-chat +AI_DAILY_TOKEN_BUDGET=500000 + +# ===== Server ===== +HOST=0.0.0.0 +PORT=3018 +LOG_LEVEL=INFO + +# ===== Data ===== +DATA_DIR=./data + +# ===== Docker 基础镜像源(可选) ===== +# 国内网络拉取 Docker Hub 超时时,可取消注释并替换为可用镜像站。 +# 常见镜像站:阿里云 registry.cn-hangzhou.aliyuncs.com +# DaoCloud m.daocloud.io/docker.io +# 中科大 docker.mirrors.ustc.edu.cn +# 网易 hub-mirror.c.163.com +# PYTHON_IMAGE=registry.cn-hangzhou.aliyuncs.com/library/python:3.11-slim +# NODE_IMAGE=registry.cn-hangzhou.aliyuncs.com/library/node:20-alpine diff --git a/serve/Dockerfile b/serve/Dockerfile new file mode 100644 index 0000000..36ec384 --- /dev/null +++ b/serve/Dockerfile @@ -0,0 +1,76 @@ +# 两阶段构建:前端 dist 拷进后端镜像,单容器运行 +# 可选:构建网络无法直连官方源时,传入 --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 +# 备用 PyPI 源:主源同步延迟/故障时自动兜底(阿里云与清华互为补充) +ARG PYPI_FALLBACK=https://mirrors.aliyun.com/pypi/simple +ARG BACKEND_EXTRAS= + +# === Stage 1: 前端构建 === +FROM node:20-alpine AS frontend-builder +ARG USE_CN_MIRROR=1 +ARG NPM_REGISTRY=https://registry.npmmirror.com +WORKDIR /build +# 关键:corepack 不读 npm 的 registry 配置,且跨 RUN 不保留环境变量, +# 因此国内网络下最稳的做法是直接用 npm 安装 pnpm(npm 会读取 .npmrc 镜像源), +# 彻底绕开 corepack 再次联网下载 pnpm 的问题。 +RUN if [ "$USE_CN_MIRROR" = "1" ]; then npm config set registry "$NPM_REGISTRY"; fi && \ + npm install -g pnpm@9 +# 让 pnpm 走镜像源安装依赖 +RUN if [ "$USE_CN_MIRROR" = "1" ]; then pnpm config set registry "$NPM_REGISTRY"; fi +COPY frontend/package.json frontend/pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install +COPY frontend/ ./ +RUN pnpm build + +# === Stage 2: Python 运行时 === +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 发版极频繁,国内镜像同步存在时间窗口,不锁版本且无 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_FALLBACK" || \ + pip install --no-cache-dir uv; \ + else \ + pip install --no-cache-dir uv; \ + fi + +# Backend deps +COPY README.md /README.md +COPY backend/pyproject.toml backend/uv.lock* ./ +# 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 是按开发布局 +# (/backend/app/) 推导的, 容器内会错算到 /。这里用环境变量显式指定 +# 三个关键路径, 确保 static / tiers / data 都指向容器内正确位置。 +COPY backend/app ./app +COPY tiers.yaml /app/tiers.yaml +ENV STATIC_DIR=/app/static \ + TIERS_YAML=/app/tiers.yaml \ + DATA_DIR=/app/data + +# Frontend 静态产物 +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"] diff --git a/serve/LICENSE b/serve/LICENSE new file mode 100644 index 0000000..6d33010 --- /dev/null +++ b/serve/LICENSE @@ -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. diff --git a/serve/README.md b/serve/README.md new file mode 100644 index 0000000..35ec5a0 --- /dev/null +++ b/serve/README.md @@ -0,0 +1,328 @@ +
+ +# 📈 A股智能量化工作台 + +**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台** + +**面向个人散户与量化爱好者而生** + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/) +[![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/) +[![Data: TickFlow](https://img.shields.io/badge/Data-TickFlow-00b386.svg)](https://tickflow.org/auth/register?ref=V3KDKGXPEA) +[![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile) +[![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](https://github.com/shy3130/tickflow-stock-panel/stargazers) + +
+ +
+ +**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[路线图](#-路线图)** + +
+ +- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费** +- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里 +- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股 +- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭) +- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析 +- 🇨🇳 **A 股专用** — 盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业 + + + + 基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。 + +> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里) + + +> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242 + + +觉得有用可以点个 Star,蟹蟹 🌹 + +--- + +## 🎯 项目定位 + +**面向个人散户与量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具。 + +--- + +## 📸 界面预览 + + + + + + + + + + + + + + + + + + + + + + + + + + +
看板 Dashboard策略 Screener
看板页面策略页
回测 Backtest监控中心 Monitor
回测页监控中心
连板梯队 Limit Ladder概念分析 Concept
连板梯队页概念分析
+ +
+ +### 📸 [查看更多界面截图 »](./screenshots/README.md) + +
+ +--- + +## 🚀 快速开始 + +### 前置依赖 + +| 工具 | 版本 | 安装 | +| :--------------------------------- | :----- | :------------------------------------------------- | +| 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 一并关闭。默认: + +- 后端 → · 前端 → +- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh` + +### 方式 B:Docker(部署最省心) + +```bash +cp .env.example .env +docker compose up --build +# 打开 http://localhost:3018 +``` + +
+环境适配与高级选项(老 CPU · 手动启动 · 回测依赖) + +**老 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` 现场编译。 + +
+ +### 🔄 更新代码(已部署用户必读) + +拉取新版本只需一条命令: + +```bash +git pull +``` + +**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。 + +> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:** +> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件) +> - `git reset --hard` +> - 直接删除整个项目文件夹重新 `git clone` +> +> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。 + +### 🧭 跑起来后的第一次使用 + +1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签 +2. **设置** → **立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用) +3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号 +4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度) +5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录 + +--- + +## ✨ 核心功能 + +### 🔍 选股引擎(Screener) + +**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`): + +| 类型 | 代表策略 | +| :---------- | :------------------------------------------------------- | +| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 | +| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 | +| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 | + +**扩展策略的三种方式:** + +| 方式 | 说明 | +| :---------------- | :---------------------------------------------------------------------------------------------------- | +| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 | +| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` | +| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 | + +### 📊 指标流水线(Indicators) + +原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet: + +- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带 +- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅 +- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数 +- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破 +- **复权**:基于除权因子自动前复权,回测与指标口径一致 + +### 🧪 回测引擎(Backtest) + +基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。 + +### 📡 监控中心(Monitor) + +统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动): + +- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical) +- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启 +- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标 +- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发 +- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填 + +### 📈 个股分析(Beta) + +以「行情 + 关键价位」为主体的单标的决策页: + +- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月 +- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口 +- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角 + +### 🧰 数据与扩展 + +- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情 +- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析** +- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控 +- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取 + +--- + +## ⚙️ 配置 + +所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。 + +### 数据源:TickFlow + +```ini +TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁 +``` + +留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**: + +| 档位 | 实时能力 | +| :------- | :--------------------------------------- | +| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) | +| Starter+ | 全市场实时行情 | +| Pro | 分钟 K + 盘口 | +| Expert | WebSocket + 财务数据 | + +> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。 + +### AI(可选) + +用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口: + +```ini +AI_PROVIDER=openai_compat # openai_compat | ollama +AI_BASE_URL=https://api.deepseek.com/v1 +AI_API_KEY= # 留空 = 关闭 AI +AI_MODEL=deepseek-chat +AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限 +``` + +### 服务与数据 + +```ini +HOST=0.0.0.0 # 监听地址 +PORT=3018 # 服务端口 +LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR +DATA_DIR=./data # Parquet / DuckDB 数据存储目录 +``` + +### 访问密码 + +面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码: + +1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取): + ```ini + AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖 + ``` +2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码 + +> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)。 + +--- + +## 🏗️ 技术栈 + +| 层 | 选型 | +| :----------- | :------------------------------------------------------------------------------------------------ | +| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette | +| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) | +| **回测** | vectorbt(全项目唯一 pandas 边界) | +| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 | +| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) | +| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit | +| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** | + +--- + +## 🗺️ 路线图 + +| Phase | 内容 | 状态 | +| :----- | :----------------------------------------------------------------- | :--- | +| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ | +| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ | +| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ | +| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ | +| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 | + +--- + +## 📚 文档与贡献 + +- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范) +- [docs/](./docs) —— 策略构建步骤、示例 + +欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。 + +--- + +## ⚠️ 免责声明 + +本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。 + +## 📄 License + +[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。 + +## 社区 + +本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。 diff --git a/serve/VERSION b/serve/VERSION new file mode 100644 index 0000000..9a48d65 --- /dev/null +++ b/serve/VERSION @@ -0,0 +1 @@ +v0.1.64 diff --git a/serve/backend/app/__init__.py b/serve/backend/app/__init__.py new file mode 100644 index 0000000..9714d24 --- /dev/null +++ b/serve/backend/app/__init__.py @@ -0,0 +1,15 @@ +"""TickFlow Stock Panel backend.""" + +import sys + +__version__ = "0.1.70" + +# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 +# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 +# 进程加载最早阶段强制 UTF-8,根治此类编码崩溃。 +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: # noqa: BLE001 + pass diff --git a/serve/backend/app/api/__init__.py b/serve/backend/app/api/__init__.py new file mode 100644 index 0000000..8a5a42a --- /dev/null +++ b/serve/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI 路由汇总。""" diff --git a/serve/backend/app/api/alerts.py b/serve/backend/app/api/alerts.py new file mode 100644 index 0000000..fe0e4d4 --- /dev/null +++ b/serve/backend/app/api/alerts.py @@ -0,0 +1,144 @@ +"""告警触发记录 API — 查询/清空/生成演示数据 alerts.jsonl。""" +from __future__ import annotations + +import random +import time +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request + +from app.services import alert_store + +router = APIRouter(prefix="/api/alerts", tags=["alerts"]) + + +def _data_dir(request: Request) -> Path: + return request.app.state.repo.store.data_dir + + +@router.get("") +def list_alerts( + request: Request, + days: int = 7, + limit: int = 5000, + source: str | None = None, + type: str | None = None, +): + """查询触发记录 (时间倒序)。""" + events = alert_store.list_recent( + _data_dir(request), days=days, limit=limit, source=source, type=type, + ) + total = alert_store.count(_data_dir(request)) + return {"alerts": events, "total": total} + + +@router.delete("") +def clear_alerts(request: Request): + """清空全部触发记录。""" + n = alert_store.clear(_data_dir(request)) + return {"ok": True, "cleared": n} + + +@router.delete("/{ts}") +def delete_alert(ts: int, request: Request): + """删除单条触发记录 (按 ts 毫秒时间戳)。""" + deleted = alert_store.delete_one(_data_dir(request), ts) + if not deleted: + raise HTTPException(status_code=404, detail="记录不存在") + return {"ok": True} + + +# ── 演示数据生成 (仅 Dev 页用) ───────────────────────── + +_DEMO_STOCKS = [ + ("600519.SH", "贵州茅台"), ("000001.SZ", "平安银行"), ("300750.SZ", "宁德时代"), + ("002594.SZ", "比亚迪"), ("000858.SZ", "五粮液"), ("601318.SH", "中国平安"), + ("002475.SZ", "立讯精密"), ("600036.SH", "招商银行"), ("000725.SZ", "京东方A"), + ("300059.SZ", "东方财富"), +] +_DEMO_TEMPLATES = [ + ("signal", "MA金叉触发", ["signal_ma_golden_5_20"], "info"), + ("signal", "放量突破新高", ["signal_volume_surge", "signal_n_day_high"], "warn"), + ("signal", "MACD金叉", ["signal_macd_golden"], "info"), + ("signal", "跌破MA20", ["signal_ma20_breakdown"], "info"), + ("price", "涨幅超 5%", [], "warn"), + ("price", "RSI 极度超卖", [], "warn"), + ("price", "跌幅超 3%", [], "info"), + ("market", "涨停封板", ["signal_limit_up"], "critical"), + ("market", "连板异动", ["signal_limit_up"], "warn"), + ("market", "炸板", ["signal_broken_limit_up"], "warn"), + # 新策略变更格式 + ("strategy", "策略「趋势突破」进入 贵州茅台 +2.3%", ["signal_n_day_high", "signal_volume_surge"], "info"), + ("strategy", "策略「趋势突破」移出 五粮液 -1.5%", ["signal_ma20_breakdown"], "info"), + ("strategy", "策略「新低反转」进入 平安银行 +1.1%", ["signal_n_day_low"], "warn"), + ("strategy", "策略「MACD金叉」移出 比亚迪 -0.8%", ["signal_macd_golden"], "info"), + # 批量变更 + ("strategy", "策略「趋势突破」进入 6 只:平安银行、宁德时代、比亚迪、东方财富、招商银行、立讯精密", [], "info"), + ("strategy", "策略「MACD金叉」移出 7 只:京东方A、平安银行、五粮液、立讯精密、招商银行、东方财富、比亚迪", [], "warn"), +] + + +@router.post("/seed") +def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True): + """生成演示触发记录 (Dev 页用)。 + + Args: + count: 生成条数 (1-50) + recent: True=时间戳设为"刚刚"(用于测试闪烁效果); False=分散在近3天 + """ + count = max(1, min(50, count)) + now_ms = int(time.time() * 1000) + events = [] + for i in range(count): + source, message, signals, severity = _DEMO_TEMPLATES[i % len(_DEMO_TEMPLATES)] + sym, name = _DEMO_STOCKS[i % len(_DEMO_STOCKS)] + # 策略类型按消息推导 type: new_entry / dropped, 否则沿用 source + if source == "strategy": + if "进入" in message: + ev_type = "new_entry" + elif "移出" in message: + ev_type = "dropped" + else: + ev_type = "strategy" + else: + ev_type = source + # recent 模式: 时间戳从现在往前每条错开 30 秒 (最新在前) + ts = now_ms - (i * 30000) if recent else now_ms - random.randint(60, 4320) * 60 * 1000 + events.append({ + "ts": ts, + "rule_id": f"demo_rule_{i}", + "rule_name": message, + "source": source, + "type": ev_type, + "symbol": "" if source == "strategy" and ("只:" in message) else sym, + "name": name, + "message": message, + "price": round(random.uniform(8, 1800), 2) if not (source == "strategy" and "只:" in message) else None, + "change_pct": round(random.uniform(-0.06, 0.098), 4) if not (source == "strategy" and "只:" in message) else None, + "signals": signals, + "severity": severity, + }) + alert_store.append_many(_data_dir(request), events) + + # 同步推入 SSE 队列, 让所有连着 SSE 的客户端实时收到 (不依赖轮询) + qs = getattr(request.app.state, "quote_service", None) + if qs: + # 转成 SSE 推送格式 (和 _evaluate_monitors 一致) + sse_alerts = [{ + "source": ev["source"], + "type": ev["type"], + "rule_id": ev.get("rule_id"), + "symbol": ev["symbol"], + "name": ev["name"], + "message": ev["message"], + "price": ev["price"], + "change_pct": ev["change_pct"], + "signals": ev["signals"], + "severity": ev.get("severity", "info"), + } for ev in events] + with qs._lock: + qs._pending_alerts.extend(sse_alerts) + qs._alert_event.set() + + return {"ok": True, "generated": len(events)} + diff --git a/serve/backend/app/api/analysis.py b/serve/backend/app/api/analysis.py new file mode 100644 index 0000000..2045b45 --- /dev/null +++ b/serve/backend/app/api/analysis.py @@ -0,0 +1,171 @@ +"""自定义分析菜单 API。""" +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Literal + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/api/analysis-menus", tags=["analysis-menus"]) + + +class AnalysisColumn(BaseModel): + field: str + label: str = "" + type: Literal["string", "number", "percent", "amount", "date"] = "string" + width: int | None = None + sortable: bool = False + precision: int | None = None + format: str | None = None + aggregate: Literal["count", "avg", "sum", "min", "max"] | None = None + visible: bool = True + + +class DefaultSort(BaseModel): + field: str + order: Literal["asc", "desc"] = "desc" + + +class AnalysisMenu(BaseModel): + id: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_]+$") + label: str = Field(..., min_length=1, max_length=64) + icon: str = "chart" + data_source: str = Field(..., min_length=1) + template: Literal["dimension_rank", "ranking", "table"] = "dimension_rank" + dimension_field: str | None = None + rank_field: str | None = None + group_columns: list[AnalysisColumn] = Field(default_factory=list) + detail_columns: list[AnalysisColumn] = Field(default_factory=list) + default_sort: DefaultSort | None = None + visible: bool = True + order: int = 0 + created_at: str | None = None + updated_at: str | None = None + builtin: bool = False + + +class UpsertAnalysisMenu(BaseModel): + label: str = Field(..., min_length=1, max_length=64) + icon: str = "chart" + data_source: str = Field(..., min_length=1) + template: Literal["dimension_rank", "ranking", "table"] = "dimension_rank" + dimension_field: str | None = None + rank_field: str | None = None + group_columns: list[AnalysisColumn] = Field(default_factory=list) + detail_columns: list[AnalysisColumn] = Field(default_factory=list) + default_sort: DefaultSort | None = None + visible: bool = True + order: int = 0 + + +class ReorderMenusReq(BaseModel): + ids: list[str] = Field(..., min_length=1) + + +def _data_dir(request: Request) -> Path: + return request.app.state.repo.store.data_dir + + +def _base_dir(request: Request) -> Path: + return _data_dir(request) / "analysis_menus" + + +def _path(request: Request, menu_id: str) -> Path: + return _base_dir(request) / f"{menu_id}.json" + + +def _load_saved(request: Request) -> list[AnalysisMenu]: + base = _base_dir(request) + if not base.exists(): + return [] + items: list[AnalysisMenu] = [] + for p in sorted(base.glob("*.json")): + try: + raw = json.loads(p.read_text(encoding="utf-8")) + items.append(AnalysisMenu(**raw)) + except Exception: + continue + return items + + +def _ordered(items: list[AnalysisMenu]) -> list[AnalysisMenu]: + return sorted(items, key=lambda m: (m.order, m.label, m.id)) + + +def _save(request: Request, menu: AnalysisMenu) -> AnalysisMenu: + now = datetime.now().isoformat() + if not menu.created_at: + menu.created_at = now + menu.updated_at = now + menu.builtin = False + base = _base_dir(request) + base.mkdir(parents=True, exist_ok=True) + _path(request, menu.id).write_text( + json.dumps(menu.model_dump(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return menu + + +def _default_menus(request: Request) -> list[AnalysisMenu]: + """自动生成的默认分析菜单。 + + 历史上会扫描扩展数据配置,对含「概念」字段的表自动生成一个「概念分析」菜单。 + 现已关闭自动生成 —— 内置的概念分析页(/concept-analysis)已覆盖该场景, + 自动菜单会造成导航重复。需要时用户可在「设置 → 扩展页面」手动创建。 + """ + return [] + + +@router.get("") +def list_menus(request: Request): + saved = _load_saved(request) + saved_ids = {m.id for m in saved} + defaults = [m for m in _default_menus(request) if m.id not in saved_ids] + return {"items": _ordered(saved + defaults)} + + +@router.get("/{menu_id}") +def get_menu(request: Request, menu_id: str): + for menu in _ordered(_load_saved(request) + _default_menus(request)): + if menu.id == menu_id: + return menu + raise HTTPException(404, f"分析菜单 '{menu_id}' 不存在") + + +@router.post("/reorder") +def reorder_menus(request: Request, body: ReorderMenusReq): + saved = {m.id: m for m in _load_saved(request)} + defaults = {m.id: m for m in _default_menus(request)} + for idx, menu_id in enumerate(body.ids): + menu = saved.get(menu_id) or defaults.get(menu_id) + if not menu: + continue + menu.order = idx + _save(request, menu) + return {"items": _ordered(_load_saved(request))} + + +@router.post("/{menu_id}") +def upsert_menu(request: Request, menu_id: str, body: UpsertAnalysisMenu): + if not menu_id.replace("_", "").isalnum(): + raise HTTPException(400, "菜单标识只能包含字母、数字和下划线") + existing = next((m for m in _load_saved(request) if m.id == menu_id), None) + menu = AnalysisMenu( + id=menu_id, + created_at=existing.created_at if existing else None, + **body.model_dump(), + ) + return _save(request, menu) + + +@router.delete("/{menu_id}") +def delete_menu(request: Request, menu_id: str): + p = _path(request, menu_id) + if not p.exists(): + raise HTTPException(404, f"分析菜单 '{menu_id}' 不存在或为默认菜单") + p.unlink() + return {"status": "deleted"} diff --git a/serve/backend/app/api/auth.py b/serve/backend/app/api/auth.py new file mode 100644 index 0000000..05e7771 --- /dev/null +++ b/serve/backend/app/api/auth.py @@ -0,0 +1,213 @@ +"""访问认证 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 + +import logging +import time +from collections import defaultdict +from threading import Lock + +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"]) + +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 + + +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) -> dict: + """认证状态: 是否已设密码 + 当前请求是否已登录。""" + token = request.cookies.get(COOKIE_NAME) + return { + "configured": auth.is_configured(), + "authenticated": bool(token and auth.is_valid_session(token)), + } + + +@router.post("/setup") +def setup_password(req: PasswordIn, request: Request) -> dict: + """首次设置访问密码。仅限本机/内网请求(防公网抢占)。 + + 若已设置过密码, 返回 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/本地浏览器操作", + ) + + 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} + + +@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} + + +@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} + + +@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": "密码已修改, 请重新登录"} diff --git a/serve/backend/app/api/backtest.py b/serve/backend/app/api/backtest.py new file mode 100644 index 0000000..0fe0736 --- /dev/null +++ b/serve/backend/app/api/backtest.py @@ -0,0 +1,474 @@ +"""回测 API — 信号回测 + 因子回测 + 策略回测。""" +from __future__ import annotations + +import asyncio +import json +import queue +import threading +from dataclasses import asdict +from datetime import date, timedelta +from typing import Literal + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from app.config import settings +from app.services.backtest import ( + BacktestConfig, + BacktestService, + VectorbtUnavailable, + is_available, +) + +router = APIRouter(prefix="/api/backtest", tags=["backtest"]) + +FACTOR_DEFAULT_DAYS = 180 +STRATEGY_DEFAULT_DAYS = 365 * 3 +BACKTEST_MAX_SERVER_DAYS = 186 +FACTOR_MAX_SYMBOLS = 1000 +BACKTEST_SERVER_GUARD_MESSAGE = ( + "当前服务器内存约 1.8GB,回测区间最多支持 6 个月;" + "更长周期容易触发 OOM,建议在 8GB 以上内存环境或本机运行。" +) + + +def _get_engine(request: Request): + """获取或创建 BacktestEngine (单例,PanelCache 跨请求生效)。""" + from app.backtest.engine import BacktestEngine + engine = getattr(request.app.state, "backtest_engine", None) + if engine is None: + engine = BacktestEngine(request.app.state.repo) + request.app.state.backtest_engine = engine + return engine + + +def _resolve_start(req: BaseModel, end: date, default_days: int) -> date: + """未传 start 使用默认区间;显式传 null/空值表示全部历史。""" + start = getattr(req, "start") + if start is not None: + return start + if "start" in req.model_fields_set: + return date(1900, 1, 1) + return end - timedelta(days=default_days) + + +def _guard_server_backtest_range(start: date, end: date): + if not settings.backtest_range_guard: + return + days = (end - start).days + 1 + if days > BACKTEST_MAX_SERVER_DAYS: + raise HTTPException(status_code=400, detail=BACKTEST_SERVER_GUARD_MESSAGE) + + +# ================================================================ +# 状态 +# ================================================================ + +@router.get("/status") +def status(): + """前端可用此接口判断回测页是否要灰显。""" + return {"available": True} + + +# ================================================================ +# 信号回测 (现有接口,保持不变) +# ================================================================ + +class BacktestRequest(BaseModel): + symbols: list[str] = Field(..., min_length=1) + start: date | None = None + end: date | None = None + entries: list[str] = [] + exits: list[str] = [] + stop_loss_pct: float | None = None + max_hold_days: int | None = None + fees_pct: float = 0.0002 + slippage_bps: float = 5 + matching: Literal["close_t", "open_t+1"] = "close_t" + + +@router.post("/run") +def run(req: BacktestRequest, request: Request): + """信号回测 — 现有接口,向后兼容。""" + repo = request.app.state.repo + svc = BacktestService(repo) + end = req.end or date.today() + start = req.start or (end - timedelta(days=365 * 3)) + + cfg = BacktestConfig( + symbols=req.symbols, + start=start, + end=end, + entries=req.entries, + exits=req.exits, + stop_loss_pct=req.stop_loss_pct, + max_hold_days=req.max_hold_days, + fees_pct=req.fees_pct, + slippage_bps=req.slippage_bps, + matching=req.matching, + ) + try: + result = svc.run(cfg) + except VectorbtUnavailable as e: + raise HTTPException(status_code=503, detail=str(e)) from e + return asdict(result) + + +# ================================================================ +# 因子回测 +# ================================================================ + +class FactorColumnsResponse(BaseModel): + columns: list[dict] + + +@router.get("/factor/columns") +def factor_columns(): + """返回可用的因子列列表。""" + from app.backtest.factor import FACTOR_COLUMNS + return {"columns": FACTOR_COLUMNS} + + +class FactorBacktestRequest(BaseModel): + factor_name: str + symbols: list[str] | None = None + start: date | None = None + end: date | None = None + n_groups: int = 5 + rebalance: Literal["daily", "weekly", "monthly"] = "monthly" + weight: Literal["equal", "factor_weight"] = "equal" + fees_pct: float = 0.0002 + slippage_bps: float = 5.0 + + +@router.post("/factor/run") +def factor_run(req: FactorBacktestRequest, request: Request): + """因子回测 — IC/IR 分析 + 分层回测。""" + from app.backtest.factor import FactorBacktestService, FactorConfig + + engine = _get_engine(request) + svc = FactorBacktestService(engine) + + end = req.end or date.today() + start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS) + _guard_server_backtest_range(start, end) + symbols = req.symbols if req.symbols else None + if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS: + raise HTTPException( + status_code=400, + detail=f"指定标的最多支持 {FACTOR_MAX_SYMBOLS} 只,请缩小标的范围。", + ) + + cfg = FactorConfig( + factor_name=req.factor_name, + symbols=symbols, + start=start, + end=end, + n_groups=req.n_groups, + rebalance=req.rebalance, + weight=req.weight, + fees_pct=req.fees_pct, + slippage_bps=req.slippage_bps, + ) + result = svc.run(cfg) + return asdict(result) + + +# ================================================================ +# 策略回测 +# ================================================================ + +class StrategyBacktestRequest(BaseModel): + strategy_id: str + symbols: list[str] | None = None + start: date | None = None + end: date | None = None + params: dict | None = None + overrides: dict | None = None + # matching 向后兼容; 显式传 entry_fill/exit_fill 时以二者为准。 + matching: Literal["close_t", "open_t+1"] = "open_t+1" + entry_fill: Literal["close_t", "open_t+1"] | None = None + exit_fill: Literal["close_t", "open_t+1"] | None = None + fees_pct: float = 0.0002 + slippage_bps: float = 5.0 + max_positions: int = 10 + max_exposure_pct: float = 1.0 + initial_capital: float = 1_000_000.0 + position_sizing: Literal["equal", "score_weight"] = "equal" + mode: Literal["position", "full"] = "position" + holding_days: int = 5 + + +@router.post("/strategy/run") +def strategy_run(req: StrategyBacktestRequest, request: Request): + """策略回测 — 复用 StrategyDef 体系做全周期回测。""" + from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig + + engine = _get_engine(request) + strategy_engine = request.app.state.strategy_engine + svc = StrategyBacktestService(engine, strategy_engine) + + end = req.end or date.today() + start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS) + _guard_server_backtest_range(start, end) + + cfg = StrategyBacktestConfig( + strategy_id=req.strategy_id, + symbols=req.symbols if req.symbols else None, + start=start, + end=end, + params=req.params, + overrides=req.overrides, + matching=req.matching, + entry_fill=req.entry_fill, + exit_fill=req.exit_fill, + fees_pct=req.fees_pct, + slippage_bps=req.slippage_bps, + max_positions=req.max_positions, + max_exposure_pct=req.max_exposure_pct, + initial_capital=req.initial_capital, + position_sizing=req.position_sizing, + mode=req.mode, + holding_days=req.holding_days, + ) + result = svc.run(cfg) + return asdict(result) + + +# ── SSE 流式回测 (实时进度 + 可取消 + 支持重连) ─────────────────── + +import time +import hashlib + + +class _BacktestJob: + """单个回测任务的状态, 存模块级供重连使用。""" + __slots__ = ("key", "cancel_event", "progress", "result", "error", "done", "finish_ts") + + def __init__(self, key: str): + self.key = key + self.cancel_event = threading.Event() + self.progress: list[dict] = [] # 进度历史 (新连接可回放) + self.result = None # 完成后的结果 + self.error: str | None = None + self.done = False + self.finish_ts: float = 0.0 + + +# 模块级任务表: key -> _BacktestJob +_running_jobs: dict[str, _BacktestJob] = {} +_jobs_lock = threading.Lock() +_JOB_TTL = 300 # 完成后保留 5 分钟 + + +def _cleanup_stale_jobs(): + """清理过期任务 (完成超过 TTL 的)。""" + now = time.time() + stale = [k for k, j in _running_jobs.items() if j.done and now - j.finish_ts > _JOB_TTL] + for k in stale: + _running_jobs.pop(k, None) + + +def _make_job_key( + strategy_id: str, symbols: str | None, start: str | None, end: str | None, + matching: str, entry_fill: str | None, exit_fill: str | None, + fees_pct: float, slippage_bps: float, + max_positions: int, max_exposure_pct: float, initial_capital: float, position_sizing: str, + params: str | None, overrides: str | None, + mode: str = "position", holding_days: int = 5, +) -> str: + raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}" + return hashlib.md5(raw.encode()).hexdigest()[:12] + + +@router.get("/strategy/stream") +async def strategy_stream( + request: Request, + strategy_id: str, + symbols: str | None = None, + start: str | None = None, + end: str | None = None, + matching: str = "open_t+1", + entry_fill: str | None = None, + exit_fill: str | None = None, + fees_pct: float = 0.0002, + slippage_bps: float = 5.0, + max_positions: int = 10, + max_exposure_pct: float = 1.0, + initial_capital: float = 1_000_000.0, + position_sizing: str = "equal", + params: str | None = None, + overrides: str | None = None, + mode: str = "position", + holding_days: int = 5, +): + """SSE 流式策略回测: 实时推送进度, 完成后推送结果, 支持重连 (刷新/切页后恢复)。 + + - 相同参数的任务只启动一次, 多次连接订阅同一个任务 + - 断开连接不会取消任务 (除非显式调用 cancel) + - 结果保留 5 分钟供重连 + + 事件类型: + - progress: {day, total, date, equity} + - done: {result} (完整回测结果) + - error: {message} + """ + from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig + + engine = _get_engine(request) + strategy_engine = request.app.state.strategy_engine + svc = StrategyBacktestService(engine, strategy_engine) + + end_date = date.fromisoformat(end) if end else date.today() + if start: + start_date = date.fromisoformat(start) + else: + # 空 start = 全部历史: 用本地最早日K日期, 查不到再回退到默认窗口 + earliest = request.app.state.repo.earliest_daily_date() + start_date = earliest or (end_date - timedelta(days=FACTOR_DEFAULT_DAYS)) + + # 服务端范围保护 + guard_violated = False + if settings.backtest_range_guard: + days = (end_date - start_date).days + 1 + if days > BACKTEST_MAX_SERVER_DAYS: + guard_violated = True + + job_key = _make_job_key( + strategy_id, symbols, start, end, + matching, entry_fill, exit_fill, + fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing, + params, overrides, + mode, holding_days, + ) + + _cleanup_stale_jobs() + + # 获取或创建任务 + with _jobs_lock: + job = _running_jobs.get(job_key) + if job is None: + job = _BacktestJob(job_key) + _running_jobs[job_key] = job + is_new = True + else: + is_new = False + + async def event_generator(): + # 范围保护: 直接报错 + if guard_violated: + yield f"event: error\ndata: {json.dumps({'message': BACKTEST_SERVER_GUARD_MESSAGE}, ensure_ascii=False)}\n\n" + return + + # 如果是新任务, 启动回测线程 + if is_new and not job.done: + cfg = StrategyBacktestConfig( + strategy_id=strategy_id, + symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None, + start=start_date, + end=end_date, + params=json.loads(params) if params else None, + overrides=json.loads(overrides) if overrides else None, + matching=matching, + entry_fill=entry_fill, + exit_fill=exit_fill, + fees_pct=fees_pct, + slippage_bps=slippage_bps, + max_positions=int(max_positions), + max_exposure_pct=float(max_exposure_pct), + initial_capital=float(initial_capital), + position_sizing=position_sizing, + mode=mode, + holding_days=int(holding_days), + ) + + def _run_backtest(): + try: + result = svc.run(cfg, lambda d: job.progress.append(d), job.cancel_event) + job.result = result + job.done = True + job.finish_ts = time.time() + except Exception as e: + job.error = str(e) + job.done = True + job.finish_ts = time.time() + + # 启动后台线程 (不阻塞事件循环) + threading.Thread(target=_run_backtest, daemon=True).start() + + # 订阅进度: 用读指针读 job.progress 列表 (多连接互不干扰) + cursor = 0 + tick = 0 + + try: + while True: + # 已完成: 推送最终结果/错误并退出 + if job.done: + if job.error: + yield f"event: error\ndata: {json.dumps({'message': job.error}, ensure_ascii=False)}\n\n" + elif job.result is not None: + r = job.result + if hasattr(r, "error") and r.error == "cancelled": + yield f"event: error\ndata: {json.dumps({'message': '回测已取消'}, ensure_ascii=False)}\n\n" + elif hasattr(r, "error") and r.error: + yield f"event: error\ndata: {json.dumps({'message': r.error}, ensure_ascii=False)}\n\n" + else: + yield f"event: done\ndata: {json.dumps(asdict(r), ensure_ascii=False, default=str)}\n\n" + return + + # 断开检测: 每 4 轮检查一次 (降低 GIL 抢占频率) + tick += 1 + if tick % 4 == 0 and await request.is_disconnected(): + break + + # 推送新进度 (从 cursor 开始读) + prog_list = job.progress + while cursor < len(prog_list): + msg = prog_list[cursor] + cursor += 1 + yield f"event: progress\ndata: {json.dumps(msg, ensure_ascii=False, default=str)}\n\n" + + await asyncio.sleep(0.5) + + except asyncio.CancelledError: + raise + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + +@router.post("/strategy/cancel") +async def strategy_cancel(request: Request): + """取消正在运行的回测任务 (前端传 query string, 后端算 job_key)。""" + body = await request.json() + qs = body.get("qs", "") + # 解析 qs 得到参数 + from urllib.parse import parse_qs + p = parse_qs(qs) + def _get(key: str, default: str = "") -> str: + return p.get(key, [default])[0] + job_key = _make_job_key( + _get("strategy_id"), + _get("symbols") or None, + _get("start") or None, + _get("end") or None, + _get("matching", "open_t+1"), + _get("entry_fill") or None, + _get("exit_fill") or None, + float(_get("fees_pct", "0.0002")), + float(_get("slippage_bps", "5")), + int(_get("max_positions", "10")), + float(_get("max_exposure_pct", "1")), + float(_get("initial_capital", "1000000")), + _get("position_sizing", "equal"), + _get("params") or None, + _get("overrides") or None, + _get("mode", "position"), + int(_get("holding_days", "5")), + ) + job = _running_jobs.get(job_key) + if job and not job.done: + job.cancel_event.set() + return {"ok": True} + return {"ok": False, "message": "任务不存在或已完成"} + diff --git a/serve/backend/app/api/data.py b/serve/backend/app/api/data.py new file mode 100644 index 0000000..cbe72d3 --- /dev/null +++ b/serve/backend/app/api/data.py @@ -0,0 +1,863 @@ +"""数据画像 API —— 让前端知道"我们本地有什么数据"。""" +from __future__ import annotations + +import logging +import os +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from fastapi import APIRouter, Request + +from app.indicators.pipeline import ENRICHED_COLUMNS + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/data", tags=["data"]) + +# ===== 缓存:storage(文件扫描) + 每张表 aggregate 各自缓存 ===== +# 同步期间前端 2s 轮一次 status,每张表 aggregate 全表 count + min/max + distinct +# 太重,加 TTL + 事件失效。stage 写完只清对应那张表的缓存。 + +_TABLE_TTL = 30.0 # 兜底 TTL,即使没人调 invalidate 也会过期 +_TABLE_TTL_LARGE = 120.0 # 大表(分钟K等)单独 TTL,避免多分区聚合反复重算 +_STORAGE_TTL = 60.0 # storage 文件扫描独立 TTL,stage 写完不触发重算 + +# 聚合慢的大表(分区数多、行数多),使用更长的 TTL +_LARGE_TABLES = {"minute"} + +_storage_cache: dict[str, Any] | None = None +_storage_cache_ts: float = 0.0 +_storage_lock = threading.Lock() + +_table_cache: dict[str, dict | None] = { + "daily": None, + "enriched": 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, + "financials": None, +} +_table_cache_ts: dict[str, float] = {k: 0.0 for k in _table_cache} +_table_cache_lock = threading.Lock() + +_last_finished_cache: dict[str, str | None] | None = None +_last_finished_lock = threading.Lock() + + +def invalidate_data_cache(table: str | None = None) -> None: + """数据写入/清除后调用。 + + table=None 时清所有表 cache + storage(粗粒度,用于 pipeline 完成/clear); + 指定 table 时只清那张表,不影响 storage(细粒度,用于单 stage 写完)。 + """ + with _table_cache_lock: + if table is None: + global _storage_cache, _storage_cache_ts, _last_finished_cache + _storage_cache = None + _storage_cache_ts = 0.0 + _last_finished_cache = None + for k in _table_cache: + _table_cache[k] = None + _table_cache_ts[k] = 0.0 + elif table in _table_cache: + _table_cache[table] = None + _table_cache_ts[table] = 0.0 + + +def invalidate_storage_cache() -> None: + """向后兼容入口 — 清全部缓存。新代码请用 invalidate_data_cache(table)。""" + invalidate_data_cache(None) + + +def _get_table_stats(name: str, fetch: Callable[[], dict | None]) -> dict | None: + """走 TTL+事件 双重缓存。fetch 在锁外执行避免阻塞别的请求。""" + ttl = _TABLE_TTL_LARGE if name in _LARGE_TABLES else _TABLE_TTL + now = time.time() + with _table_cache_lock: + cached = _table_cache.get(name) + cached_ts = _table_cache_ts.get(name, 0.0) + if cached is not None and (now - cached_ts) < ttl: + return cached + + fresh = fetch() + + with _table_cache_lock: + _table_cache[name] = fresh + _table_cache_ts[name] = now + return fresh + + +def _safe_aggregate(repo, view: str) -> dict | None: + """聚合视图基础统计;视图不存在或为空时返 None。""" + try: + row = repo.execute_one( + f"""SELECT count(*) AS rows, + min(date) AS earliest, + max(date) AS latest, + count(DISTINCT symbol) AS symbols, + count(DISTINCT date) AS trading_days + FROM {view}""" + ) + except Exception as e: # noqa: BLE001 + logger.debug("aggregate %s failed: %s", view, e) + return None + if not row or not row[0]: + return None + 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), + } + + +def _safe_aggregate_daily(repo, view: str = "kline_daily") -> dict | None: + """日K轻量统计 — 零数据扫描。 + + 从分区目录名获取日期范围和交易日数,不读任何 parquet。 + 标的数从 instruments 小表获取(~5000行,毫秒级)。 + """ + daily_dir = repo.store.data_dir / "kline_daily" + if not daily_dir.exists(): + return None + dates: list[str] = [] + for d in daily_dir.iterdir(): + if d.is_dir() and d.name.startswith("date="): + dates.append(d.name[5:]) + if not dates: + return None + dates.sort() + + symbols = _count_instruments_symbols(repo) + + return { + "rows": 0, + "earliest_date": dates[0], + "latest_date": dates[-1], + "symbols_covered": symbols, + "trading_days": len(dates), + } + + +def _safe_aggregate_enriched(repo) -> dict | None: + """Enriched 轻量统计 — 零数据扫描。 + + 字段数从 DESCRIBE 读 schema(不碰数据),毫秒级。 + 日期范围从分区目录名获取(同 minute 策略),不读任何 parquet。 + 标的数从 instruments 小表取。 + """ + # 字段数:读 schema,不碰数据 + fields = 0 + try: + cols = repo.execute_all("DESCRIBE kline_enriched") + fields = len(cols) + except Exception: # noqa: BLE001 + pass + + # 日期范围:从分区目录名获取,不扫数据 + enriched_dir = repo.store.data_dir / "kline_daily_enriched" + if not enriched_dir.exists(): + return None + dates: list[str] = [] + for d in enriched_dir.iterdir(): + if d.is_dir() and d.name.startswith("date="): + dates.append(d.name[5:]) + if not dates: + return None + dates.sort() + + symbols = _count_instruments_symbols(repo) + + return { + "rows": 0, + "fields": fields, + "earliest_date": dates[0], + "latest_date": dates[-1], + "symbols_covered": symbols, + "trading_days": len(dates), + } + + +def _count_instruments_symbols(repo) -> int: + """从 instruments 小表取标的数(~5000行,毫秒级)。""" + try: + sym_row = repo.execute_one( + "SELECT count(DISTINCT symbol) FROM instruments" + ) + if sym_row and sym_row[0]: + return int(sym_row[0]) + except Exception: # noqa: BLE001 + pass + return 0 + + +def _safe_aggregate_instruments(repo) -> dict | None: + """instruments 视图统计(无 date 列,用 as_of)。""" + try: + row = repo.execute_one( + """SELECT count(*) AS rows, + count(DISTINCT symbol) AS symbols, + max(as_of) AS latest_as_of, + count_if(name IS NOT NULL AND name != '') AS named + FROM instruments""" + ) + except Exception as e: # noqa: BLE001 + logger.debug("aggregate instruments failed: %s", e) + return None + if not row or not row[0]: + return None + return { + "rows": int(row[0]), + "symbols_covered": int(row[1] or 0), + "latest_as_of": str(row[2]) if row[2] else None, + "named": int(row[3] or 0), + } + + +def _safe_aggregate_index_daily(repo) -> dict | None: + """指数日K统计。指数数据量较小,直接读取 parquet 元数据统计真实行数。""" + return _safe_aggregate(repo, "kline_index_daily") + + +def _safe_aggregate_index_enriched(repo) -> dict | None: + """指数 enriched 统计。指数数据量较小,直接读取 parquet 元数据统计真实行数。""" + fields = 0 + try: + cols = repo.execute_all("DESCRIBE kline_index_enriched") + fields = len(cols) + except Exception: # noqa: BLE001 + pass + stats = _safe_aggregate(repo, "kline_index_enriched") + if not stats: + return None + return {**stats, "fields": fields} + + +def _safe_aggregate_index_instruments(repo) -> dict | None: + """指数 instruments 视图统计。""" + try: + row = repo.execute_one( + """SELECT count(*) AS rows, + count(DISTINCT symbol) AS symbols, + count_if(name IS NOT NULL AND name != '') AS named + FROM instruments_index""" + ) + except Exception as e: # noqa: BLE001 + logger.debug("aggregate instruments_index failed: %s", e) + return None + if not row or not row[0]: + return None + return { + "rows": int(row[0]), + "symbols_covered": int(row[1] or 0), + "latest_as_of": None, + "named": int(row[2] or 0), + } + + +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: + # 取日 K 的日期范围作为过滤条件 + dr = repo.execute_one( + "SELECT min(date), max(date) FROM kline_daily" + ) + if not dr or not dr[0]: + return None + d_min, d_max = dr[0], dr[1] + row = repo.execute_one( + """SELECT count(*) AS rows, + count(DISTINCT symbol) AS symbols, + count(DISTINCT trade_date) AS trading_days + FROM adj_factor + WHERE trade_date BETWEEN ? AND ?""", + [str(d_min), str(d_max)], + ) + if not row or not row[0]: + return None + return { + "rows": int(row[0]), + "symbols_covered": int(row[1]) if isinstance(row[1], (int, float)) else 0, + "earliest_date": str(d_min), + "latest_date": str(d_max), + "trading_days": int(row[2] or 0), + } + except Exception as e: # noqa: BLE001 + logger.debug("aggregate adj_factor failed: %s", e) + return None + + +def _safe_aggregate_minute(repo) -> dict | None: + """kline_minute 统计 — 从分区目录名获取交易日数,跳过全表扫描。 + + 分钟 K 按 date=YYYY-MM-DD 分区存储,直接数目录即可, + 无需 count(*) / count(DISTINCT ...) 等昂贵查询。 + """ + minute_dir = repo.store.data_dir / "kline_minute" + if not minute_dir.exists(): + return None + + # 从 date=YYYY-MM-DD 目录名提取交易日 + dates: list[str] = [] + for d in minute_dir.iterdir(): + if d.is_dir() and d.name.startswith("date="): + dates.append(d.name[5:]) + + if not dates: + return None + + dates.sort() + return { + "rows": 0, # 不再查询行数 + "earliest_date": dates[0], + "latest_date": dates[-1], + "symbols_covered": 0, # 不再查询标的数 + "trading_days": len(dates), + } + + +def _safe_aggregate_financials(repo) -> dict | None: + """财务数据统计 — 检查各表文件是否存在及行数。""" + data_dir = repo.store.data_dir + tables_info: dict[str, dict] = {} + total_rows = 0 + + for table in ("metrics", "income", "balance_sheet", "cash_flow"): + path = data_dir / "financials" / table / "part.parquet" + if path.exists(): + try: + import polars as pl + df = pl.read_parquet(path, columns=["symbol"]) + rows = len(df) + symbols = df["symbol"].n_unique() if not df.is_empty() else 0 + tables_info[table] = {"rows": rows, "symbols": symbols} + total_rows += rows + except Exception: + tables_info[table] = {"rows": 0, "symbols": 0} + else: + tables_info[table] = {"rows": 0, "symbols": 0} + + if total_rows == 0: + return None + + return { + "rows": total_rows, + "tables": tables_info, + } + + +def _scan_dir_stats(dirpath: Path) -> tuple[int, float]: + """单次遍历统计目录下文件数和总大小(MB)。比 rglob+stat 快很多。""" + if not dirpath.exists(): + return 0, 0.0 + count = 0 + total = 0 + for entry in os.scandir(dirpath): + if entry.is_dir(follow_symlinks=False): + c, s = _scan_dir_recursive(entry) + count += c + total += s + elif entry.is_file(follow_symlinks=False): + try: + total += entry.stat().st_size + except OSError: + pass + count += 1 + return count, round(total / 1048576, 2) + + +def _scan_dir_recursive(entry: os.DirEntry) -> tuple[int, int]: + """递归统计一个 DirEntry 下的文件数和总字节数。""" + count = 0 + total = 0 + try: + for sub in os.scandir(entry.path): + if sub.is_dir(follow_symlinks=False): + c, s = _scan_dir_recursive(sub) + count += c + total += s + elif sub.is_file(follow_symlinks=False): + try: + total += sub.stat().st_size + except OSError: + pass + count += 1 + except PermissionError: + pass + return count, total + + +def _compute_storage(data_dir: Path) -> dict: + """单次遍历计算 storage 统计,避免多次 rglob。""" + import os + + # 只统计关心的子目录 + subdirs = { + "daily": data_dir / "kline_daily", + "enriched": data_dir / "kline_daily_enriched", + "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", + "ext_data": data_dir / "ext_data", + } + stats = {} + total_size = 0 + for key, d in subdirs.items(): + fc, sz = _scan_dir_stats(d) + total_size += sz + stats[f"{key}_files"] = fc + stats[f"{key}_size_mb"] = sz + + # total: 再加上其他零散文件(pools, financials, capabilities.json 等) + other_dirs = ["pools", "financials", "backtest_results", "screener_results", "ai_cache"] + for name in other_dirs: + d = data_dir / name + if d.exists(): + _, s = _scan_dir_stats(d) + total_size += s + + # financials 单独统计 + fin_dir = data_dir / "financials" + if fin_dir.exists(): + fc, sz = _scan_dir_stats(fin_dir) + stats["financials_files"] = fc + stats["financials_size_mb"] = sz + total_size += sz + for name in other_dirs: + d = data_dir / name + if d.exists(): + _, s = _scan_dir_stats(d) + total_size += s + # 根目录散文件 + for entry in os.scandir(data_dir): + if entry.is_file(follow_symlinks=False): + try: + total_size += entry.stat().st_size / 1048576 + except OSError: + pass + stats["total_size_mb"] = round(total_size, 2) + return stats + + +def _next_cron_run(scheduler, job_id: str) -> str | None: + """读 APScheduler 下次执行时间。""" + if not scheduler: + return None + try: + job = scheduler.get_job(job_id) + if job and job.next_run_time: + return job.next_run_time.isoformat(timespec="seconds") + except Exception: # noqa: BLE001 + pass + return None + + +def _get_storage(data_dir: Path) -> dict: + """返回缓存的 storage 统计;走独立 TTL,stage 写完不触发重算。""" + global _storage_cache, _storage_cache_ts + now = time.time() + with _storage_lock: + if _storage_cache is not None and (now - _storage_cache_ts) < _STORAGE_TTL: + return _storage_cache + fresh = _compute_storage(data_dir) + with _storage_lock: + _storage_cache = fresh + _storage_cache_ts = now + return fresh + + +def _last_finished(job_label: str) -> str | None: + """从 JobStore 读最近一次该类型任务的完成时间(缓存到 pipeline 终态失效)。""" + global _last_finished_cache + with _last_finished_lock: + if _last_finished_cache is not None: + return _last_finished_cache.get(job_label) + + from app.services.pipeline_jobs import job_store + jobs = job_store.list_recent(limit=50) + cache: dict[str, str | None] = {} + for j in jobs: + if j["status"] not in ("succeeded", "failed"): + continue + if "instruments_rows" in (j.get("result") or {}) and "instruments" not in cache: + cache["instruments"] = j["finished_at"] + if "daily_days" in (j.get("result") or {}) and "pipeline" not in cache: + cache["pipeline"] = j["finished_at"] + with _last_finished_lock: + _last_finished_cache = cache + return cache.get(job_label) + + +@router.get("/status") +def status(request: Request) -> dict: + repo = request.app.state.repo + scheduler = getattr(request.app.state, "scheduler", None) + data_dir = repo.store.data_dir + + 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)), + "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)), + + # 文件层面信息(缓存) + "storage": _get_storage(data_dir), + + # 调度 + "next_instruments_run": _next_cron_run(scheduler, "pre_market_instruments"), + "next_pipeline_run": _next_cron_run(scheduler, "daily_pipeline"), + "last_instruments_run": _last_finished("instruments"), + "last_pipeline_run": _last_finished("pipeline"), + "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + } + + +@router.post("/clear") +def clear_data(request: Request): + """清除所有本地 Parquet 数据(保留 capabilities.json 和目录结构)。""" + import shutil + + repo = request.app.state.repo + data_dir = repo.store.data_dir + deleted = 0 + + 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", "instruments", "instruments_index", "instruments_etf", "pools", "financials", + "backtest_results", "screener_results", "ai_cache", + ): + d = data_dir / sub + if d.exists(): + # 先删所有 parquet 文件 + for f in d.rglob("*.parquet"): + f.unlink() + deleted += 1 + # 再删除空的日期分区子目录(date=YYYY-MM-DD 等) + for child in list(d.iterdir()): + if child.is_dir(): + shutil.rmtree(child, ignore_errors=True) + + # 清除同步历史(内存 + 磁盘 job_store/ 文件夹) + from app.services.pipeline_jobs import job_store + job_store.clear() + + # 清除财务数据 + fin_dir = data_dir / "financials" + for sub in ("metrics", "income", "balance_sheet", "cash_flow"): + fp = fin_dir / sub / "part.parquet" + if fp.exists(): + fp.unlink() + deleted += 1 + + # 清除监控运行数据 (user_data 下仅清运行产物, 不动 monitor_rules/preferences/secrets 等用户配置) + # - 触发记录 alerts.jsonl + from app.services import alert_store + alert_store.clear(data_dir) + # - 待推送的实时通知队列 (进程内存) + qs = getattr(request.app.state, "quote_service", None) + if qs is not None: + with qs._lock: + qs._pending_alerts.clear() + + # 清除 Polars 缓存 + # 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return, + # 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因), + # 再 refresh_cache 尝试重载 (磁盘有数据则重建缓存)。 + repo.clear_cache() + repo.refresh_cache() + + # 清除 Screener 进程级 _history_cache (TTL 缓存) + from app.services.screener import ScreenerService + ScreenerService.clear_history_cache() + + # 清除 Overview 总览聚合结果缓存 (5s TTL) + from app.api.overview import invalidate_overview_cache + invalidate_overview_cache() + + # 刷新 DuckDB 视图(空 parquet 目录也需要重新挂载) + d = data_dir.as_posix() + for name, path in { + "kline_daily": f"{d}/kline_daily/**/*.parquet", + "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet", + "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet", + "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet", + "kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet", + "kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet", + "kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet", + "kline_minute": f"{d}/kline_minute/**/*.parquet", + "adj_factor": f"{d}/adj_factor/**/*.parquet", + "adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet", + "instruments": f"{d}/instruments/**/*.parquet", + "instruments_index": f"{d}/instruments_index/**/*.parquet", + "instruments_etf": f"{d}/instruments_etf/**/*.parquet", + }.items(): + try: + repo.db.execute( + f"CREATE OR REPLACE VIEW {name} AS " + f"SELECT * FROM read_parquet('{path}', union_by_name=true)" + ) + except Exception: + pass + + logger.info("数据已清除: 删除 %d 个 parquet 文件", deleted) + invalidate_data_cache(None) + return {"deleted_files": deleted} + + +# 各表字段说明 +_TABLE_FIELD_DESC: dict[str, dict[str, str]] = { + "kline_daily": { + "symbol": "股票代码", + "date": "交易日期", + "open": "开盘价", + "high": "最高价", + "low": "最低价", + "close": "收盘价", + "volume": "成交量", + "amount": "成交额", + }, + "kline_enriched": ENRICHED_COLUMNS, + "kline_index_daily": { + "symbol": "指数代码", + "date": "交易日期", + "open": "开盘点位", + "high": "最高点位", + "low": "最低点位", + "close": "收盘点位", + "volume": "成交量", + "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": "分钟时间戳", + "open": "开盘价", + "high": "最高价", + "low": "最低价", + "close": "收盘价", + "volume": "成交量", + "amount": "成交额", + }, + "adj_factor": { + "symbol": "股票代码", + "timestamp": "除权除息时间戳(ms)", + "trade_date": "除权除息日", + "ex_factor": "复权因子", + }, + "instruments": { + "symbol": "股票代码", + "name": "股票名称", + "code": "股票编码(纯数字)", + "exchange": "交易所(SH/SZ/BJ)", + "region": "地区", + "type": "证券类型", + "listing_date": "上市日期", + "total_shares": "总股本", + "float_shares": "流通股本", + "tick_size": "最小价格变动单位", + "limit_up": "涨停限制(%)", + "limit_down": "跌停限制(%)", + "as_of": "快照日期", + }, + "instruments_index": { + "symbol": "指数代码", + "name": "指数名称", + "code": "指数编码(纯数字)", + "asset_type": "资产类型(index)", + }, + "instruments_etf": { + "symbol": "ETF代码", + "name": "ETF名称", + "code": "ETF编码(纯数字)", + "asset_type": "资产类型(etf)", + "source": "数据源", + }, +} + +# view 名 → DuckDB 视图名 +_SCHEMA_VIEWS: dict[str, str] = { + "daily": "kline_daily", + "enriched": "kline_enriched", + "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", +} + + +@router.get("/schema/{table}") +def table_schema(request: Request, table: str) -> list[dict]: + """返回指定表的字段名、类型和中文说明。 + + 优先从 DuckDB DESCRIBE 读取(有数据时含精确类型); + 视图不存在(无数据)时回退到 _TABLE_FIELD_DESC 静态定义。 + """ + view = _SCHEMA_VIEWS.get(table) + if not view: + return [] + desc_map = _TABLE_FIELD_DESC.get(view, {}) + repo = request.app.state.repo + fields: list[dict] = [] + try: + cols = repo.execute_all(f"DESCRIBE {view}") + for col in cols: + name = col[0] + dtype = col[1] + fields.append({ + "name": name, + "type": dtype, + "desc": desc_map.get(name, ""), + }) + except Exception: # noqa: BLE001 + # 视图不存在(本地无数据),用静态字段定义兜底 + if desc_map: + for name, desc in desc_map.items(): + fields.append({"name": name, "type": "—", "desc": desc}) + return fields + + +@router.get("/version") +def get_version(request: Request) -> dict: + """返回当前项目版本号。 + + 优先读 app.__version__ (与 /health 接口同源, 唯一权威版本), + 回退到项目根 VERSION 文件, 最后兜底 v0.0.0。 + """ + from app import __version__ + + # 1. 优先用 app.__version__ (唯一权威版本, 打包期由 PyInstaller 注入) + if __version__: + v = __version__.strip() + return {"version": v if v.startswith("v") else f"v{v}"} + + # 2. 回退到项目根 VERSION 文件 + from app.config import settings + project_root = Path(settings.data_dir).parent + version_file = project_root / "VERSION" + if version_file.exists(): + v = version_file.read_text(encoding="utf-8").strip() + if v: + return {"version": v} + + return {"version": "v0.0.0"} diff --git a/serve/backend/app/api/ext_data.py b/serve/backend/app/api/ext_data.py new file mode 100644 index 0000000..a2b3ff2 --- /dev/null +++ b/serve/backend/app/api/ext_data.py @@ -0,0 +1,871 @@ +"""扩展数据 API — CRUD + 文件上传 + JSON 写入 + 定时拉取 + schema 发现。""" +from __future__ import annotations + +import json +import logging +import math +import shutil +import tempfile +from datetime import date, datetime +from pathlib import Path +from typing import Literal + +import polars as pl +from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile +from pydantic import BaseModel, Field + +from app.services.ext_data import ( + ExtConfig, + ExtConfigStore, + ExtField, + PullConfig, + ensure_utf8_csv, + fix_symbol_format, + normalize_symbol, + parse_upload_file, + write_ext_parquet, + rows_to_parquet, +) +from app.services.ext_pull import fetch_and_ingest, pull_scheduler + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/ext-data", tags=["ext-data"]) + + +# --------------------------------------------------------------------------- +# Pydantic 模型 +# --------------------------------------------------------------------------- + +class FieldDef(BaseModel): + name: str + dtype: str = "string" # string | int | float | bool + label: str = "" + + +class CreateExtReq(BaseModel): + id: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_]+$") + label: str = Field(..., min_length=1, max_length=64) + mode: Literal["snapshot", "timeseries"] + fields: list[FieldDef] = Field(..., min_length=1) + description: str = "" + symbol_map: dict = {} # {"type": "mapped", "col": "..."} 或 {"type": "computed", "from": "code", "method": "append_exchange"} + code_map: dict = {} # {"type": "mapped", "col": "..."} 或 {"type": "computed", "from": "symbol", "method": "strip_exchange"} + + +class UpdateExtReq(BaseModel): + label: str | None = None + fields: list[FieldDef] | None = None + description: str | None = None + symbol_map: dict | None = None + code_map: dict | None = None + + +class IngestReq(BaseModel): + """JSON 批量写入请求。""" + date: str | None = None # YYYY-MM-DD,不传默认今天 + rows: list[dict] = Field(..., min_length=1) + + +class PullConfigReq(BaseModel): + """定时拉取配置请求。""" + url: str = Field(..., min_length=1) + method: str = "GET" + headers: dict[str, str] | None = None + body: str | None = None + response_path: str = "" # dot-path to rows array + field_map: dict[str, str] | None = None # external → internal field name + schedule_minutes: int = Field(1440, ge=1) + enabled: bool = False + + +# --------------------------------------------------------------------------- +# 辅助 +# --------------------------------------------------------------------------- + +def _store(request: Request) -> ExtConfigStore: + return ExtConfigStore(request.app.state.repo.store.data_dir) + + +def _data_dir(request: Request) -> Path: + return request.app.state.repo.store.data_dir + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +def _apply_mapping(df: pl.DataFrame, config: ExtConfig, data_dir: Path) -> pl.DataFrame: + """根据 config 的 symbol_map / code_map 自动生成 symbol 和 code 列。 + + 执行顺序:先 mapped(从文件列复制),再 computed(从已生成的列计算)。 + """ + sm = config.symbol_map or {} + cm = config.code_map or {} + + # --- 第一步:mapped 类型,直接从文件列映射 --- + if sm.get("type") == "mapped" and sm["col"] in df.columns: + df = df.with_columns(df[sm["col"]].cast(pl.Utf8).alias("symbol")) + + if cm.get("type") == "mapped" and cm["col"] in df.columns: + df = df.with_columns(df[cm["col"]].cast(pl.Utf8).alias("code")) + + # --- 第二步:computed 类型,从已生成的列计算 --- + if "symbol" not in df.columns and sm.get("type") == "computed": + if sm.get("from") == "code" and "code" in df.columns: + # code → symbol: 000001 → 000001.SZ + from app.services.ext_data import build_code_lookup + lookup = build_code_lookup(data_dir) + df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol")) + + if "code" not in df.columns and cm.get("type") == "computed": + if cm.get("from") == "symbol" and "symbol" in df.columns: + # symbol → code: 000001.SZ → 000001 + df = df.with_columns( + df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code") + ) + + # --- 兜底:如果只有一个,自动生成另一个 --- + if "symbol" in df.columns and "code" not in df.columns: + df = df.with_columns( + df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code") + ) + elif "code" in df.columns and "symbol" not in df.columns: + from app.services.ext_data import build_code_lookup + lookup = build_code_lookup(data_dir) + df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol")) + + # 标准化 symbol 列 + if "symbol" in df.columns: + from app.services.ext_data import build_code_lookup + lookup = build_code_lookup(data_dir) + df = df.with_columns(normalize_symbol(df["symbol"].cast(pl.Utf8), lookup)) + + return df + + +def _clean_col_names(df: pl.DataFrame) -> pl.DataFrame: + """清洗列名:去掉所有 (...) 及其内容,避免时间戳导致列名不稳定。""" + import re + renames = {col: re.sub(r"\([^)]*\)", "", col).strip() for col in df.columns} + # 去重:如果清洗后重名,加序号后缀 + seen: dict[str, int] = {} + final = {} + for old, new in renames.items(): + if new in seen: + seen[new] += 1 + final[old] = f"{new}_{seen[new]}" + else: + seen[new] = 0 + final[old] = new + return df.rename(final) + + +def _ext_data_dir(config: ExtConfig, data_dir: Path) -> Path: + """返回扩展数据的数据目录。 + + - snapshot: data/ext_data/{id}/(part.parquet 与 config.json 同级) + - timeseries: data/ext_data/{id}/timeseries/ + """ + cfg_dir = data_dir / "ext_data" / config.id + if config.mode == "timeseries": + return cfg_dir / "timeseries" + return cfg_dir + + +def _parquet_glob(config: ExtConfig, data_dir: Path) -> str: + """返回该扩展配置下所有 parquet 文件的 glob 模式。 + + snapshot: 'data/ext_data/{id}/*.parquet'(只有 part.parquet) + timeseries: 'data/ext_data/{id}/timeseries/**/*.parquet' + """ + cfg_dir = data_dir / "ext_data" / config.id + if config.mode == "snapshot": + return str(cfg_dir / "*.parquet") + return str(cfg_dir / "timeseries" / "**" / "*.parquet") + + +def _safe_json_value(value): + if isinstance(value, float) and not math.isfinite(value): + return None + if isinstance(value, (date, datetime)): + return value.isoformat() + return value + + +def _read_ext_dataframe( + config: ExtConfig, + data_dir: Path, + snapshot_date: str | None = None, +) -> tuple[pl.DataFrame, str | None]: + cfg_dir = data_dir / "ext_data" / config.id + + if config.mode == "snapshot": + path = cfg_dir / "part.parquet" + if not path.exists(): + return pl.DataFrame(), None + return pl.read_parquet(path), _latest_sync_date(config, data_dir) + + base = cfg_dir / "timeseries" + if not base.exists(): + return pl.DataFrame(), None + + if snapshot_date: + path = base / f"date={snapshot_date}" / "part.parquet" + if not path.exists(): + return pl.DataFrame(), snapshot_date + return pl.read_parquet(path), snapshot_date + + partitions = sorted( + d for d in base.iterdir() + if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists() + ) + if not partitions: + return pl.DataFrame(), None + + latest = partitions[-1] + latest_date = latest.name[5:] + return pl.read_parquet(latest / "part.parquet"), latest_date + + +def _with_instrument_name(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame: + if df.is_empty() or "symbol" not in df.columns or "name" in df.columns: + return df + path = data_dir / "instruments" / "instruments.parquet" + if not path.exists(): + return df + try: + inst = pl.read_parquet(path) + if "symbol" in inst.columns and "name" in inst.columns: + inst = inst.select(["symbol", "name"]).unique(subset=["symbol"], keep="last") + return df.join(inst, on="symbol", how="left") + except Exception: + return df + return df + + +def _latest_sync_date(config: ExtConfig, data_dir: Path) -> str | None: + """扫描数据文件,返回该扩展配置的最新同步时间(含时分秒)。 + + - snapshot: 直接取 ext_data/{id}/part.parquet 的 mtime + - timeseries: 扫描 ext_data/{id}/timeseries/date=xxx 分区目录 + """ + from datetime import datetime + + if config.mode == "snapshot": + # 快照: part.parquet 与 config.json 同级 + p = data_dir / "ext_data" / config.id / "part.parquet" + if p.exists(): + ts = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") + return ts + # 兼容旧路径 + old = data_dir / "instruments_ext" + if old.exists(): + return _latest_sync_from_partitions(old) + return None + + # 时序: 扫描 timeseries/date=xxx + base = _ext_data_dir(config, data_dir) + if not base.exists(): + # 兼容旧路径 + base = data_dir / "kline_ext" + if not base.exists(): + return None + return _latest_sync_from_partitions(base) + + +def _latest_sync_from_partitions(base: Path) -> str | None: + """从 date=xxx 分区目录中找到最新分区的修改时间。""" + from datetime import datetime + latest_ts: float = 0 + latest_date: str | None = None + for d in base.iterdir(): + if d.is_dir() and d.name.startswith("date="): + for f in d.glob("*.parquet"): + mtime = f.stat().st_mtime + if mtime > latest_ts: + latest_ts = mtime + latest_date = d.name[5:] + if latest_date and latest_ts > 0: + ts = datetime.fromtimestamp(latest_ts).strftime("%H:%M:%S") + return f"{latest_date} {ts}" + return latest_date + + +def _date_range(config: ExtConfig, data_dir: Path) -> list[str] | None: + """返回时序型扩展数据的日期范围 [最早, 最新]。""" + if config.mode != "timeseries": + return None + base = _ext_data_dir(config, data_dir) + if not base.exists(): + # 兼容旧路径 + base = data_dir / "kline_ext" + if not base.exists(): + return None + dates: list[str] = [] + for d in base.iterdir(): + if d.is_dir() and d.name.startswith("date="): + dates.append(d.name[5:]) + if len(dates) < 1: + return None + dates.sort() + return [dates[0], dates[-1]] + + +@router.get("") +def list_configs(request: Request): + """列出所有扩展数据配置。""" + configs = _store(request).load_all() + data_dir = _data_dir(request) + items = [] + for c in configs: + d = c.to_dict() + d["latest_sync_date"] = _latest_sync_date(c, data_dir) + d["date_range"] = _date_range(c, data_dir) + items.append(d) + 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): + """创建扩展数据配置。""" + store = _store(request) + if store.get(body.id): + raise HTTPException(400, f"配置 '{body.id}' 已存在") + config = ExtConfig( + id=body.id, + label=body.label, + mode=body.mode, + fields=[ExtField(f.name, f.dtype, f.label) for f in body.fields], + description=body.description, + symbol_map=body.symbol_map, + code_map=body.code_map, + ) + store.upsert(config) + return config.to_dict() + + +@router.put("/{config_id}") +def update_config(request: Request, config_id: str, body: UpdateExtReq): + """更新扩展数据配置。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + if body.label is not None: + config.label = body.label + if body.fields is not None: + config.fields = [ExtField(f.name, f.dtype, f.label) for f in body.fields] + if body.description is not None: + config.description = body.description + if body.symbol_map is not None: + config.symbol_map = body.symbol_map + if body.code_map is not None: + config.code_map = body.code_map + store.upsert(config) + return config.to_dict() + + +@router.delete("/{config_id}") +def delete_config(request: Request, config_id: str): + """删除扩展数据配置。""" + store = _store(request) + if not store.delete(config_id): + raise HTTPException(404, f"配置 '{config_id}' 不存在") + return {"status": "deleted"} + + +@router.get("/{config_id}/rows") +def list_rows( + request: Request, + config_id: str, + snapshot_date: str | None = Query(None, alias="date"), + columns: str | None = Query(None, description="逗号分隔的字段列表"), + limit: int = Query(1000, ge=1, le=20000), +): + """读取扩展数据明细。 + + - snapshot: 返回当前快照。 + - timeseries: 默认返回最新日期分区,也可通过 date=YYYY-MM-DD 指定。 + """ + config = _store(request).get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + + data_dir = _data_dir(request) + df, active_date = _read_ext_dataframe(config, data_dir, snapshot_date) + df = _with_instrument_name(df, data_dir) + requested = [c.strip() for c in (columns or "").split(",") if c.strip()] + if requested: + keep = [c for c in ["symbol", "code", "name", *requested] if c in df.columns] + if keep: + df = df.select(list(dict.fromkeys(keep))) + total = len(df) + if total > limit: + df = df.head(limit) + + rows = [] + for row in df.to_dicts(): + rows.append({k: _safe_json_value(v) for k, v in row.items()}) + + return { + "id": config.id, + "label": config.label, + "mode": config.mode, + "date": active_date, + "total": total, + "limit": limit, + "fields": [f.to_dict() for f in config.fields], + "rows": rows, + } + + +# --------------------------------------------------------------------------- +# 文件上传 +# --------------------------------------------------------------------------- + +@router.post("/{config_id}/upload") +async def upload_data( + request: Request, + config_id: str, + file: UploadFile = File(...), + snapshot_date: str | None = None, +): + """上传 CSV/Excel 文件写入扩展数据。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + + # 校验文件后缀 + suffix = Path(file.filename or "").suffix.lower() + if suffix not in (".csv", ".xlsx", ".xls"): + raise HTTPException(400, "仅支持 CSV / Excel 文件") + + # 写到临时文件再解析 + tmp_dir = Path(tempfile.mkdtemp()) + tmp_path = tmp_dir / f"upload{suffix}" + try: + with tmp_path.open("wb") as f: + content = await file.read() + f.write(content) + + # 直接读取文件,不做列重命名 + if suffix == ".csv": + df = pl.read_csv(ensure_utf8_csv(tmp_path), infer_schema_length=10000) + elif suffix in (".xlsx", ".xls"): + df = pl.read_excel(tmp_path) + else: + raise HTTPException(400, f"不支持的文件格式: {suffix}") + + # 清洗列名:去掉括号内的时间戳等信息 + df = _clean_col_names(df) + + # 按映射关系自动生成 symbol 和 code 列 + df = _apply_mapping(df, config, _data_dir(request)) + except ValueError as e: + raise HTTPException(400, str(e)) from e + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + # 确保配置的字段列存在于上传数据中(symbol/code 由映射自动生成,不校验) + auto_fields = {"symbol", "code"} + config_cols = {f.name for f in config.fields} - auto_fields + missing = config_cols - set(df.columns) + if missing: + raise HTTPException(400, f"上传数据缺少字段: {', '.join(sorted(missing))}") + + # 只保留配置中定义的列(包括自动生成的 symbol/code),忽略文件中多余的字段 + all_config_cols = {f.name for f in config.fields} + keep = [c for c in df.columns if c in all_config_cols] + df = df.select(keep) + + # 解析快照日期 + snap = date.fromisoformat(snapshot_date) if snapshot_date else date.today() + + rows = write_ext_parquet(df, config, _data_dir(request), snapshot_date=snap) + + # 刷新 DuckDB 视图 + _refresh_views(request) + + return {"status": "ok", "rows": rows, "date": snap.isoformat()} + + +# --------------------------------------------------------------------------- +# JSON 接口写入 +# --------------------------------------------------------------------------- + +@router.post("/{config_id}/ingest") +def ingest_data(request: Request, config_id: str, body: IngestReq): + """通过 JSON 接口批量写入扩展数据。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + + # 校验必填字段 + configured = {f.name for f in config.fields} + required = configured - {"symbol"} + for i, row in enumerate(body.rows): + if "symbol" not in row: + raise HTTPException(400, f"第 {i + 1} 行缺少 symbol 字段") + missing = required - set(row.keys()) + if missing: + raise HTTPException(400, f"第 {i + 1} 行缺少字段: {', '.join(sorted(missing))}") + + snap = date.fromisoformat(body.date) if body.date else date.today() + + rows_written = rows_to_parquet(body.rows, config, _data_dir(request), snapshot_date=snap) + + _refresh_views(request) + + return {"status": "ok", "rows": rows_written, "date": snap.isoformat()} + + +# --------------------------------------------------------------------------- +# 定时拉取 +# --------------------------------------------------------------------------- + +@router.put("/{config_id}/pull") +def configure_pull(request: Request, config_id: str, body: PullConfigReq): + """配置(或更新)定时拉取。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + + # 保留历史状态字段 + old_pull = config.pull + config.pull = PullConfig( + url=body.url, + method=body.method, + headers=body.headers, + body=body.body, + response_path=body.response_path, + field_map=body.field_map, + schedule_minutes=body.schedule_minutes, + enabled=body.enabled, + last_run=old_pull.last_run if old_pull else None, + last_status=old_pull.last_status if old_pull else None, + last_message=old_pull.last_message if old_pull else None, + last_rows=old_pull.last_rows if old_pull else None, + ) + store.upsert(config) + + # 刷新调度器 + pull_scheduler.refresh(_data_dir(request)) + + # 关闭定时拉取时清理残留的 next_run, 避免前端展示一个永不执行的"下次" + if not config.pull.enabled: + cleared = store.get(config_id) + if cleared and cleared.pull and cleared.pull.next_run: + cleared.pull.next_run = None + store.upsert(cleared) + + return {"status": "ok", "pull": config.pull.to_dict()} + + +@router.post("/{config_id}/pull/test") +async def test_pull(request: Request, config_id: str): + """测试拉取:请求外部 API 并返回预览数据,不写入。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + if not config.pull or not config.pull.url: + raise HTTPException(400, "拉取未配置或 URL 为空") + + # 临时构建一个带新配置的 config 用于测试 + from app.services.ext_pull import _extract_rows, _apply_field_map + import httpx + + pull = config.pull + try: + async with httpx.AsyncClient(timeout=30) as client: + headers = pull.headers or {} + kwargs: dict = {"headers": headers} + if pull.method.upper() == "POST" and pull.body: + kwargs["content"] = pull.body + if "content-type" not in {k.lower() for k in headers}: + kwargs["headers"]["Content-Type"] = "application/json" + resp = await client.request(pull.method.upper(), pull.url, **kwargs) + resp.raise_for_status() + data = resp.json() + + rows = _extract_rows(data, pull.response_path) + preview = _apply_field_map(rows[:5], pull.field_map) + return { + "status": "ok", + "total_rows": len(rows), + "preview": preview, + "has_symbol": bool(rows and "symbol" in rows[0]), + } + except Exception as e: + raise HTTPException(400, f"测试失败: {e}") from e + + +@router.post("/{config_id}/pull/run") +async def run_pull(request: Request, config_id: str): + """手动触发一次拉取并写入。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + if not config.pull or not config.pull.url: + raise HTTPException(400, "拉取未配置或 URL 为空") + + try: + n, d = await fetch_and_ingest(config, _data_dir(request)) + _refresh_views(request) + # 写回执行状态, 让前端"上次执行"面板立即反映 + updated = store.get(config_id) + if updated and updated.pull: + from datetime import datetime, timezone + updated.pull.last_run = datetime.now(timezone.utc).isoformat() + updated.pull.last_status = "success" + updated.pull.last_message = f"{n} rows @ {d}" + updated.pull.last_rows = n + store.upsert(updated) + return {"status": "ok", "rows": n, "date": d} + except Exception as e: + # 失败也写回状态, 记录错误信息 + failed = store.get(config_id) + if failed and failed.pull: + from datetime import datetime, timezone + failed.pull.last_run = datetime.now(timezone.utc).isoformat() + failed.pull.last_status = "error" + failed.pull.last_message = str(e)[:200] + store.upsert(failed) + raise HTTPException(400, f"拉取失败: {e}") from e + + +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Symbol 格式修复 +# --------------------------------------------------------------------------- + +@router.post("/{config_id}/fix-symbol") +def fix_symbol(request: Request, config_id: str): + """扫描已有 Parquet 数据,将 symbol 列标准化为 代码.交易所 格式。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + + fixed = fix_symbol_format(config, _data_dir(request)) + _refresh_views(request) + return {"status": "ok", "fixed_files": fixed} + + +# --------------------------------------------------------------------------- +# Schema 发现 +# --------------------------------------------------------------------------- + +_POLARS_TYPE_MAP = { + "Int64": "int", "Int32": "int", "Int16": "int", "Int8": "int", + "UInt64": "int", "UInt32": "int", "UInt16": "int", "UInt8": "int", + "Float64": "float", "Float32": "float", + "Boolean": "bool", + "Utf8": "string", "String": "string", + "Date": "string", "Datetime": "string", "Duration": "string", + "Categorical": "string", +} + + +@router.post("/detect-fields") +async def detect_fields( + request: Request, + file: UploadFile = File(...), +): + """上传 CSV/Excel 文件,自动检测列名和类型。 + + 返回 symbol_candidates(数据匹配 000001.SZ 格式的列)和 + code_candidates(数据匹配 6位纯数字的列)。 + """ + suffix = Path(file.filename or "").suffix.lower() + if suffix not in (".csv", ".xlsx", ".xls"): + raise HTTPException(400, "仅支持 CSV / Excel 文件") + + tmp_dir = Path(tempfile.mkdtemp()) + tmp_path = tmp_dir / f"upload{suffix}" + try: + with tmp_path.open("wb") as f: + content = await file.read() + f.write(content) + + # 直接读取,不要求 symbol 列 + if suffix == ".csv": + df = pl.read_csv(ensure_utf8_csv(tmp_path), infer_schema_length=10000) + elif suffix in (".xlsx", ".xls"): + df = pl.read_excel(tmp_path) + else: + raise HTTPException(400, f"不支持的文件格式: {suffix}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, str(e)) from e + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + # 清洗列名:去掉括号内的时间戳等信息 + df = _clean_col_names(df) + + # 构建字段列表 + fields = [] + for col_name in df.columns: + pl_type = df[col_name].dtype + dtype = _POLARS_TYPE_MAP.get(str(pl_type.base_type()), "string") + fields.append({"name": col_name, "dtype": dtype, "label": col_name}) + + # --- 分别检测 symbol 候选 (000001.SZ) 和 code 候选 (6位纯数字) --- + import re + _CODE_PAT = re.compile(r"^\d{6}$") + _SYMBOL_PAT = re.compile(r"^\d{6}\.[A-Z]{2}$") + + symbol_candidates: list[str] = [] # 数据匹配 000001.SZ 格式 + code_candidates: list[str] = [] # 数据匹配 000001 格式 + + for col in df.columns: + col_data = df[col].cast(pl.Utf8).drop_nulls() + if len(col_data) == 0: + continue + sample = col_data.head(200).to_list() + sym_hits = sum(1 for v in sample if _SYMBOL_PAT.match(str(v).strip())) + code_hits = sum(1 for v in sample if _CODE_PAT.match(str(v).strip())) + total = len(sample) + if total > 0: + if sym_hits / total > 0.5: + symbol_candidates.append(col) + elif code_hits / total > 0.5: + code_candidates.append(col) + + return { + "fields": fields, + "rows": len(df), + "symbol_candidates": symbol_candidates, + "code_candidates": code_candidates, + } + + +@router.get("/schema/{config_id}") +def discover_schema(request: Request, config_id: str): + """发现扩展数据的实际 Parquet schema(基于已有数据)。""" + config = _store(request).get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + + data_dir = _data_dir(request) + glob = _parquet_glob(config, data_dir) + + try: + import duckdb + rows = duckdb.query( + f"SELECT column_name, data_type FROM (DESCRIBE SELECT * FROM read_parquet('{glob}', union_by_name=true))" + ).fetchall() + return {"columns": [{"name": r[0], "type": r[1]} for r in rows]} + except Exception: + # 无数据时返回配置中定义的字段 + return {"columns": [f.to_dict() for f in config.fields]} + + +@router.get("/schema-all") +def discover_all_schemas(request: Request): + """发现所有扩展表的 schema(用于前端动态列选择)。""" + configs = _store(request).load_all() + result = [] + for config in configs: + data_dir = _data_dir(request) + glob = _parquet_glob(config, data_dir) + + try: + import duckdb + cols = duckdb.query( + f"SELECT column_name, data_type FROM (DESCRIBE SELECT * FROM read_parquet('{glob}', union_by_name=true))" + ).fetchall() + field_labels = {f.name: f.label for f in config.fields} + columns = [{"name": r[0], "type": r[1], "label": field_labels.get(r[0], r[0])} for r in cols] + except Exception: + columns = [f.to_dict() for f in config.fields] + + result.append({ + "id": config.id, + "label": config.label, + "mode": config.mode, + "columns": columns, + }) + return {"items": result} + + +# --------------------------------------------------------------------------- +# 视图刷新 +# --------------------------------------------------------------------------- + +def _refresh_views(request: Request) -> None: + """重新注册 DuckDB 视图以包含新的扩展数据。""" + repo = request.app.state.repo + db = repo.store.db + d = repo.store.data_dir.as_posix() + + # 注册旧路径视图(兼容) + for name, subdir in [("instruments_ext", "instruments_ext"), ("kline_ext", "kline_ext")]: + old_glob = f"{d}/{subdir}/**/*.parquet" + old_dir = Path(d) / subdir + if old_dir.exists(): + sql = ( + f"CREATE OR REPLACE VIEW {name} AS " + f"SELECT * FROM read_parquet('{old_glob}', union_by_name=true)" + ) + try: + db.execute(sql) + except Exception: + pass + + # 注册新路径视图:每个扩展表一个视图 ext_{config_id} + ext_base = Path(d) / "ext_data" + if ext_base.exists(): + for cfg_dir in ext_base.iterdir(): + if not cfg_dir.is_dir(): + continue + cp = cfg_dir / "config.json" + if not cp.exists(): + continue + try: + raw = json.loads(cp.read_text(encoding="utf-8")) + cfg_id = raw["id"] + # 检查是否有数据文件(snapshot: part.parquet, timeseries: timeseries/ 目录) + has_data = (cfg_dir / "part.parquet").exists() or (cfg_dir / "timeseries").exists() + if has_data: + view_name = f"ext_{cfg_id}" + # snapshot: part.parquet 在 cfg_dir/ 根下; timeseries: 在 timeseries/ 子目录 + mode = raw.get("mode", "snapshot") + if mode == "snapshot": + glob_pattern = f"{cfg_dir.as_posix()}/*.parquet" + else: + glob_pattern = f"{cfg_dir.as_posix()}/timeseries/**/*.parquet" + sql = ( + f"CREATE OR REPLACE VIEW {view_name} AS " + f"SELECT * FROM read_parquet('{glob_pattern}', union_by_name=true)" + ) + db.execute(sql) + except Exception: + pass diff --git a/serve/backend/app/api/financials.py b/serve/backend/app/api/financials.py new file mode 100644 index 0000000..b8414b3 --- /dev/null +++ b/serve/backend/app/api/financials.py @@ -0,0 +1,217 @@ +"""财务数据 API — 独立路由, Cap.FINANCIAL 门控。""" +from __future__ import annotations + +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__) + +router = APIRouter(prefix="/api/financials", tags=["financials"]) + + +@router.get("/status") +def financial_status(request: Request): + """返回各财务表的同步状态。无需 FINANCIAL 权限(前端根据 available 决定是否展示)。""" + capset = request.app.state.capabilities + if not capset.has(Cap.FINANCIAL): + return {"available": False, "tables": {}} + + data_dir = request.app.state.repo.store.data_dir + tables = {} + + for table in ("metrics", "income", "balance_sheet", "cash_flow"): + path = data_dir / "financials" / table / "part.parquet" + if path.exists(): + try: + df = pl.read_parquet(path, columns=["symbol"]) + tables[table] = { + "rows": len(df), + "symbols": df["symbol"].n_unique() if not df.is_empty() else 0, + } + except Exception: + tables[table] = {"rows": 0, "symbols": 0} + else: + tables[table] = {"rows": 0, "symbols": 0} + + fs = getattr(request.app.state, "financial_scheduler", None) + last_sync = fs.last_sync if fs else {} + + return { + "available": True, + "tables": tables, + "last_sync": last_sync, + # 服务端是否正在同步(手动触发)——前端据此显示"同步中"并防重复点击, + # 且刷新页面后仍能正确反映服务端状态。 + "syncing": bool(fs and fs.is_syncing), + } + + +@router.get("/metrics") +def get_metrics(request: Request, symbol: str | None = None): + """查询核心财务指标。""" + capset = request.app.state.capabilities + capset.require(Cap.FINANCIAL) + + df = get_financial_df(request.app.state.repo.store.data_dir, "metrics") + if df.is_empty(): + return {"data": []} + if symbol: + df = df.filter(pl.col("symbol") == symbol) + return {"data": df.to_dicts()} + + +@router.get("/income") +def get_income(request: Request, symbol: str | None = None): + """查询利润表。""" + capset = request.app.state.capabilities + capset.require(Cap.FINANCIAL) + + df = get_financial_df(request.app.state.repo.store.data_dir, "income") + if df.is_empty(): + return {"data": []} + if symbol: + df = df.filter(pl.col("symbol") == symbol) + return {"data": df.to_dicts()} + + +@router.get("/balance-sheet") +def get_balance_sheet(request: Request, symbol: str | None = None): + """查询资产负债表。""" + capset = request.app.state.capabilities + capset.require(Cap.FINANCIAL) + + df = get_financial_df(request.app.state.repo.store.data_dir, "balance_sheet") + if df.is_empty(): + return {"data": []} + if symbol: + df = df.filter(pl.col("symbol") == symbol) + return {"data": df.to_dicts()} + + +@router.get("/cash-flow") +def get_cash_flow(request: Request, symbol: str | None = None): + """查询现金流量表。""" + capset = request.app.state.capabilities + capset.require(Cap.FINANCIAL) + + df = get_financial_df(request.app.state.repo.store.data_dir, "cash_flow") + if df.is_empty(): + return {"data": []} + if symbol: + df = df.filter(pl.col("symbol") == symbol) + return {"data": df.to_dicts()} + + +@router.post("/sync/{table}") +def sync_table(request: Request, table: str): + """手动触发同步(立即返回,后台异步执行)。 + + table: metrics / income / balance_sheet / cash_flow / all + 同步在后台线程执行,全量同步需数分钟。本接口立即返回 started 状态, + 前端通过轮询 GET /status 的 syncing 字段观察进度。 + """ + capset = request.app.state.capabilities + capset.require(Cap.FINANCIAL) + + valid_tables = {"metrics", "income", "balance_sheet", "cash_flow", "all"} + if table not in valid_tables: + raise HTTPException(400, f"invalid table: {table}, expected one of {valid_tables}") + + fs = getattr(request.app.state, "financial_scheduler", None) + if not fs: + return {"status": "error", "message": "FinancialScheduler not available"} + + target = None if table == "all" else table + result = fs.trigger(target) + + return {"status": "ok", "synced": result} + + +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} diff --git a/serve/backend/app/api/indices.py b/serve/backend/app/api/indices.py new file mode 100644 index 0000000..e78c2a2 --- /dev/null +++ b/serve/backend/app/api/indices.py @@ -0,0 +1,149 @@ +"""指数 API。""" +from __future__ import annotations + +import logging +from datetime import date, datetime, timedelta +from typing import Optional + +import polars as pl +from fastapi import APIRouter, HTTPException, Query, Request + +from app.indicators.pipeline import compute_enriched +from app.services import index_sync, kline_sync +from app.tickflow.capabilities import Cap + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/index", tags=["index"]) + + +def _index_info(repo, symbol: str) -> dict: + df = repo.get_index_instruments() + if df.is_empty() or "symbol" not in df.columns: + return {} + hit = df.filter(pl.col("symbol") == symbol).head(1) + if hit.is_empty(): + return {} + return hit.to_dicts()[0] + + +@router.get("/list") +def list_indices(request: Request): + """返回已缓存的 CN_Index 指数列表。""" + repo = request.app.state.repo + df = repo.get_index_instruments() + if df.is_empty(): + return {"results": [], "count": 0} + cols = [c for c in ["symbol", "name", "code", "asset_type"] if c in df.columns] + rows = df.select(cols).sort("symbol").to_dicts() + return {"results": rows, "count": len(rows)} + + +@router.get("/search") +def search_indices( + request: Request, + q: str = Query("", min_length=0, max_length=50, description="搜索关键词"), + limit: int = Query(20, ge=1, le=100), +): + """模糊搜索指数。""" + repo = request.app.state.repo + df = repo.get_index_instruments() + if df.is_empty(): + return {"results": []} + if not q.strip(): + rows = df.head(limit).to_dicts() + return {"results": rows} + + keyword = q.strip().upper() + masks = [] + if "code" in df.columns: + masks.append(pl.col("code").cast(pl.Utf8).str.contains(keyword, literal=True)) + masks.append(pl.col("symbol").cast(pl.Utf8).str.to_uppercase().str.contains(keyword, literal=True)) + if "name" in df.columns: + masks.append(pl.col("name").cast(pl.Utf8).str.contains(q.strip(), literal=True)) + + mask = masks[0] + for m in masks[1:]: + mask = mask | m + rows = df.filter(mask).head(limit).to_dicts() + return {"results": rows} + + +@router.get("/daily") +def get_index_daily( + request: Request, + symbol: str = Query(..., description="指数代码, 如 000001.SH"), + days: int = Query(120, ge=10, le=2000), + start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"), + end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"), +): + """读取指数日 K。指数数据使用独立 kline_index_* parquet。""" + repo = request.app.state.repo + end = date.fromisoformat(end_date) if end_date else date.today() + start = date.fromisoformat(start_date) if start_date else end - timedelta(days=days) + info = _index_info(repo, symbol) + + df = repo.get_index_daily(symbol, start, end) + if not df.is_empty(): + return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": df.to_dicts(), "source": "index_enriched"} + + capset = request.app.state.capabilities + if not capset.has(Cap.KLINE_DAILY_BATCH): + return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": [], "source": "none"} + + try: + raw = kline_sync.sync_daily_batch([symbol], count=days + 150) + except Exception as e: # noqa: BLE001 + 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"} + + enriched = compute_enriched(raw, factors=None, instruments=None) + rows = enriched.filter((pl.col("date") >= start) & (pl.col("date") <= end)).to_dicts() + return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": rows, "source": "live"} + + +@router.get("/minute") +def get_index_minute( + request: Request, + symbol: str = Query(..., description="指数代码, 如 000001.SH"), + trade_date: date | None = Query(None, alias="date", description="交易日期, 默认今天"), +): + """实时读取指数分钟 K。不写入股票分钟 parquet。""" + repo = request.app.state.repo + info = _index_info(repo, symbol) + day = trade_date or date.today() + df = kline_sync.fetch_minute_single(symbol, day) + return { + "symbol": symbol, + "name": info.get("name"), + "index_info": info, + "date": str(day), + "rows": df.to_dicts(), + "source": "live" if not df.is_empty() else "none", + } + + +@router.post("/sync_instruments") +def sync_index_instruments(request: Request): + """同步 CN_Index 指数标的列表。""" + repo = request.app.state.repo + count = index_sync.sync_index_instruments(repo) + return {"status": "ok", "count": count} + + +@router.post("/sync_daily") +def sync_index_daily( + request: Request, + days: int = Query(365, ge=30, le=5000), +): + """同步指数日K到独立 parquet。""" + repo = request.app.state.repo + capset = request.app.state.capabilities + if not capset.has(Cap.KLINE_DAILY_BATCH): + raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)") + end = datetime.now() + start = end - timedelta(days=days) + count = index_sync.sync_index_instruments(repo) + rows = index_sync.sync_and_persist_index_daily(repo, capset, start_date=start, end_date=end) + return {"status": "ok", "index_count": count, "rows_written": rows} diff --git a/serve/backend/app/api/intraday.py b/serve/backend/app/api/intraday.py new file mode 100644 index 0000000..64b5d8a --- /dev/null +++ b/serve/backend/app/api/intraday.py @@ -0,0 +1,212 @@ +"""行情状态 / SSE 推送 API。 + +盘中选股相关端点已迁移至策略页面,此处仅保留全局行情基础设施。 +SSE 推送三种事件 (使用标准 SSE event 字段): + - quotes_updated: 行情数据刷新,前端 invalidate 对应 query + - strategy_alert: 策略监控/告警触发,前端弹通知 + - depth_updated: 五档盘口修正完成,前端刷新连板梯队/看板封单数据 +""" +from __future__ import annotations + +import asyncio +import json +import time + +from fastapi import APIRouter, Query, Request +from sse_starlette.sse import EventSourceResponse + +router = APIRouter(prefix="/api/intraday", tags=["quotes"]) + + +def _get_quote_service(request: Request): + """获取全局 QuoteService。""" + return getattr(request.app.state, "quote_service", None) + + +def _fallback_index_quotes_from_daily(request: Request, symbols: list[str] | None = None) -> list[dict]: + """实时指数缓存为空时,从本地指数日 K 取最近收盘价作为兜底。""" + repo = getattr(request.app.state, "repo", None) + if not repo: + return [] + + params: list[str] = [] + symbol_filter = "" + if symbols: + placeholders = ", ".join("?" for _ in symbols) + symbol_filter = f"WHERE symbol IN ({placeholders})" + params.extend(symbols) + + try: + rows = repo.execute_all( + f""" + WITH ranked AS ( + SELECT symbol, date, close, + row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn + FROM kline_index_daily + {symbol_filter} + ), latest AS ( + SELECT symbol, + max(CASE WHEN rn = 1 THEN date END) AS date, + max(CASE WHEN rn = 1 THEN close END) AS last_price, + max(CASE WHEN rn = 2 THEN close END) AS prev_close + FROM ranked + WHERE rn <= 2 + GROUP BY symbol + ) + SELECT latest.symbol, latest.date, latest.last_price, latest.prev_close + FROM latest + ORDER BY latest.symbol + """, + params, + ) + except Exception: # noqa: BLE001 + return [] + + out: list[dict] = [] + for symbol, dt, last_price, prev_close in rows: + change_amount = None + change_pct = None + if last_price is not None and prev_close not in (None, 0): + change_amount = float(last_price) - float(prev_close) + change_pct = change_amount / float(prev_close) * 100 + out.append({ + "symbol": symbol, + "name": None, + "date": str(dt) if dt else None, + "last_price": float(last_price) if last_price is not None else None, + "close": float(last_price) if last_price is not None else None, + "prev_close": float(prev_close) if prev_close is not None else None, + "change_amount": change_amount, + "change_pct": change_pct, + "source": "index_daily", + }) + return out + + +@router.get("/status") +def status(request: Request): + """行情状态 (来自全局 QuoteService)。""" + qs = _get_quote_service(request) + if qs: + return qs.status() + return {"enabled": False, "running": False, "symbol_count": 0, "index_symbol_count": 0, + "quote_age_ms": None, "is_trading_hours": False, "last_fetch_ms": None} + + +@router.get("/indices") +def index_quotes( + request: Request, + symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"), +): + """返回实时指数行情缓存,不触发 TickFlow 请求。""" + symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None + qs = _get_quote_service(request) + if not qs: + rows = _fallback_index_quotes_from_daily(request, symbol_list) + return {"rows": rows, "count": len(rows), "source": "index_daily"} + df = qs.get_index_quotes(symbol_list) + rows = df.to_dicts() if not df.is_empty() else [] + if not rows: + rows = _fallback_index_quotes_from_daily(request, symbol_list) + return {"rows": rows, "count": len(rows), "source": "index_daily"} + return {"rows": rows, "count": len(rows), "source": "realtime"} + + +@router.get("/stream") +async def quote_stream(request: Request): + """SSE 端点: 行情更新 + 告警推送 + 五档修正。 + + 使用 sse-starlette EventSourceResponse: + - 标准 SSE event 字段,前端按 event name 监听 + - 内置断线检测,客户端断开立即终止 generator + - 内置 ping 心跳,保持连接活跃 + """ + qs = _get_quote_service(request) + + async def event_generator(): + while True: + # 同时等待三类信号: 行情更新 / 告警 / 五档修正 + tasks: dict[str, asyncio.Future] = { + "quote": asyncio.ensure_future( + asyncio.to_thread(qs.wait_for_update, timeout=5.0) if qs else asyncio.sleep(5) + ), + "alert": asyncio.ensure_future( + asyncio.to_thread(qs.wait_for_alert, timeout=5.0) if qs else asyncio.sleep(5) + ), + "depth": asyncio.ensure_future( + asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5) + ), + "review": asyncio.ensure_future( + asyncio.to_thread(qs.wait_for_review, timeout=5.0) if qs else asyncio.sleep(5) + ), + } + + done, pending = await asyncio.wait( + list(tasks.values()), + timeout=30.0, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + + # 先推送告警 (如果有) + if qs: + alerts = qs.pop_alerts() + if alerts: + for chunk_start in range(0, len(alerts), 20): + chunk = alerts[chunk_start:chunk_start + 20] + yield { + "event": "strategy_alert", + "data": json.dumps({ + "ts": int(time.time() * 1000), + "alerts": chunk, + }, ensure_ascii=False), + } + + # 推送复盘进度 (定时复盘流式生成时) — 前端 reviewStore 直接消费 + # 事件已是 recap_market_stream 产出的 JSON 字符串, 逐条转发 + for evt_json in qs.pop_review_events(): + yield { + "event": "review_progress", + "data": evt_json, + } + + # 推送行情更新 (行情信号触发) + if tasks["quote"] in done: + try: + update_result = tasks["quote"].result() + except Exception: # noqa: BLE001 + update_result = False + if update_result: + yield { + "event": "quotes_updated", + "data": json.dumps({ + "ts": int(time.time() * 1000), + "symbol_count": qs._symbol_count if qs else 0, + }), + } + + # 推送五档修正完成 (depth 信号触发) — 前端刷新连板梯队封单数据 + if tasks["depth"] in done: + try: + depth_result = tasks["depth"].result() + except Exception: # noqa: BLE001 + depth_result = False + if depth_result: + yield { + "event": "depth_updated", + "data": json.dumps({ + "ts": int(time.time() * 1000), + }), + } + + return EventSourceResponse(event_generator()) + + +@router.post("/refresh") +def refresh_quotes(request: Request): + """手动刷新一次行情数据。""" + qs = _get_quote_service(request) + if qs: + return qs.refresh() + return {"error": "QuoteService not available"} diff --git a/serve/backend/app/api/kline.py b/serve/backend/app/api/kline.py new file mode 100644 index 0000000..8e0261f --- /dev/null +++ b/serve/backend/app/api/kline.py @@ -0,0 +1,822 @@ +"""K 线 / 同步 API。""" +from __future__ import annotations + +import logging +from datetime import date, timedelta +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query, Request + +from app.indicators.pipeline import compute_enriched, compute_enriched_single +from app.services import kline_sync + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/kline", tags=["kline"]) + + +@router.get("/instruments/search") +def search_instruments( + request: Request, + q: str = Query("", min_length=0, max_length=50, description="搜索关键词"), + limit: int = Query(20, ge=1, le=50), +): + """模糊搜索标的 (代码 / 名称)。从内存 instruments 缓存中查。""" + repo = request.app.state.repo + df = repo.get_instruments() + if df.is_empty() or not q.strip(): + return {"results": []} + + keyword = q.strip().upper() + import polars as pl + + # code/symbol 前缀优先,再 name 包含匹配 + prefix_mask = ( + pl.col("code").str.starts_with(keyword) + | pl.col("symbol").str.to_uppercase().str.starts_with(keyword) + ) + contains_mask = ( + pl.col("code").str.contains(keyword, literal=True) + | pl.col("symbol").str.to_uppercase().str.contains(keyword, literal=True) + | pl.col("name").str.contains(keyword, literal=True) + ) + + # 前缀匹配优先,剩余名额用包含匹配补充 + prefix_hits = df.filter(prefix_mask).head(limit) + if prefix_hits.height >= limit: + matched = prefix_hits + else: + remaining = limit - prefix_hits.height + # 排除已匹配的 symbol + prefix_symbols = set(prefix_hits["symbol"].to_list()) if not prefix_hits.is_empty() else set() + contain_hits = df.filter(contains_mask & ~pl.col("symbol").is_in(prefix_symbols)).head(remaining) + matched = pl.concat([prefix_hits, contain_hits]) if not prefix_hits.is_empty() else contain_hits + rows = matched.select(["symbol", "name", "code"]).to_dicts() + return {"results": rows} + + +@router.post("/instruments/names") +def instruments_names(request: Request, symbols: list[str]): + """批量查股票名称。传入 symbol 列表, 返回 {symbol: name}。""" + if not symbols: + return {"names": {}} + repo = request.app.state.repo + df = repo.get_instruments() + if df.is_empty(): + return {"names": {}} + import polars as pl + matched = df.filter(pl.col("symbol").is_in(symbols)).select(["symbol", "name"]) + names = {row["symbol"]: row["name"] for row in matched.iter_rows(named=True)} + return {"names": names} + + +def _get_stock_info(repo, symbol: str) -> dict: + """从 instruments 视图查标的名称 + 股本。""" + try: + row = repo.execute_one( + "SELECT name, total_shares, float_shares FROM instruments WHERE symbol = ? LIMIT 1", + [symbol], + ) + except Exception: # noqa: BLE001 + return {} + if not row: + return {} + return { + "name": row[0], + "total_shares": row[1], + "float_shares": row[2], + } + + +@router.get("/daily") +def get_daily( + request: Request, + symbol: str = Query(..., description="标的代码,如 000001.SZ"), + days: int = Query(120, ge=10, le=2000), + start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"), + end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"), + ext_columns: Optional[str] = Query(None, description="逗号分隔的 ext 列: config_id.field_name"), +): + """读取本地 enriched 表中某只股票的日 K。 + + - 若 QuoteService 有实时行情, 追加/覆盖今日实时蜡烛 + - Free 用户: 若 enriched 表里没有该股票, 实时拉取 + 本地算 enriched 返回 + - ext_columns: 可选,动态 LEFT JOIN 扩展数据表,结果平铺到 stock_info.ext 下 + (key 为 "{config_id}__{field_name}"),供日K信息条等场景展示自定义字段 + """ + import polars as pl + + repo = request.app.state.repo + end = date.fromisoformat(end_date) if end_date else date.today() + if start_date: + start = date.fromisoformat(start_date) + else: + start = end - timedelta(days=days) + + stock_info = _get_stock_info(repo, symbol) + stock_name = stock_info.get("name") + + # 从 enriched 表读取 (已含前复权 OHLCV + 技术指标 + 信号) + df = repo.get_daily(symbol, start, end) + + if df.is_empty(): + try: + raw = kline_sync.sync_daily_batch([symbol], count=days + 30) + except Exception as 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": []} + # 拉除权因子做前复权 (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) + resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"} + return _attach_ext(resp, repo, symbol, ext_columns) + + rows = df.to_dicts() + + # 追加/覆盖今日实时蜡烛 + rows = _maybe_inject_live_candle(request, symbol, rows) + + resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"} + return _attach_ext(resp, repo, symbol, ext_columns) + + +def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> dict: + """按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。 + + key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。 + JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。 + """ + if not ext_columns or not ext_columns.strip(): + return resp + + specs: list[tuple[str, str]] = [] + for part in ext_columns.split(","): + part = part.strip() + if "." not in part: + continue + config_id, field_name = part.split(".", 1) + config_id, field_name = config_id.strip(), field_name.strip() + if config_id and field_name: + specs.append((config_id, field_name)) + if not specs: + return resp + + import polars as pl + data_dir = repo.store.data_dir + try: + from app.services.ext_data import ExtConfigStore + from app.api.ext_data import _read_ext_dataframe + ext_store = ExtConfigStore(data_dir) + configs = {c.id: c for c in ext_store.load_all()} + except Exception: # noqa: BLE001 + configs = {} + + ext_values: dict = {} + for config_id, field_name in specs: + ext_col_name = f"{config_id}__{field_name}" + value = None + try: + cfg = configs.get(config_id) + if cfg: + ext_df, _ = _read_ext_dataframe(cfg, data_dir) + else: + ext_df = pl.from_arrow( + repo.store.db.query( + f'SELECT symbol, "{field_name}" FROM ext_{config_id}' + ).arrow() + ) + if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns: + # 时序表取最新分区,避免一个 symbol 多行 + row = ( + ext_df + .select(["symbol", field_name]) + .unique(subset=["symbol"], keep="last") + .filter(pl.col("symbol") == symbol) + ) + if not row.is_empty(): + value = row[field_name][0] + except Exception as e: # noqa: BLE001 + logger.debug("kline ext join failed for %s.%s: %s", config_id, field_name, e) + ext_values[ext_col_name] = value + + stock_info = dict(resp.get("stock_info") or {}) + stock_info["ext"] = ext_values + resp["stock_info"] = stock_info + return resp + + +def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]: + """如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。""" + qs = getattr(request.app.state, "quote_service", None) + if not qs: + return rows + + df_today, enriched_date = qs.get_enriched_today() + if df_today.is_empty(): + return rows + + # 非交易日(周末/假日)缓存的行情日期 != 今天,跳过注入避免产生重复蜡烛 + if not enriched_date or enriched_date != date.today(): + return rows + + # 查找该 symbol 的实时 enriched 行 + import polars as pl + try: + q = df_today.filter(pl.col("symbol") == symbol).to_dicts() + if not q: + return rows + q = q[0] + except Exception: # noqa: BLE001 + return rows + + close_price = q.get("close") + if not close_price or close_price <= 0: + return rows + + today_str = str(enriched_date) + + # enriched 行已包含 OHLCV + 全套指标, 直接用它 + # 修复: API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛 + raw_open = q.get("open") + raw_high = q.get("high") + raw_low = q.get("low") + live_row: dict = { + "date": today_str, + "symbol": symbol, + "open": raw_open if raw_open and raw_open > 0 else close_price, + "high": raw_high if raw_high and raw_high > 0 else close_price, + "low": raw_low if raw_low and raw_low > 0 else close_price, + "close": close_price, + "volume": q.get("volume"), + "amount": q.get("amount"), + "change_pct": q.get("change_pct"), + "is_live": True, + } + # 补上 enriched 的技术指标字段 + for key in ("ma5", "ma10", "ma20", "ma30", "ma60", + "macd_dif", "macd_dea", "macd_hist", + "kdj_k", "kdj_d", "kdj_j", + "boll_upper", "boll_lower", + "rsi_6", "rsi_14", "rsi_24", + "atr_14", "vol_ratio_5d"): + if key in q and q[key] is not None: + live_row[key] = q[key] + + # 如果已有今天的 enriched 行, 覆盖; 否则追加 + found = False + for i, r in enumerate(rows): + if str(r.get("date")) == today_str: + r.update(live_row) + found = True + break + + if not found: + rows.append(live_row) + + return rows + + +class DailyBatchRequest: + """批量日K请求。""" + symbols: list[str] + days: int = 12 + + +@router.post("/daily-batch") +def get_daily_batch(request: Request, body: dict): + """批量获取多只股票最近 N 天日K (OHLCV)。 + + 用于自选列表迷你蜡烛图等场景,只返回基础列,不返回全部 enriched 指标。 + """ + symbols = body.get("symbols", []) + days = body.get("days", 12) + if not symbols: + return {"data": {}} + days = max(5, min(60, days)) + + repo = request.app.state.repo + import polars as pl + from datetime import date, timedelta + + end = date.today() + start = end - timedelta(days=days * 2) # 多取一些确保交易日够 + + cols = ["symbol", "date", "open", "high", "low", "close", "volume"] + df = repo.get_daily_batch(symbols, start, end, columns=cols) + + if df.is_empty(): + return {"data": {}} + + # 按 symbol 分组, 每只取最近 N 条 + result: dict[str, list[dict]] = {} + for sym in symbols: + sub = df.filter(pl.col("symbol") == sym).sort("date").tail(days) + if not sub.is_empty(): + result[sym] = sub.to_dicts() + + return {"data": result} + + +@router.get("/minute") +def get_minute( + request: Request, + symbol: str = Query(..., description="标的代码"), + trade_date: date | None = Query(None, alias="date", description="交易日期, 默认最新"), +): + """读取某只股票某天的分钟 K 线。 + + - 本地有完整数据(240条) → 直接返回 + - 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入) + """ + repo = request.app.state.repo + stock_info = _get_stock_info(repo, symbol) + stock_name = stock_info.get("name") + + if trade_date is None: + trade_date = repo.latest_minute_date(symbol) + if trade_date is None: + # 本地无任何分钟K,尝试从 TickFlow 拉取当天 + trade_date = date.today() + df = kline_sync.fetch_minute_single(symbol, trade_date) + return { + "symbol": symbol, "name": stock_name, "stock_info": stock_info, + "date": str(trade_date), "rows": df.to_dicts(), "source": "live", + } + + df = repo.get_minute(symbol, trade_date) + + # 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算 + expected = 240 + today = date.today() + if trade_date == today: + from datetime import datetime as _dt + now = _dt.now() + h, m = now.hour, now.minute + if h < 9 or (h == 9 and m < 30): + expected = 0 # 还没开盘 + elif h < 12 or (h == 12 and m == 0): + expected = (h - 9) * 60 + m - 30 # 9:30 起 + elif h < 13: + expected = 120 # 午休 + elif h < 15: + expected = 120 + (h - 13) * 60 + m + else: + expected = 240 + + is_complete = not df.is_empty() and len(df) >= expected * 0.9 # 允许 10% 容差 + + if is_complete: + return { + "symbol": symbol, "name": stock_name, "stock_info": stock_info, + "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, + "date": str(trade_date), "rows": live_df.to_dicts(), + "source": "live" if not live_df.is_empty() else "none", + } + + +@router.post("/sync") +def sync_symbol( + request: Request, + symbol: str = Query(...), + days: int = Query(250, ge=10, le=2000), +): + """手动触发单股同步(Free 用户在 K 线页用)。""" + repo = request.app.state.repo + capset = request.app.state.capabilities + n = kline_sync.sync_and_persist_daily_batch([symbol], repo, capset, count=days) + return {"symbol": symbol, "rows_written": n} + + +@router.post("/sync_batch") +def sync_batch( + request: Request, + symbols: list[str], + days: int = Query(250, ge=10, le=2000), +): + repo = request.app.state.repo + capset = request.app.state.capabilities + n = kline_sync.sync_and_persist_daily_batch(symbols, repo, capset, count=days) + return {"symbols": symbols, "rows_written": n} + + +@router.post("/refresh_views") +def refresh_views(request: Request): + """刷新所有 DuckDB 视图(解决视图状态不一致问题)。""" + from app.jobs.daily_pipeline import _refresh_views + repo = request.app.state.repo + _refresh_views(repo) + return {"status": "ok"} + + +@router.post("/sync_minute") +async def sync_minute(request: Request): + """手动触发分钟 K 同步(全市场)。返回 pipeline job_id 可轮询进度。""" + import asyncio + + from app.services.pipeline_jobs import job_store + from app.api.data import invalidate_storage_cache + from app.services.preferences import get_minute_sync_days + from app.tickflow.capabilities import Cap + from app.tickflow.pools import get_pool + + repo = request.app.state.repo + capset = request.app.state.capabilities + + if not capset.has(Cap.KLINE_MINUTE_BATCH): + raise HTTPException(status_code=403, detail="需要 Pro+ 权限") + + job_id = job_store.create() + existing = job_store.get(job_id) + if existing and existing["status"] == "running": + return {"status": "reused", "job_id": job_id} + + async def task() -> None: + job_store.start(job_id) + loop = asyncio.get_event_loop() + + def progress(stage: str, pct: int, msg: str) -> None: + job_store.progress(job_id, stage, pct, msg) + + try: + progress("sync_minute", 5, "解析标的池…") + universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A"))) + # 补充 instruments 全量标的,覆盖北交所、新股等 + inst_path = repo.store.data_dir / "instruments" / "instruments.parquet" + if inst_path.exists(): + try: + import polars as pl + inst = pl.read_parquet(inst_path, columns=["symbol"]) + universe = sorted(set(universe) | set(inst["symbol"].to_list())) + except Exception: # noqa: BLE001 + pass + progress("sync_minute", 10, f"标的池 {len(universe)} 只") + + days = get_minute_sync_days() + + def _run(): + return kline_sync.sync_and_persist_minute(universe, repo, capset, days=days) + + written = await loop.run_in_executor(_long_task_executor, _run) + + # 刷新视图 + from app.jobs.daily_pipeline import _refresh_single_view + _refresh_single_view(repo, "kline_minute") + + progress("done", 100, f"分钟 K 同步完成,{written} 行") + job_store.succeed(job_id, {"minute_rows": written, "universe_size": len(universe)}) + invalidate_storage_cache() + except Exception as e: # noqa: BLE001 + job_store.fail(job_id, str(e)) + invalidate_storage_cache() + + asyncio.create_task(task()) + return {"status": "started", "job_id": job_id} + + +@router.post("/extend_history") +async def extend_history(request: Request): + """向前扩展历史日K数据 — 独立于盘后管道。 + + body: { "value": int, "unit": "day"|"month"|"year" } + 返回 job_id,可轮询 /api/pipeline/jobs 查看进度。 + """ + import asyncio + import traceback as _tb + try: + body = await request.json() + value = body.get("value") + unit = body.get("unit", "month") + if not value or value <= 0: + raise HTTPException(status_code=400, detail="value 必须为正整数") + if unit not in ("day", "month", "year"): + raise HTTPException(status_code=400, detail="unit 只支持 day/month/year") + + repo = request.app.state.repo + capset = request.app.state.capabilities + + from app.tickflow.capabilities import Cap + if not capset.has(Cap.KLINE_DAILY_BATCH): + raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)") + + from app.services.extend_history import run_extend_history + from app.services.pipeline_jobs import job_store + from app.api.data import invalidate_storage_cache + + job_id = job_store.create() + existing = job_store.get(job_id) + if existing and existing["status"] == "running": + return {"status": "reused", "job_id": job_id} + + async def task() -> None: + job_store.start(job_id) + loop = asyncio.get_event_loop() + + def progress(stage: str, pct: int, msg: str, + stage_pct: int | None = None, skip_log: bool = False) -> None: + job_store.progress(job_id, stage, pct, msg, + stage_pct=stage_pct, skip_log=skip_log) + + try: + result = await loop.run_in_executor( + _long_task_executor, + lambda: run_extend_history(repo, capset, value, unit, on_progress=progress), + ) + if "error" in result: + job_store.fail(job_id, result["error"]) + else: + job_store.succeed(job_id, result) + invalidate_storage_cache() + except Exception as e: + logger.exception("extend_history failed: job_id=%s", job_id) + job_store.fail(job_id, str(e)) + invalidate_storage_cache() + + asyncio.create_task(task()) + return {"status": "started", "job_id": job_id} + except HTTPException: + raise + except Exception as e: + logger.error("extend_history error: %s\n%s", e, _tb.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/rebuild_enriched") +async def rebuild_enriched(request: Request): + """全量重算 enriched 表 — 不获取任何数据,仅基于已有 kline_daily + adj_factor 重算复权+指标。 + + 返回 job_id,可轮询 /api/pipeline/jobs 查看进度。 + """ + import asyncio + try: + repo = request.app.state.repo + + from app.services.pipeline_jobs import job_store + from app.api.data import invalidate_storage_cache + + job_id = job_store.create() + existing = job_store.get(job_id) + if existing and existing["status"] == "running": + return {"status": "reused", "job_id": job_id} + + async def task() -> None: + job_store.start(job_id) + loop = asyncio.get_event_loop() + + def progress(stage: str, pct: int, msg: str, + stage_pct: int | None = None, skip_log: bool = False) -> None: + job_store.progress(job_id, stage, pct, msg, + stage_pct=stage_pct, skip_log=skip_log) + + try: + progress("rebuild_enriched", 10, "全量计算 enriched…") + from app.indicators.pipeline import run_pipeline + + def _batch_progress(cur: int, tot: int) -> None: + pct = 10 + int(85 * cur / tot) + progress("rebuild_enriched", pct, + f"计算指标 批次 {cur}/{tot}", + stage_pct=int(100 * cur / tot), skip_log=True) + + written = await loop.run_in_executor( + _long_task_executor, + lambda: run_pipeline(on_batch_done=_batch_progress), + ) + + enriched_dir = repo.store.data_dir / "kline_daily_enriched" + enriched_days = len(list(enriched_dir.glob("date=*"))) if enriched_dir.exists() else 0 + + # 刷新视图 + d = repo.store.data_dir.as_posix() + for view_name, glob in [ + ("kline_enriched", f"{d}/kline_daily_enriched/**/*.parquet"), + ]: + try: + repo.db.execute( + f"CREATE OR REPLACE VIEW {view_name} AS " + f"SELECT * FROM read_parquet('{glob}', union_by_name=true)" + ) + except Exception: + pass + + progress("rebuild_enriched", 100, f"完成,覆盖 {enriched_days} 天") + job_store.succeed(job_id, { + "enriched_days": enriched_days, + "enriched_rows": written, + }) + invalidate_storage_cache() + except Exception as e: + logger.exception("rebuild_enriched failed: job_id=%s", job_id) + job_store.fail(job_id, str(e)) + invalidate_storage_cache() + + asyncio.create_task(task()) + return {"status": "started", "job_id": job_id} + except Exception as e: + import traceback as _tb + logger.error("rebuild_enriched error: %s\n%s", e, _tb.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) from e + + +# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理) +import concurrent.futures as _cf +_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task") + + +@router.post("/extend_minute_history") +async def extend_minute_history(request: Request): + """向前扩展分钟K历史数据 — 仅拉数据,不做任何后续处理。 + + body: { "value": int, "unit": "day"|"month" } + - day 单位:1~15 天(所有有分钟K权限的套餐可用) + - month 单位:1~6 月(每月按 30 天计,即最多 180 天)—— 仅 Expert+ 可用 + 返回 job_id,可轮询 /api/pipeline/jobs 查看进度。 + """ + import asyncio + import traceback as _tb + try: + body = await request.json() + value = body.get("value") + unit = body.get("unit", "day") + if not value or value <= 0: + raise HTTPException(status_code=400, detail="value 必须为正整数") + if unit not in ("day", "month"): + raise HTTPException(status_code=400, detail="unit 只支持 day/month") + + repo = request.app.state.repo + capset = request.app.state.capabilities + + from app.tickflow.capabilities import Cap + if not capset.has(Cap.KLINE_MINUTE_BATCH): + raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch minute K-line)") + + # month 单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用 day + if unit == "month": + from app.tickflow.policy import tier_label + base_tier = tier_label().split()[0].split("+")[0].strip().lower() + if base_tier != "expert": + raise HTTPException( + status_code=403, + detail="按月扩展分钟K历史需要 Expert 及以上套餐", + ) + + # 计算天数上限:day 最多 15 天;month 最多 6 月(180 天) + from datetime import timedelta + if unit == "month": + total_days = min(value * 30, 180) + else: + total_days = min(value, 15) + + if total_days <= 0: + raise HTTPException(status_code=400, detail="扩展范围无效") + + from app.services.pipeline_jobs import job_store + from app.api.data import invalidate_storage_cache + + job_id = job_store.create() + existing = job_store.get(job_id) + if existing and existing["status"] == "running": + return {"status": "reused", "job_id": job_id} + + async def task() -> None: + job_store.start(job_id) + loop = asyncio.get_event_loop() + + def progress(stage: str, pct: int, msg: str, + stage_pct: int | None = None, skip_log: bool = False) -> None: + job_store.progress(job_id, stage, pct, msg, + stage_pct=stage_pct, skip_log=skip_log) + + try: + # 获取当前最早日期 + earliest = repo.earliest_minute_date() + if not earliest: + # 本地无分钟K数据 → 以今天为基准往前获取 + from datetime import date as _date + latest = _date.today() + else: + latest = earliest + + new_start = latest - timedelta(days=total_days) + if new_start >= latest: + job_store.fail(job_id, "扩展范围无效") + invalidate_storage_cache() + return + + start_str = new_start.strftime("%Y-%m-%d") + end_str = latest.strftime("%Y-%m-%d") + + progress("extend_minute", 5, "解析标的池…") + universe = _resolve_minute_universe(capset, repo) + progress("extend_minute", 8, f"标的池: {len(universe)} 只") + + from app.tickflow.capabilities import Cap + + lim = capset.limits(Cap.KLINE_MINUTE_BATCH) + batch_size = lim.batch if lim and lim.batch else 100 + rpm = lim.rpm if lim else 30 + + def _run(): + """全部在 executor 线程里完成,避免阻塞事件循环。""" + from app.services.kline_sync import sync_minute_batch + from datetime import datetime as _dt + + def _chunk(cur: int, tot: int) -> None: + progress("extend_minute", 8 + int(85 * cur / tot), + f"分钟K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True) + + df = sync_minute_batch( + universe, + start_time=_dt.combine(new_start, _dt.min.time()), + end_time=_dt.combine(latest, _dt.min.time()), + batch_size=batch_size, rpm=rpm, + on_chunk_done=_chunk, + ) + + written = 0 + day_count = 0 + if not df.is_empty(): + import polars as pl + df = df.with_columns(pl.col("datetime").dt.date().alias("_trade_date")) + for day_df in df.partition_by("_trade_date"): + trade_date = day_df["_trade_date"][0] + out = repo.store.data_dir / "kline_minute" / f"date={trade_date}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + if out.exists(): + existing_df = pl.read_parquet(out) + if "datetime" in existing_df.columns: + existing_df = existing_df.filter(pl.col("datetime").is_not_null()) + day_df = pl.concat([existing_df, day_df.drop("_trade_date")]).unique( + subset=["symbol", "datetime"], keep="last", + ) + else: + day_df = day_df.drop("_trade_date") + day_df = day_df.sort("symbol", "datetime") + day_df.write_parquet(out) + written += day_df.height + day_count += 1 + + # 刷新视图 + d = repo.store.data_dir.as_posix() + try: + repo.db.execute( + f"CREATE OR REPLACE VIEW kline_minute AS " + f"SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)" + ) + except Exception: + pass + return written, day_count + + progress("extend_minute", 10, f"获取分钟K [{start_str} ~ {end_str}]…") + written, day_count = await loop.run_in_executor(_long_task_executor, _run) + + progress("extend_minute", 95, f"分钟K 完成,{day_count} 天") + job_store.succeed(job_id, { + "minute_days": day_count, + "universe_size": len(universe), + "earliest_before": (earliest or latest).isoformat(), + "earliest_after": new_start.isoformat(), + }) + invalidate_storage_cache() + except Exception as e: + logger.exception("extend_minute_history failed: job_id=%s", job_id) + job_store.fail(job_id, str(e)) + invalidate_storage_cache() + + asyncio.create_task(task()) + return {"status": "started", "job_id": job_id} + except HTTPException: + raise + except Exception as e: + logger.error("extend_minute_history error: %s\n%s", e, _tb.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) from e + + +def _resolve_minute_universe(capset, repo) -> list[str]: + """分钟K标的池解析。""" + from app.tickflow.capabilities import Cap + if capset.has(Cap.KLINE_MINUTE_BATCH): + try: + from app.tickflow.pools import get_pool + all_a = get_pool("CN_Equity_A", refresh=True) + if all_a: + return sorted(all_a) + except Exception: + pass + return [] diff --git a/serve/backend/app/api/market_recap.py b/serve/backend/app/api/market_recap.py new file mode 100644 index 0000000..7215d23 --- /dev/null +++ b/serve/backend/app/api/market_recap.py @@ -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} diff --git a/serve/backend/app/api/monitor_rules.py b/serve/backend/app/api/monitor_rules.py new file mode 100644 index 0000000..b5e332c --- /dev/null +++ b/serve/backend/app/api/monitor_rules.py @@ -0,0 +1,499 @@ +"""监控规则 API 路由 — HTTP 请求 → 调用 monitor_rules 模块 → 同步引擎内存态。 + +只做胶水: 校验 → 持久化 → 失效引擎内存态。不含评估逻辑。 +""" +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from app.strategy import monitor_rules + +router = APIRouter(prefix="/api/monitor-rules", tags=["monitor-rules"]) + + +def _data_dir(request: Request) -> Path: + return request.app.state.repo.store.data_dir + + +def _sync_engine(request: Request) -> None: + """保存/删除后,把最新规则集 reload 到引擎内存态。""" + engine = getattr(request.app.state, "monitor_engine", None) + if engine is not None: + rules = monitor_rules.load_all(_data_dir(request)) + engine.set_rules(rules) + + +# ── Pydantic 模型 ─────────────────────────────────────── +class ConditionModel(BaseModel): + field: str + op: str # truth | > >= < <= == != + value: float | None = None # op 非 truth 时必填 + + +class RuleModel(BaseModel): + id: str + name: str + enabled: bool = True + type: str # strategy | signal | price | market + scope: str = "symbols" # symbols | all | sector + symbols: list[str] = [] + sector: str | None = None + strategy_id: str | None = None + direction: str = "entry" # entry | exit | both + conditions: list[ConditionModel] = [] + logic: str = "and" # and | or + cooldown_seconds: int = 3600 + severity: str = "info" # info | warn | critical + webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定) + webhook_enabled: bool = False + message: str = "" + # ladder 专属 (连板梯队封单监控) + metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元) + threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元) + + +# ── 字段选项 ───────────────────────────────────────────── +@router.get("/options") +def get_options(request: Request): + """返回可选字段、信号列、运算符、枚举,供前端表单使用。""" + from app.indicators.pipeline import ENRICHED_COLUMNS + from app.strategy.custom_signals import ALLOWED_FIELDS, load_all as load_csg + + # 阈值字段 (带中文标签) + threshold_fields = [ + {"key": f, "label": ENRICHED_COLUMNS.get(f, f)} + for f in sorted(ALLOWED_FIELDS) + ] + # 内置信号列 (布尔, 用于 op=truth) + builtin_signals = [ + {"key": k, "label": v} + for k, v in ENRICHED_COLUMNS.items() + if k.startswith("signal_") + ] + # 自定义信号列 (csg_) + custom_sigs = [] + try: + for cs in load_csg(_data_dir(request)): + if cs.get("enabled") is not False: + custom_sigs.append({ + "key": f"csg_{cs['id']}", + "label": cs.get("name", cs["id"]), + }) + except Exception: + pass + + return { + "threshold_fields": threshold_fields, + "builtin_signals": builtin_signals, + "custom_signals": custom_sigs, + "operators": [">", ">=", "<", "<=", "==", "!="], + "types": [ + {"key": "signal", "label": "个股信号"}, + {"key": "price", "label": "价格/涨跌"}, + {"key": "market", "label": "市场异动"}, + {"key": "strategy", "label": "策略监控"}, + ], + "scopes": [ + {"key": "symbols", "label": "指定股票"}, + {"key": "all", "label": "全市场"}, + {"key": "sector", "label": "板块"}, + ], + "logics": [ + {"key": "and", "label": "全部满足 (AND)"}, + {"key": "or", "label": "任一满足 (OR)"}, + ], + "severities": [ + {"key": "info", "label": "普通"}, + {"key": "warn", "label": "警告"}, + {"key": "critical", "label": "重要"}, + ], + "directions": [ + {"key": "entry", "label": "买入"}, + {"key": "exit", "label": "卖出"}, + {"key": "both", "label": "买卖都报"}, + ], + } + + +# ── 列表 ─────────────────────────────────────────────── +@router.get("") +def list_rules(request: Request): + rules = monitor_rules.load_all(_data_dir(request)) + # 按 created_at 倒序 + rules.sort(key=lambda r: r.get("created_at", ""), reverse=True) + return {"rules": rules} + + +# ── 新建 / 更新 ──────────────────────────────────────── +@router.post("") +def save_rule(req: RuleModel, request: Request): + rule = monitor_rules.normalize(req.model_dump()) + # 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。 + # 无能力时拒绝创建, 避免规则存了却永远无法触发。 + if rule.get("type") == "ladder": + from app.tickflow.capabilities import Cap + capset = getattr(request.app.state, "capabilities", None) + if capset is None or not capset.has(Cap.DEPTH5_BATCH): + raise HTTPException( + status_code=403, + detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置", + ) + # 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动) + existing = monitor_rules.load_one(_data_dir(request), rule["id"]) + if existing and existing.get("created_at"): + rule["created_at"] = existing["created_at"] + try: + monitor_rules.validate(rule) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + monitor_rules.save_one(_data_dir(request), rule) + _sync_engine(request) + return {"ok": True, "rule": rule} + + +# ── 删除 ─────────────────────────────────────────────── +@router.delete("/{rule_id}") +def delete_rule(rule_id: str, request: Request): + if not monitor_rules.ID_RE.match(rule_id): + raise HTTPException(status_code=400, detail="规则 id 非法") + deleted = monitor_rules.delete_one(_data_dir(request), rule_id) + if not deleted: + raise HTTPException(status_code=404, detail="规则不存在") + _sync_engine(request) + return {"ok": True} + + +# ── 演示数据生成 (仅 Dev 页用) ───────────────────────── + +import time as _time +from datetime import datetime, timezone + + +def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str], + conditions: list[dict], logic: str = "or", cooldown: int = 3600, + severity: str = "info", message: str = "", + strategy_id: str | None = None, direction: str = "entry") -> dict: + rule = monitor_rules.normalize({ + "id": rule_id, + "name": name, + "type": rtype, + "scope": scope, + "symbols": symbols, + "conditions": conditions, + "logic": logic, + "cooldown_seconds": cooldown, + "severity": severity, + "message": message, + "enabled": True, + }) + if rtype == "strategy": + rule["strategy_id"] = strategy_id + rule["direction"] = direction + return rule + + +_DEMO_RULES_TEMPLATE = [ + ("个股信号 · 茅台放量突破", "signal", "symbols", ["600519.SH"], + [{"field": "signal_volume_surge", "op": "truth"}, + {"field": "signal_n_day_high", "op": "truth"}], "or", "info"), + ("个股信号 · 宁德金叉", "signal", "symbols", ["300750.SZ"], + [{"field": "signal_ma_golden_5_20", "op": "truth"}], "or", "info"), + ("价格 · 平安跌幅监控", "price", "symbols", ["000001.SZ"], + [{"field": "change_pct", "op": "<", "value": -0.03}], "or", "warn", "warn"), + ("价格 · 比亚迪RSI超卖", "price", "symbols", ["002594.SZ"], + [{"field": "rsi_14", "op": "<", "value": 30}], "and", "warn", "warn"), + ("市场异动 · 全市场涨停", "market", "all", [], + [{"field": "signal_limit_up", "op": "truth"}], "or", "critical", "critical"), + ("市场异动 · 全市场炸板", "market", "all", [], + [{"field": "signal_broken_limit_up", "op": "truth"}], "or", "warn", "warn"), + ("市场异动 · 跌幅超5%", "market", "all", [], + [{"field": "change_pct", "op": "<", "value": -0.05}], "or", "warn", "warn"), + ("个股信号 · 茅台跌破MA20", "signal", "symbols", ["600519.SH"], + [{"field": "signal_ma20_breakdown", "op": "truth"}], "or", "info"), +] + +# 策略类型单独声明 (格式不同: 含 strategy_id + direction) +_DEMO_STRATEGY_RULES: list[dict] = [ + {"name": "策略监控 · 趋势突破", "strategy_id": "trend_breakout", "direction": "entry"}, + {"name": "策略监控 · MACD金叉", "strategy_id": "macd_golden", "direction": "both"}, +] + + +@router.post("/seed") +def seed_demo_rules(request: Request): + """生成演示监控规则 (Dev 页用)。覆盖 signal/price/market/strategy 四类。""" + ts = int(_time.time() * 1000) + created = [] + i = 0 + for (name, rtype, scope, symbols, conditions, logic, severity, sev) in _DEMO_RULES_TEMPLATE: + rule_id = f"demo_{ts}_{i}" + rule = _demo_rule(rule_id, name, rtype, scope, symbols, conditions, logic, 3600, sev) + monitor_rules.save_one(_data_dir(request), rule) + created.append(rule_id) + i += 1 + # 策略类型规则 + for sr in _DEMO_STRATEGY_RULES: + rule_id = f"demo_{ts}_{i}" + rule = _demo_rule( + rule_id, sr["name"], "strategy", "all", [], [], "and", 3600, "info", + strategy_id=sr["strategy_id"], direction=sr.get("direction", "entry"), + ) + monitor_rules.save_one(_data_dir(request), rule) + created.append(rule_id) + i += 1 + _sync_engine(request) + return {"ok": True, "generated": len(created), "ids": created} + + +# ── 封单监控模拟触发 (Dev 调试用) ───────────────────── +@router.post("/test-ladder") +def test_ladder(request: Request): + """模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。 + + 用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame, + 跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。 + """ + import polars as pl + + repo = request.app.state.repo + depth_svc = getattr(request.app.state, "depth_service", None) + engine = getattr(request.app.state, "monitor_engine", None) + + if not depth_svc: + raise HTTPException(status_code=503, detail="depth 服务未初始化") + if not engine or not engine.has_rule_type("ladder"): + raise HTTPException(status_code=400, detail="无 ladder 类型监控规则") + + # 最新交易日 + latest = repo.enriched_latest_date() + if not latest: + raise HTTPException(status_code=400, detail="无 enriched 数据") + + # 取涨停+跌停封单 {symbol: vol} + sealed: dict[str, int] = {} + for is_down in (False, True): + m = depth_svc.get_sealed_map(latest, is_down=is_down) + for sym, info in m.items(): + vol = (info or {}).get("vol") + if vol and vol > 0: + sealed[sym] = vol + + if not sealed: + raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)") + + # 取这些 symbol 的 close (算封单额用) + enriched_today, _ = repo.get_enriched_latest() + cols = ["symbol", "close", "change_pct"] + avail = [c for c in cols if c in enriched_today.columns] + mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys()))) + + # 注入 _sealed_vol + sealed_df = pl.DataFrame({ + "symbol": list(sealed.keys()), + "_sealed_vol": list(sealed.values()), + }) + mock = mock.join(sealed_df, on="symbol", how="inner") + + # 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire) + ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)] + all_events = [] + not_triggered = [] + + for rule in ladder_rules: + syms = rule.get("symbols", []) + sym = syms[0] if syms else None + metric = rule.get("metric", "sealed_vol") + thr = rule.get("threshold", 0) + direction = rule.get("direction", "up") + warn_label = "炸板预警" if direction == "up" else "翘板预警" + + # 取该 symbol 的封单数据 + cur_vol = sealed.get(sym) if sym else None + row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear() + cur_close = row["close"][0] if len(row) and "close" in row.columns else None + cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None + cur_val = cur_amt if metric == "sealed_amount" else cur_vol + + # 条件判断: 封单 > 0 且 比较值 <= 阈值 + if cur_val is not None and cur_val > 0 and cur_val <= thr: + if metric == "sealed_amount": + sv_text = f"{cur_val / 1e4:.0f}万元" + th_text = f"{thr / 1e4:.0f}万元" + else: + sv_text = f"{cur_val:,.0f} 手" + th_text = f"{thr:,.0f} 手" + all_events.append({ + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "symbol": sym, + "name": sym, + "type": warn_label, + "message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}", + "severity": rule.get("severity", "warn"), + "sealed_value": cur_val, + "sealed_metric": metric, + "current_sealed_vol": cur_vol, + "current_sealed_amount": cur_amt, + }) + else: + reason = "封单数据缺失" if cur_val is None else ( + f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0" + ) + not_triggered.append({ + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "symbol": sym, + "metric": metric, + "threshold": thr, + "current_value": cur_val, + "current_sealed_vol": cur_vol, + "current_sealed_amount": cur_amt, + "reason": reason, + }) + + return { + "ok": True, + "as_of": str(latest), + "sealed_count": len(sealed), + "triggered": all_events, + "not_triggered": not_triggered, + } + + +@router.post("/trigger-ladder") +def trigger_ladder(request: Request): + """真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。 + + 与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE, + 让用户看到真实的预警通知。绕过 cooldown 强制触发。 + """ + import time + from app.services import alert_store + + repo = request.app.state.repo + depth_svc = getattr(request.app.state, "depth_service", None) + engine = getattr(request.app.state, "monitor_engine", None) + quote_svc = getattr(request.app.state, "quote_service", None) + + if not depth_svc: + raise HTTPException(status_code=503, detail="depth 服务未初始化") + if not engine or not engine.has_rule_type("ladder"): + raise HTTPException(status_code=400, detail="无 ladder 类型监控规则") + + latest = repo.enriched_latest_date() + if not latest: + raise HTTPException(status_code=400, detail="无 enriched 数据") + + # 取封单 + sealed: dict[str, int] = {} + for is_down in (False, True): + m = depth_svc.get_sealed_map(latest, is_down=is_down) + for sym, info in m.items(): + vol = (info or {}).get("vol") + if vol and vol > 0: + sealed[sym] = vol + if not sealed: + raise HTTPException(status_code=400, detail="无封单数据") + + # 构造真实 rule_events (与 _evaluate_ladder 产出格式一致) + import polars as pl + enriched_today, _ = repo.get_enriched_latest() + cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns] + mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys()))) + sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())}) + mock = mock.join(sealed_df, on="symbol", how="inner") + + now = time.time() + rule_events: list[dict] = [] + name_map = {} + try: + inst = repo.get_instruments() + if not inst.is_empty() and "name" in inst.columns: + name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")} + except Exception: # noqa: BLE001 + pass + + for rule in engine.rules.values(): + if rule.get("type") != "ladder" or not rule.get("enabled", True): + continue + sym = rule.get("symbols", [""])[0] if rule.get("symbols") else "" + metric = rule.get("metric", "sealed_vol") + thr = rule.get("threshold", 0) + direction = rule.get("direction", "up") + warn_label = "炸板预警" if direction == "up" else "翘板预警" + + row = mock.filter(pl.col("symbol") == sym) + if row.is_empty(): + continue + cur_vol = row["_sealed_vol"][0] + close_v = row["close"][0] if "close" in row.columns else None + cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol + if not cur_val or cur_val <= 0 or cur_val > thr: + continue # 不满足条件, 跳过 + + if metric == "sealed_amount": + sv_text = f"{cur_val / 1e4:.0f}万元" + th_text = f"{thr / 1e4:.0f}万元" + else: + sv_text = f"{cur_val:,.0f} 手" + th_text = f"{thr:,.0f} 手" + + rule_events.append({ + "ts": int(now * 1000), + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "source": "ladder", + "type": warn_label, + "symbol": sym, + "name": name_map.get(sym, sym), + "message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}", + "price": close_v, + "change_pct": row["change_pct"][0] if "change_pct" in row.columns else None, + "signals": [], + "severity": rule.get("severity", "warn"), + "conditions": [], + "logic": "and", + "sealed_value": cur_val, + "sealed_metric": metric, + }) + + if not rule_events: + raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)") + + # 1. 落盘到 alerts.jsonl + try: + alert_store.append_many(repo.store.data_dir, rule_events) + except Exception as e: # noqa: BLE001 + pass # 落盘失败不阻断推送 + + # 2. SSE 推送 (入 pending_alerts 队列) + if quote_svc: + sse_alerts = [{ + "source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"], + "strategy_id": None, "symbol": ev["symbol"], "name": ev["name"], + "message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"], + "signals": ev["signals"], "severity": ev["severity"], + "conditions": ev["conditions"], "logic": ev["logic"], + } for ev in rule_events] + try: + with quote_svc._lock: + quote_svc._pending_alerts.extend(sse_alerts) + quote_svc._alert_event.set() + except Exception: # noqa: BLE001 + pass + + # 3. 飞书推送 + if quote_svc: + try: + quote_svc._maybe_send_webhook(rule_events, engine) + except Exception: # noqa: BLE001 + pass + + return { + "ok": True, + "triggered": len(rule_events), + "events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events], + } diff --git a/serve/backend/app/api/overview.py b/serve/backend/app/api/overview.py new file mode 100644 index 0000000..3bd7ede --- /dev/null +++ b/serve/backend/app/api/overview.py @@ -0,0 +1,372 @@ +"""市场总览聚合 API。""" +from __future__ import annotations + +import math +import re +import time +from datetime import date +from typing import Any + +import polars as pl +from fastapi import APIRouter, Request + +from app.services.ext_data import ExtConfig, ExtConfigStore +from app.services.screener import ScreenerService + +router = APIRouter(prefix="/api/overview", tags=["overview"]) + +_CACHE_TTL = 5.0 +_cache: dict[str, Any] | None = None +_cache_key: str | None = None +_cache_ts: float = 0.0 + + +def invalidate_overview_cache() -> None: + """清空总览聚合结果缓存。 + + 清除数据后调用, 避免看板在 TTL 窗口内继续返回旧的聚合结果。 + """ + global _cache, _cache_key, _cache_ts + _cache = None + _cache_key = None + _cache_ts = 0.0 + + +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 _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], request: Request, 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(request.app.state.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(request.app.state.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} + + +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))) + + +def _quote_status(request: Request) -> dict: + qs = getattr(request.app.state, "quote_service", None) + if not qs: + return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False} + return qs.status() + + +def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]: + qs = getattr(request.app.state, "quote_service", None) + rows: list[dict] = [] + if qs and as_of is None: + df = qs.get_index_quotes(list(CORE_INDEX_SYMBOLS)) + if not df.is_empty(): + rows = df.to_dicts() + + if not rows: + repo = getattr(request.app.state, "repo", None) + if repo: + placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS) + try: + db_rows = repo.execute_all( + f""" + WITH ranked AS ( + SELECT symbol, date, close, + row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn + FROM kline_index_daily + WHERE symbol IN ({placeholders}) + AND (? IS NULL OR date <= ?) + ), latest AS ( + SELECT symbol, + max(CASE WHEN rn = 1 THEN date END) AS date, + max(CASE WHEN rn = 1 THEN close END) AS last_price, + max(CASE WHEN rn = 2 THEN close END) AS prev_close + FROM ranked + WHERE rn <= 2 + GROUP BY symbol + ) + SELECT symbol, date, last_price, prev_close + FROM latest + """, + [*CORE_INDEX_SYMBOLS, as_of, as_of], + ) + 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 _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_overview(request: Request, as_of: date | None = None) -> dict: + """装配市场总览(委托给 services.market_overview_builder,保持行为一致)。 + + 逻辑已抽离至 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") +def market_overview(request: Request, as_of: date | None = None): + """总览页单次请求聚合数据,避免前端拉全市场明细后再计算。""" + global _cache, _cache_key, _cache_ts + now = time.time() + cache_key = as_of.isoformat() if as_of else "latest" + if _cache is not None and _cache_key == cache_key and (now - _cache_ts) < _CACHE_TTL: + return _cache + data = _build_overview(request, as_of) + _cache = data + _cache_key = cache_key + _cache_ts = now + return data diff --git a/serve/backend/app/api/pipeline.py b/serve/backend/app/api/pipeline.py new file mode 100644 index 0000000..11f522b --- /dev/null +++ b/serve/backend/app/api/pipeline.py @@ -0,0 +1,107 @@ +"""盘后管道 API — 异步触发 + 进度跟踪。""" +from __future__ import annotations + +import asyncio +import concurrent.futures as _cf +import logging + +from fastapi import APIRouter, HTTPException, Request + +from app.jobs import daily_pipeline +from app.services.pipeline_jobs import job_store +from app.api.data import invalidate_storage_cache + +# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理) +_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task") + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/pipeline", tags=["pipeline"]) + + +@router.post("/run") +async def run_now(request: Request) -> dict: + """异步触发盘后管道,立即返回 job_id。客户端轮询 /jobs/{id} 拿进度。 + + 若已有任务在跑,**返回该任务 id 而不是开新任务**(防止并发拉数据撞限流)。 + 但如果该任务已运行超过 10 分钟 (可能因 reload 卡死), 强制标记为失败后重新创建。 + """ + repo = request.app.state.repo + capset = request.app.state.capabilities + + # 检测卡死的 running job (如 reload 后孤儿 task) + existing_id = job_store.active_id() + if existing_id: + existing = job_store.get(existing_id) + if existing and existing["status"] == "running": + from datetime import datetime, timezone + started = existing.get("started_at") + if started: + try: + start_dt = datetime.fromisoformat(started.replace("Z", "+00:00")) + elapsed = (datetime.now(timezone.utc) - start_dt).total_seconds() + if elapsed > 600: # 超过 10 分钟视为卡死 + logger.warning("强制取消卡死 job %s (已运行 %.0fs)", existing_id, elapsed) + job_store.fail(existing_id, "超时自动取消 (疑似 reload 后孤儿 task)") + except Exception: + pass + + job_id = job_store.create() + + # 如果是复用的 active job,直接返回(不重启) + existing = job_store.get(job_id) + if existing and existing["status"] == "running": + return {"job_id": job_id, "reused": True} + + # 在 executor 里跑同步任务(pipeline 内部都是阻塞 IO + CPU) + async def task() -> None: + job_store.start(job_id) + loop = asyncio.get_event_loop() + + def progress(stage: str, pct: int, msg: str, stage_pct: int | None = None, + skip_log: bool = False) -> None: + job_store.progress(job_id, stage, pct, msg, stage_pct=stage_pct, skip_log=skip_log) + + try: + result = await loop.run_in_executor( + _long_task_executor, + lambda: daily_pipeline.run_now(repo, capset, on_progress=progress), + ) + job_store.succeed(job_id, result) + invalidate_storage_cache() + repo.refresh_cache() # 刷新 Polars 缓存 + except Exception as e: # noqa: BLE001 + logger.exception("pipeline failed") + job_store.fail(job_id, str(e)) + invalidate_storage_cache() + + asyncio.create_task(task()) + return {"job_id": job_id, "reused": False} + + +@router.get("/jobs/{job_id}") +def get_job(job_id: str) -> dict: + j = job_store.get(job_id) + if not j: + raise HTTPException(status_code=404, detail="job not found") + return j + + +@router.post("/jobs/{job_id}/cancel") +def cancel_job(job_id: str) -> dict: + """手动取消一个 running 的 job。""" + j = job_store.get(job_id) + if not j: + raise HTTPException(status_code=404, detail="job not found") + if j["status"] not in ("running", "pending"): + raise HTTPException(status_code=400, detail=f"job status is {j['status']}, cannot cancel") + job_store.fail(job_id, "用户手动取消") + return {"cancelled": job_id} + + +@router.get("/jobs") +def list_jobs(limit: int = 20) -> dict: + return { + "active_id": job_store.active_id(), + "jobs": job_store.list_recent(limit=limit), + } diff --git a/serve/backend/app/api/routes.py b/serve/backend/app/api/routes.py new file mode 100644 index 0000000..d0df268 --- /dev/null +++ b/serve/backend/app/api/routes.py @@ -0,0 +1,40 @@ +"""API 路由 — Phase 0 仅 /health 与 /api/capabilities。""" +from __future__ import annotations + +from fastapi import APIRouter + +from app import __version__ +from app.tickflow import client as tf_client +from app.tickflow.policy import detect_capabilities, tier_label + +router = APIRouter() + + +@router.get("/health") +def health() -> dict: + return { + "status": "ok", + "version": __version__, + # 三态: none(无key/无效) / free(免费key) / api_key(付费档) + "mode": tf_client.current_mode(), + } + + +@router.get("/api/capabilities") +def capabilities() -> dict: + """前端用来决定哪些功能可用、哪些灰显。""" + capset = detect_capabilities() + return { + "label": tier_label(), + "capabilities": capset.to_dict(), + } + + +@router.post("/api/capabilities/redetect") +def redetect() -> dict: + """用户在设置页"重新检测"按钮。""" + capset = detect_capabilities(force=True) + return { + "label": tier_label(), + "capabilities": capset.to_dict(), + } diff --git a/serve/backend/app/api/rps.py b/serve/backend/app/api/rps.py new file mode 100644 index 0000000..558aa9a --- /dev/null +++ b/serve/backend/app/api/rps.py @@ -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"}, + ) diff --git a/serve/backend/app/api/screener.py b/serve/backend/app/api/screener.py new file mode 100644 index 0000000..d35faef --- /dev/null +++ b/serve/backend/app/api/screener.py @@ -0,0 +1,730 @@ +"""Screener API。""" +from __future__ import annotations + +import logging +import math +import re +import time +from dataclasses import asdict +from datetime import date, datetime +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel + +from app.services.screener import PRESET_STRATEGIES, ScreenerService +from app.services import strategy_cache +from app.strategy import config as strategy_config + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/screener", tags=["screener"]) + + +class CustomRequest(BaseModel): + conditions: list[str] + order_by: Optional[str] = None + limit: int = 30 + pool: Optional[list[str]] = None + as_of: Optional[date] = None + ext_columns: Optional[str] = None + + +class PresetRequest(BaseModel): + strategy_id: str + pool: Optional[list[str]] = None + as_of: Optional[date] = None + ext_columns: Optional[str] = None + + +def _safe(result_dict: dict) -> dict: + """sanitize for JSON(NaN / Inf → None).""" + rows = result_dict.get("rows", []) + for r in rows: + for k, v in list(r.items()): + if isinstance(v, float) and not math.isfinite(v): + r[k] = None + return result_dict + + +_EXT_IDENT_RE = re.compile(r"^[A-Za-z0-9_]+$") + + +def _safe_ext_value(value: Any) -> Any: + if isinstance(value, float) and not math.isfinite(value): + return None + if isinstance(value, (date, datetime)): + return value.isoformat() + return value + + +def _quote_ident(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def _load_ext_value_maps(repo, ext_columns: Optional[str]) -> dict[str, dict[str, Any]]: + """按请求加载扩展列,返回 {输出列名: {symbol: value}}。 + + 策略结果缓存是共享文件,不能被不同 ext_columns 组合污染;因此扩展列只在 + 返回前通过该投影映射追加到结果副本中。 + """ + ext_specs = _parse_ext_columns(ext_columns) if ext_columns else [] + if not ext_specs: + return {} + + import polars as pl + from app.api.ext_data import _read_ext_dataframe + from app.services.ext_data import ExtConfigStore + + db = repo.store.db + data_dir = repo.store.data_dir + ext_store = ExtConfigStore(data_dir) + configs = {c.id: c for c in ext_store.load_all()} + value_maps: dict[str, dict[str, Any]] = {} + + for config_id, field_name in ext_specs: + out_col = f"{config_id}__{field_name}" + cfg = configs.get(config_id) + try: + if cfg: + # 时序扩展表只取最新分区,避免历史分区把同一 symbol JOIN 放大。 + ext_df, _ = _read_ext_dataframe(cfg, data_dir) + else: + view_name = f"ext_{config_id}" + ext_df = pl.from_arrow(db.query( + f"SELECT symbol, {_quote_ident(field_name)} FROM {view_name}" + ).arrow()) + + if ext_df.is_empty() or "symbol" not in ext_df.columns or field_name not in ext_df.columns: + continue + + ext_df = ext_df.select(["symbol", field_name]).unique(subset=["symbol"], keep="last") + value_maps[out_col] = { + str(row["symbol"]): _safe_ext_value(row.get(field_name)) + for row in ext_df.to_dicts() + if row.get("symbol") + } + except Exception as e: # noqa: BLE001 + logger.debug("screener ext column join skipped for %s.%s: %s", config_id, field_name, e) + + return value_maps + + +def _row_with_ext(row: dict, ext_values: dict[str, dict[str, Any]], symbol: Optional[str] = None) -> dict: + next_row = dict(row) + sym = symbol or next_row.get("symbol") + for out_col, value_map in ext_values.items(): + next_row[out_col] = value_map.get(str(sym)) if sym else None + return next_row + + +def _rows_with_ext(rows: list[dict], ext_values: dict[str, dict[str, Any]]) -> list[dict]: + if not ext_values: + return rows + return [_row_with_ext(r, ext_values) for r in rows] + + +def _result_with_ext(result_dict: dict, ext_values: dict[str, dict[str, Any]]) -> dict: + if not ext_values: + return result_dict + return {**result_dict, "rows": _rows_with_ext(result_dict.get("rows", []), ext_values)} + + +def _results_with_ext(results: dict[str, dict], ext_values: dict[str, dict[str, Any]]) -> dict[str, dict]: + if not ext_values: + return results + return {sid: _result_with_ext(r, ext_values) for sid, r in results.items()} + + +def _cache_payload_with_ext(cached: dict, ext_values: dict[str, dict[str, Any]]) -> dict: + if not ext_values: + return cached + + payload = dict(cached) + payload["results"] = _results_with_ext(cached.get("results", {}), ext_values) + + ever_rows = cached.get("today_ever_rows") + if isinstance(ever_rows, dict): + enriched_ever: dict[str, dict[str, dict]] = {} + for sid, sym_map in ever_rows.items(): + if not isinstance(sym_map, dict): + continue + enriched_ever[sid] = { + sym: _row_with_ext(row, ext_values, symbol=sym) + for sym, row in sym_map.items() + if isinstance(row, dict) + } + payload["today_ever_rows"] = enriched_ever + + return payload + + +def _update_cache_strategy(data_dir, as_of: str, strategy_id: str, safe_data: dict) -> None: + """单跑后更新缓存中该策略的结果,保持缓存与最新计算一致。""" + from app.services import strategy_cache + cached = strategy_cache.read_cache(data_dir) + if cached and cached.get("as_of") == as_of: + results = cached.get("results", {}) + results[strategy_id] = { + "total": safe_data.get("total", 0), + "as_of": as_of, + "rows": safe_data.get("rows", []), + } + strategy_cache.write_cache(data_dir, as_of, results) + + +@router.get("/strategies") +def strategies(request: Request): + """策略清单(内置 + 自定义 + AI)。""" + data_dir = request.app.state.repo.store.data_dir + presets = [] + seen_ids: set[str] = set() + + # 内置策略 + for k, v in PRESET_STRATEGIES.items(): + overrides = strategy_config.load_override(data_dir, k) + name = (overrides.get("name") or v["name"]) if overrides else v["name"] + desc = (overrides.get("description") or v["description"]) if overrides else v["description"] + presets.append({"id": k, "name": name, "description": desc, "source": "builtin"}) + seen_ids.add(k) + + # 自定义/AI 策略(不在 PRESET_STRATEGIES 中的) + engine = getattr(request.app.state, "strategy_engine", None) + if engine: + for meta in engine.list_strategies(): + sid = meta["id"] + if sid not in seen_ids: + overrides = strategy_config.load_override(data_dir, sid) + name = (overrides.get("name") or meta["name"]) if overrides else meta["name"] + desc = (overrides.get("description") or meta.get("description", "")) if overrides else meta.get("description", "") + presets.append({"id": sid, "name": name, "description": desc, "source": meta.get("source", "custom")}) + seen_ids.add(sid) + + return {"presets": presets} + + +@router.post("/run") +def run_custom(req: CustomRequest, request: Request): + repo = request.app.state.repo + svc = ScreenerService(repo) + as_of = req.as_of or svc.latest_date() + if not as_of: + raise HTTPException(status_code=400, + detail="无可用数据日期 — enriched 表为空,请先运行盘后管道") + result = svc.run( + as_of=as_of, + conditions=req.conditions, + order_by=req.order_by, + limit=req.limit, + pool=req.pool, + ) + safe_data = _safe(asdict(result)) + ext_values = _load_ext_value_maps(repo, req.ext_columns) + return _result_with_ext(safe_data, ext_values) + + +@router.post("/run_preset") +def run_preset(req: PresetRequest, request: Request): + repo = request.app.state.repo + svc = ScreenerService(repo) + as_of = req.as_of or svc.latest_date() + if not as_of: + raise HTTPException(status_code=400, detail="无可用数据日期") + + # 加载用户保存的策略配置 + data_dir = request.app.state.repo.store.data_dir + ext_values = _load_ext_value_maps(repo, req.ext_columns) + overrides = strategy_config.load_override(data_dir, req.strategy_id) + 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 req.strategy_id in PRESET_STRATEGIES: + try: + result = svc.run_preset(req.strategy_id, as_of=as_of, pool=req.pool, basic_filter=bf, display_limit=dl) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + safe_data = _safe(asdict(result)) + _update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data) + return _result_with_ext(safe_data, ext_values) + + # 自定义/AI 策略 — 通过 StrategyEngine 执行 + engine = getattr(request.app.state, "strategy_engine", None) + if not engine: + raise HTTPException(status_code=404, detail=f"策略引擎未初始化或策略 {req.strategy_id} 不存在") + + try: + result = engine.run(req.strategy_id, as_of, pool=req.pool, overrides=overrides or None) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + data = asdict(result) + + if dl is not None and dl > 0: + data["rows"] = data["rows"][:dl] + data["total"] = min(data["total"], dl) + + # 单跑后更新缓存中该策略的结果(保持缓存最新) + safe_data = _safe(data) + _update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data) + + return _result_with_ext(safe_data, ext_values) + + +@router.get("/cached") +def get_cached( + request: Request, + ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"), +): + """读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。 + + - 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。 + - 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出, + 不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。 + 被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。 + """ + 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) + + +@router.get("/market-snapshot") +def market_snapshot(request: Request): + """最新全市场轻量行情快照,供板块/概念聚合分析使用。""" + import polars as pl + + repo = request.app.state.repo + svc = ScreenerService(repo) + as_of = svc.latest_date() + if not as_of: + return {"as_of": None, "rows": []} + + df = svc._load_enriched_for_date(as_of) + if df.is_empty(): + return {"as_of": str(as_of), "rows": []} + + if "close" in df.columns and "total_shares" in df.columns and "market_cap" not in df.columns: + df = df.with_columns((pl.col("close") * pl.col("total_shares")).alias("market_cap")) + if "close" in df.columns and "float_shares" in df.columns and "float_market_cap" not in df.columns: + df = df.with_columns((pl.col("close") * pl.col("float_shares")).alias("float_market_cap")) + + cols = [ + "symbol", "name", "close", "change_pct", "amount", "volume", + "turnover_rate", "vol_ratio_5d", "total_shares", "float_shares", + "market_cap", "float_market_cap", "consecutive_limit_ups", + ] + df = df.select([c for c in cols if c in df.columns]) + rows = df.to_dicts() + for r in rows: + for k, v in list(r.items()): + if isinstance(v, float) and not math.isfinite(v): + r[k] = None + + return {"as_of": str(as_of), "rows": rows} + + +@router.post("/run_all") +def run_all(request: Request, body: Optional[dict] = None): + """批量运行指定策略,只返回每个策略的命中数。 + + 优化: 从 enriched 读取一次目标日期数据, 所有策略共享。 + body.strategy_ids: 只跑指定的策略 ID 列表, 为空则跑全部。 + """ + from datetime import date as date_type + + t_total = time.perf_counter() + + body = body or {} + repo = request.app.state.repo + svc = ScreenerService(repo) + + # 解析日期 + raw_date = body.get("as_of") + if raw_date: + as_of = date_type.fromisoformat(str(raw_date)) if isinstance(raw_date, str) else raw_date + else: + as_of = svc.latest_date() + if not as_of: + return {"as_of": None, "results": {}} + + # 一次读取目标日期的全部数据 + t0 = time.perf_counter() + precomputed = svc._load_enriched_for_date(as_of) + logger.info("run_all: _load_enriched_for_date took %.1fms", (time.perf_counter() - t0) * 1000) + + results: dict[str, dict] = {} + data_dir = request.app.state.repo.store.data_dir + + # 收集需要运行的策略 ID (如果指定了 strategy_ids 则只跑这些) + requested_ids = body.get("strategy_ids") + all_ids = list(PRESET_STRATEGIES.keys()) + engine = getattr(request.app.state, "strategy_engine", None) + if engine: + for meta in engine.list_strategies(): + sid = meta["id"] + if sid not in PRESET_STRATEGIES: + all_ids.append(sid) + + if requested_ids and isinstance(requested_ids, list): + id_set = set(requested_ids) + all_ids = [sid for sid in all_ids if sid in id_set] + + if not all_ids: + return {"as_of": str(as_of), "results": {}} + + # 批量预加载所有 override 配置 + t0 = time.perf_counter() + all_overrides = strategy_config.list_overrides(data_dir) + logger.info("run_all: list_overrides took %.1fms (%d overrides)", (time.perf_counter() - t0) * 1000, len(all_overrides)) + + # 历史策略: 只在需要时加载 (只加载 all_ids 中包含的 filter_history 策略) + t0 = time.perf_counter() + shared_history = None + id_set = set(all_ids) + if engine: + 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 = min(max(s.lookback_days for _, s in history_strats), 30) + shared_history = svc._load_enriched_history(as_of, max(1, max_lb)) + else: + history_strats = [] + logger.info("run_all: _load_enriched_history took %.1fms (history_strats=%d)", (time.perf_counter() - t0) * 1000, len(history_strats)) + + for sid in all_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=precomputed, basic_filter=bf, display_limit=dl) + else: + r = engine.run( + sid, as_of, overrides=overrides or None, + precomputed=precomputed, precomputed_history=shared_history, + ) + if dl is not None and dl > 0: + r.rows = r.rows[:dl] + r.total = min(r.total, dl) + + safe_rows = _safe(asdict(r)).get("rows", []) + results[sid] = {"total": r.total, "as_of": str(as_of), "rows": safe_rows} + except (ValueError, Exception): + continue + + elapsed = (time.perf_counter() - t_total) * 1000 + logger.info("run_all: total took %.1fms (%d strategies)", elapsed, len(all_ids)) + + # 写入策略缓存 (供页面秒加载) + if results: + try: + strategy_cache.write_cache(data_dir, str(as_of), results) + except Exception: # noqa: BLE001 + pass + + ext_values = _load_ext_value_maps(repo, body.get("ext_columns")) + return {"as_of": str(as_of), "results": _results_with_ext(results, ext_values)} + + +@router.get("/limit-ladder") +def limit_ladder( + request: Request, + as_of: Optional[date] = None, + direction: str = Query("up", description="up=涨停梯队 | down=跌停梯队"), + ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"), +): + """连板/连跌梯队 — 按连板数分组, 含三状态。 + 返回: tiers = [{ boards, count, stocks: [{symbol,name,change_pct,status,...}] }] + + direction=up (默认): + status: limit_up=涨停 | broken=炸板(摸板未封) | failed=断板(晋级失败) + direction=down: + status: limit_down=跌停 | recovery=翘板(跌停后回升,含收阳条件) | failed=止跌(昨日跌停今日未跌停也未翘板) + + ext_columns: 动态 JOIN 扩展数据, 如 "concept.concept,industry.industry" + """ + from datetime import timedelta + + import polars as pl + + is_down = direction == "down" + + # 按 direction 参数化字段映射 + if is_down: + sig_col = "signal_limit_down" + consec_col = "consecutive_limit_downs" + broken_col = "signal_limit_down_recovery" + status_main, status_broken, status_failed = "limit_down", "recovery", "failed" + else: + sig_col = "signal_limit_up" + consec_col = "consecutive_limit_ups" + broken_col = "signal_broken_limit_up" + status_main, status_broken, status_failed = "limit_up", "broken", "failed" + + repo = request.app.state.repo + svc = ScreenerService(repo) + as_of = as_of or svc.latest_date() + if not as_of: + raise HTTPException(status_code=400, detail="无可用数据日期") + + df = svc._load_enriched_for_date(as_of) + if df.is_empty(): + return {"as_of": str(as_of), "tiers": [], "counts": {"up": 0, "down": 0}} + + # 双方向涨跌停计数(不论当前 direction, 前端始终同时显示) + count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0 + count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0 + + # 双方向 sealed 修正: 减去各自的假涨停(假涨停已归炸板, 不计入涨停数) + depth_svc_global = getattr(request.app.state, "depth_service", None) + fake_up = 0 + fake_down = 0 + sealed_up_ready = False + sealed_down_ready = False + if depth_svc_global: + up_map = depth_svc_global.get_sealed_map(as_of, is_down=False) + down_map = depth_svc_global.get_sealed_map(as_of, is_down=True) + sealed_up_ready = bool(up_map) and depth_svc_global.is_sealed_ready(as_of) + sealed_down_ready = bool(down_map) and depth_svc_global.is_sealed_ready(as_of) + if up_map: + fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False) + if down_map: + fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False) + count_up = count_up_raw - fake_up if sealed_up_ready else count_up_raw + count_down = count_down_raw - fake_down if sealed_down_ready else count_down_raw + + # 双方向 sealed 明细(供前端弹窗同时显示涨跌停) + def _count_sealed(m: dict, ready: bool): + if not m or not ready: + return {"real": 0, "fake": 0, "pending": 0} + real = sum(1 for v in m.values() if v.get("sealed") is True) + fake = sum(1 for v in m.values() if v.get("sealed") is False) + pending = sum(1 for v in m.values() if v.get("sealed") is None) + return {"real": real, "fake": fake, "pending": pending} + sealed_counts_up = _count_sealed(up_map, sealed_up_ready) + sealed_counts_down = _count_sealed(down_map, sealed_down_ready) + + # 加载前一日数据获取 prev consecutive_limit_ups/downs + prev_consec: pl.DataFrame = pl.DataFrame() + for delta in range(1, 10): + candidate = as_of - timedelta(days=delta) + df_prev = svc._load_enriched_for_date(candidate) + if not df_prev.is_empty() and consec_col in df_prev.columns: + prev_consec = df_prev.select( + "symbol", + pl.col(consec_col).alias("prev_consec"), + ) + break + + if not prev_consec.is_empty(): + df = df.join(prev_consec, on="symbol", how="left") + else: + df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("prev_consec")) + + # 表达式 + is_limit = pl.col(sig_col).fill_null(False) if sig_col in df.columns else pl.lit(False) + is_broken = pl.col(broken_col).fill_null(False) if broken_col in df.columns else pl.lit(False) + consec = pl.col(consec_col).fill_null(0) if consec_col in df.columns else pl.lit(0) + prev_c = pl.col("prev_consec").fill_null(0) + + # 计算 status + boards (结构涨跌停对称, 仅字段与字面量不同) + is_failed = ~is_limit & ~is_broken & (prev_c > 0) + df = df.with_columns([ + pl.when(is_limit).then(pl.lit(status_main)) + .when(is_broken).then(pl.lit(status_broken)) + .when(is_failed).then(pl.lit(status_failed)) + .otherwise(None).alias("status"), + pl.when(is_limit).then(consec) + .when(is_broken | is_failed).then(prev_c + 1) + .otherwise(0).cast(pl.UInt32).alias("boards"), + ]) + + df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0)) + + # ── 五档 sealed 叠加(独立旁路, 不改 signal_limit_up) ── + # 假涨停(收盘价=涨停价但卖一有量)从 limit 降级为 broken(归炸板视图) + # 真涨停保留 + 附封单量; sealed=null(待确认/降级)保持原状 + depth_svc = getattr(request.app.state, "depth_service", None) + sealed_ready = False + sealed_age: float | None = None + if depth_svc: + sealed_map = depth_svc.get_sealed_map(as_of, is_down=is_down) + sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of) + sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None + + if sealed_map: + # 构建 sealed 列(symbol → sealed bool, vol) + sym_sealed = {s: v.get("sealed") for s, v in sealed_map.items()} + sym_vol = {s: v.get("vol") for s, v in sealed_map.items()} + + # JOIN sealed: 对每只 status=main 的票, 看 sealed 值 + sealed_rows = pl.DataFrame({ + "symbol": list(sym_sealed.keys()), + "_sealed": list(sym_sealed.values()), + "_sealed_vol": list(sym_vol.values()), + }) if sym_sealed else pl.DataFrame() + + if not sealed_rows.is_empty(): + df = df.join(sealed_rows, on="symbol", how="left") + # 假涨停(main 状态但 sealed=False)→ 降级为 broken + df = df.with_columns( + pl.when( + (pl.col("status") == status_main) + & pl.col("_sealed").is_not_null() + & (pl.col("_sealed") == False) # noqa: E712 + ).then(pl.lit(status_broken)) + .otherwise(pl.col("status")).alias("status"), + # sealed_status: real/fake/pending/null + pl.when( + (pl.col("status") == status_main) + & (pl.col("_sealed") == True) # noqa: E712 + ).then(pl.lit("real")) + .when( + (pl.col("_sealed") == False) # noqa: E712 + ).then(pl.lit("fake")) + .when( + (pl.col("status") == status_main) + & pl.col("_sealed").is_null() + ).then(pl.lit("pending")) + .otherwise(None).alias("sealed_status"), + pl.col("_sealed_vol").alias("sealed_vol"), + ).drop(["_sealed", "_sealed_vol"]) + else: + df = df.with_columns( + pl.lit(None).alias("sealed_status"), + pl.lit(None).alias("sealed_vol"), + ) + else: + df = df.with_columns( + pl.lit(None).alias("sealed_status"), + pl.lit(None).alias("sealed_vol"), + ) + else: + df = df.with_columns( + pl.lit(None).alias("sealed_status"), + pl.lit(None).alias("sealed_vol"), + ) + + # 动态 JOIN 扩展数据 + ext_specs = _parse_ext_columns(ext_columns) if ext_columns else [] + ext_col_names: list[str] = [] + if ext_specs: + db = repo.store.db + data_dir = repo.store.data_dir + from app.services.ext_data import ExtConfigStore + + ext_store = ExtConfigStore(data_dir) + configs = {c.id: c for c in ext_store.load_all()} + + for config_id, field_name in ext_specs: + view_name = f"ext_{config_id}" + ext_col_name = f"{config_id}__{field_name}" + try: + ext_df = pl.from_arrow(db.query( + f"SELECT symbol, \"{field_name}\" FROM {view_name}" + ).arrow()) + if not ext_df.is_empty() and "symbol" in ext_df.columns: + ext_df = ext_df.rename({field_name: ext_col_name}) + df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left") + ext_col_names.append(ext_col_name) + except Exception: + cfg = configs.get(config_id) + if cfg: + try: + from app.api.ext_data import _parquet_glob + glob = _parquet_glob(cfg, data_dir) + ext_df = pl.read_parquet(glob) + if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns: + ext_df = ext_df.select(["symbol", field_name]).rename({field_name: ext_col_name}) + df = df.join(ext_df, on="symbol", how="left") + ext_col_names.append(ext_col_name) + except Exception: + pass + + # 选择输出列 + cols = ["symbol", "name", "close", "change_pct", "boards", "status", consec_col, "sealed_status", "sealed_vol"] + ext_col_names + df = df.select([c for c in cols if c in df.columns]) + # 排序: boards 降序, status 按主状态→炸/翘→断/止 + status_order = pl.when(pl.col("status") == status_main).then(0) + status_order = status_order.when(pl.col("status") == status_broken).then(1) + status_order = status_order.otherwise(2).alias("_status_order") + df = df.with_columns(status_order).sort(["boards", "_status_order"], descending=[True, False]).drop("_status_order") + + rows = df.to_dicts() + for r in rows: + for k, v in list(r.items()): + if isinstance(v, float) and not math.isfinite(v): + r[k] = None + + # 按 boards 分组 + tiers: dict[int, list] = {} + for r in rows: + n = int(r.get("boards") or 0) + tiers.setdefault(n, []).append(r) + + tier_list = [ + {"boards": n, "count": len(stocks), "stocks": stocks} + for n, stocks in sorted(tiers.items(), key=lambda x: -x[0]) + ] + + return { + "as_of": str(as_of), + "tiers": tier_list, + "counts": {"up": count_up, "down": count_down}, + "counts_raw": {"up": count_up_raw, "down": count_down_raw}, + "sealed_ready": sealed_ready, + "sealed_age": round(sealed_age, 0) if sealed_age is not None else None, + "sealed_counts": { + "real": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "real"), + "fake": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "fake"), + "pending": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "pending"), + }, + "sealed_counts_up": sealed_counts_up, + "sealed_counts_down": sealed_counts_down, + } + + +def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]: + """解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]。""" + result = [] + for part in ext_columns.split(","): + part = part.strip() + if "." not in part: + continue + config_id, field_name = part.split(".", 1) + config_id = config_id.strip() + field_name = field_name.strip() + if not config_id or not field_name: + continue + if not _EXT_IDENT_RE.match(config_id) or "\x00" in field_name: + continue + result.append((config_id, field_name)) + return result diff --git a/serve/backend/app/api/settings.py b/serve/backend/app/api/settings.py new file mode 100644 index 0000000..39c51ea --- /dev/null +++ b/serve/backend/app/api/settings.py @@ -0,0 +1,1083 @@ +"""设置 API — Key 配置 / 模式切换。 + +提供面向非开发者的 UI 配置入口,避免逼用户改 .env。 +""" +from __future__ import annotations + +import logging +import time + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from app import secrets_store +from app.tickflow import client as tf_client +from app.tickflow.policy import ( + detect_capabilities, + extras_caps, + missing_caps, + probe_log, + tier_label, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +# 默认端点 —— endpoints.json 列表第一项,UI"当前使用"始终对齐此项。 +# 注意:Free 模式 SDK 实际走 free-api(免费数据通道),但 UI 显示统一用默认节点。 +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 + + +@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), + "has_tickflow_key": bool(key), + "tier_label": tier_label(), + "current_endpoint": tf_client.current_endpoint(), + "probe_log": probe_log(), + "missing_caps": missing_caps(), + "extras_caps": extras_caps(), + # 首次使用引导 + "onboarding_completed": preferences.get_onboarding_completed(), + # AI 配置 + "ai_provider": ai_provider, + "ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url), + "ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()), + "has_ai_key": bool(secrets_store.get_ai_key()), + "ai_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), + } + + +class SwitchEndpointIn(BaseModel): + url: str + + +@router.post("/switch_endpoint") +def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict: + """切换 TickFlow 端点并立即生效。 + + 端点切换仅对付费档(starter+,走 api.tickflow.org)有意义; + none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。 + """ + # none/free 档没有付费端点权限,禁止切换 + if tf_client.current_mode() != "api_key": + return {"ok": False, "error": "当前档位无法切换端点,仅付费套餐(Starter+)支持"} + + url = req.url.strip().rstrip("/") + if not url.startswith("https://"): + return {"ok": False, "error": "仅支持 HTTPS 端点"} + + # 持久化到 secrets.json + secrets_store.save({"tickflow_base_url": url}) + # 重置客户端,下次调用自动用新端点 + tf_client.reset_clients() + + return { + "ok": True, + "current_endpoint": tf_client.current_endpoint(), + } + + +@router.post("/tickflow-key") +def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict: + """保存 TickFlow API Key 并立即重新探测能力。 + + 先探后存(关键改动,修复乱填 key 也会被持久化的问题): + 1. 临时用新 key 探测(付费端点),判定档位 + 2. 判定为 none(连单只日K都拿不到)→ key 无效:不存,清除已存的, + 返回 {ok: false, reason: "invalid"},前端提示「Key 无效」 + 3. 判定为 free(免费有效 key)→ 存 key,客户端切到 free-api 服务器 + 4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑) + + 端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用, + 故自动切到默认付费端点(api.tickflow.org);free 档则清除自定义端点。 + """ + from app.tickflow.policy import ( + base_tier_name, is_invalid_key, + ) + + key = req.api_key.strip() + if not key: + return {"ok": False, "error": "key empty"} + + # ===== 1) 临时存 key + 重置客户端,让探测走付费端点 ===== + secrets_store.save({"tickflow_api_key": key}) + tf_client.reset_clients() + + # 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证) + capset = detect_capabilities(force=True) + request.app.state.capabilities = capset + _sync_financial_scheduler_caps(request.app.state, capset) + + # ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 ===== + if is_invalid_key() or base_tier_name() == "none": + # 无效 key:清除刚存的,避免乱填被持久化;退回 none 档 + secrets_store.clear("tickflow_api_key", "tickflow_base_url") + tf_client.reset_clients() + capset = detect_capabilities(force=True) + request.app.state.capabilities = capset + _sync_financial_scheduler_caps(request.app.state, capset) + return { + "ok": False, + "reason": "invalid", + "error": "Key 无效或已过期,请检查后重试", + "mode": "none", + "tier_label": tier_label(), + "current_endpoint": tf_client.current_endpoint(), + "probe_log": [], + "capabilities_count": len(capset.all()), + } + + # ===== 3) free 档(免费有效 key)→ 存 key,切到 free-api 服务器 ===== + if base_tier_name() == "free": + # 免费档运行时走 free-api 服务器,清除付费端点的自定义配置 + secrets_store.clear("tickflow_base_url") + tf_client.reset_clients() + return { + "ok": True, + "tickflow_api_key_masked": secrets_store.mask(key), + "mode": "free", + "tier_label": tier_label(), + "current_endpoint": tf_client.current_endpoint(), + "probe_log": [], + "capabilities_count": len(capset.all()), + } + + # ===== 4) starter+ 付费档 → 确保走付费端点(现有逻辑) ===== + # 若之前是 none/free(无自定义付费端点),切到默认付费端点 + base = secrets_store.load().get("tickflow_base_url") + if not base: + secrets_store.save({"tickflow_base_url": DEFAULT_PAID_ENDPOINT}) + tf_client.reset_clients() + + return { + "ok": True, + "tickflow_api_key_masked": secrets_store.mask(key), + "mode": "api_key", + "tier_label": tier_label(), + "current_endpoint": tf_client.current_endpoint(), + "probe_log": [], + "capabilities_count": len(capset.all()), + } + + +@router.delete("/tickflow-key") +def clear_tickflow_key(request: Request) -> dict: + """清除 Key,退回无档(none)。 + + 同时清除 tickflow_base_url(测速切换的自定义端点),使客户端走 free-api + 服务器取历史日K;档位标签为 None(无档)。 + """ + secrets_store.clear("tickflow_api_key", "tickflow_base_url") + tf_client.reset_clients() + + capset = detect_capabilities(force=True) + request.app.state.capabilities = capset + _sync_financial_scheduler_caps(request.app.state, capset) + + return { + "ok": True, + "mode": "none", + "tier_label": tier_label(), + "current_endpoint": tf_client.current_endpoint(), + "capabilities_count": len(capset.all()), + } + + +@router.post("/onboarding/complete") +def complete_onboarding() -> dict: + """标记首次使用向导完成。 + + 写入 preferences.json,前端守卫据此判断是否需要再次展示向导。 + 跨设备/清缓存安全 —— 状态落在后端文件,不依赖浏览器本地存储。 + """ + from app.services import preferences + done = preferences.set_onboarding_completed(True) + return {"ok": True, "onboarding_completed": done} + + +class AiSettingsIn(BaseModel): + provider: str = "openai_compat" + base_url: str = "" + api_key: str | None = None + model: str = "" + 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: + updates["ai_provider"] = req.provider + settings.ai_provider = req.provider + if req.base_url: + updates["ai_base_url"] = req.base_url + settings.ai_base_url = req.base_url + if req.api_key is not None: + if req.api_key: + updates["ai_api_key"] = req.api_key + settings.ai_api_key = req.api_key + else: + secrets_store.clear("ai_api_key") + settings.ai_api_key = "" + 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 + 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} + + +# ===== 偏好设置 ===== + +def _realtime_allowed() -> bool: + """当前档位是否允许实时行情(none/free 不允许)。""" + from app.services.quote_service import QuoteService + return QuoteService.is_realtime_allowed() + + +class MinuteSyncPrefs(BaseModel): + minute_sync_enabled: bool + minute_sync_days: int = 5 + + +@router.get("/preferences") +def get_preferences() -> dict: + """返回用户偏好设置。""" + from app.services import preferences + return { + "realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(), + "realtime_allowed": _realtime_allowed(), + "indices_nav_pinned": preferences.get_indices_nav_pinned(), + "minute_sync_enabled": preferences.get_minute_sync_enabled(), + "minute_sync_days": preferences.get_minute_sync_days(), + "daily_data_provider": preferences.get_daily_data_provider(), + "adj_factor_provider": preferences.get_adj_factor_provider(), + "minute_data_provider": preferences.get_minute_data_provider(), + "realtime_data_provider": preferences.get_realtime_data_provider(), + "realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(), + **preferences.get_realtime_quote_scope(), + "pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(), + "pipeline_pull_etf": preferences.get_pipeline_pull_etf(), + "pipeline_pull_index": preferences.get_pipeline_pull_index(), + "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(), + "index_daily_batch_size": preferences.get_index_daily_batch_size(), + "watchlist_columns": preferences.get_watchlist_columns(), + "screener_result_columns": preferences.get_screener_result_columns(), + "sse_refresh_pages": preferences.get_sse_refresh_pages(), + "strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(), + "strategy_monitor_ids": preferences.get_strategy_monitor_ids(), + "system_notify_enabled": preferences.get_system_notify_enabled(), + "feishu_webhook_url": preferences.get_feishu_webhook_url(), + "feishu_webhook_secret": preferences.get_feishu_webhook_secret(), + "webhook_enabled_default": preferences.get_webhook_enabled_default(), + "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), + "nav_order": preferences.get_nav_order(), + "nav_hidden": preferences.get_nav_hidden(), + "screener_auto_run": preferences.get_screener_auto_run(), + "limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(), + "depth_polling_interval": preferences.get_depth_polling_interval(), + "depth_finalize_time": preferences.get_depth_finalize_time(), + "review_schedule": preferences.get_review_schedule(), + "review_push_channels": preferences.get_review_push_channels(), + } + + +@router.get("/preferences/watchlist-columns") +def get_watchlist_columns() -> dict: + """返回自选列表列配置。""" + from app.services import preferences + cols = preferences.get_watchlist_columns() + return {"columns": cols} + + +class NavOrderIn(BaseModel): + nav_order: list[str] + + +class NavHiddenIn(BaseModel): + nav_hidden: list[str] + + +@router.put("/preferences/nav-order") +def update_nav_order(req: NavOrderIn) -> dict: + """保存左侧菜单排序(内置页面 path + 扩展分析菜单 id 的有序列表)。""" + from app.services import preferences + saved = preferences.set_nav_order(req.nav_order) + return {"nav_order": saved} + + +@router.put("/preferences/nav-hidden") +def update_nav_hidden(req: NavHiddenIn) -> dict: + """保存左侧菜单隐藏项。""" + from app.services import preferences + saved = preferences.set_nav_hidden(req.nav_hidden) + return {"nav_hidden": saved} + + +@router.put("/preferences/watchlist-columns") +def update_watchlist_columns(req: dict) -> dict: + """保存自选列表列配置。""" + from app.services import preferences + columns = req.get("columns", []) + saved = preferences.set_watchlist_columns(columns) + return {"columns": saved} + + +@router.get("/preferences/screener-result-columns") +def get_screener_result_columns() -> dict: + """返回策略结果列表列配置。""" + from app.services import preferences + cols = preferences.get_screener_result_columns() + return {"columns": cols} + + +@router.put("/preferences/screener-result-columns") +def update_screener_result_columns(req: dict) -> dict: + """保存策略结果列表列配置。""" + from app.services import preferences + columns = req.get("columns", []) + saved = preferences.set_screener_result_columns(columns) + return {"columns": saved} + + +@router.put("/preferences/minute-sync") +def update_minute_sync(req: MinuteSyncPrefs) -> dict: + """保存分钟 K 同步偏好。""" + from app.services import preferences + days = max(1, min(30, req.minute_sync_days)) + preferences.save({ + "minute_sync_enabled": req.minute_sync_enabled, + "minute_sync_days": days, + }) + return { + "minute_sync_enabled": req.minute_sync_enabled, + "minute_sync_days": days, + } + + +class RealtimeQuotesPrefs(BaseModel): + realtime_quotes_enabled: bool + + +class RealtimeQuoteScopePrefs(BaseModel): + realtime_pull_stock: bool | None = None + realtime_pull_etf: bool | None = None + realtime_pull_index: bool | None = None + realtime_index_mode: str | None = None + realtime_index_symbols: list[str] | None = None + + +@router.put("/preferences/realtime-quotes") +def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: + """保存全局实时行情开关。 + + none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。 + 前端据此把开关置灰 / 回弹。 + """ + from app.services import preferences + qs = getattr(request.app.state, "quote_service", None) + + allowed = qs.is_realtime_allowed() if qs else True + if req.realtime_quotes_enabled and not allowed: + # 当前档位不允许开启实时行情 — 强制关闭 + preferences.save({"realtime_quotes_enabled": False}) + if qs: + qs.disable() + return {"realtime_quotes_enabled": False, "realtime_allowed": False} + if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols(): + preferences.save({"realtime_quotes_enabled": False}) + return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"} + + preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled}) + if qs: + if req.realtime_quotes_enabled: + qs.enable() + else: + qs.disable() + + return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed} + + +@router.put("/preferences/realtime-quote-scope") +def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict: + """保存盘中实时行情范围;独立于盘后管道范围。""" + from app.services import preferences + cfg = req.model_dump(exclude_none=True) + return preferences.set_realtime_quote_scope(cfg) + + +class RealtimeWatchlistPrefs(BaseModel): + symbols: list[str] = [] + + +@router.put("/preferences/realtime-watchlist") +def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict: + """兼容旧入口;Free 实时标的由自选页前 5 个决定。""" + from app.services import preferences + symbols = preferences.set_realtime_watchlist_symbols(req.symbols) + return {"realtime_watchlist_symbols": symbols} + + +class IndicesNavPinnedPrefs(BaseModel): + indices_nav_pinned: bool + + +@router.put("/preferences/indices-nav-pinned") +def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict: + """保存侧栏指数报价卡片固定显示开关。 + ON=常驻显示;OFF=跟随实时行情开关(仅实时开时显示)。""" + from app.services import preferences + preferences.save({"indices_nav_pinned": req.indices_nav_pinned}) + return {"indices_nav_pinned": req.indices_nav_pinned} + + +class RealtimeMonitorConfigIn(BaseModel): + sse_refresh_pages: dict[str, bool] | None = None + strategy_monitor_enabled: bool | None = None + strategy_monitor_ids: list[str] | None = None + sidebar_index_symbols: list[str] | None = None + screener_auto_run: bool | None = None + + +@router.put("/preferences/realtime-monitor") +def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict: + """更新实时监控配置。策略监控统一迁移为 MonitorRule,由监控引擎评估。""" + from app.services import preferences + + cfg = req.model_dump(exclude_none=True) + result = preferences.set_realtime_monitor_config(cfg) + + # 策略监控开关/池变化 → 同步迁移为 type=strategy 规则 + reload 引擎 + if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None: + monitor_engine = getattr(request.app.state, "monitor_engine", None) + strategy_engine = getattr(request.app.state, "strategy_engine", None) + data_dir = request.app.state.repo.store.data_dir + if monitor_engine is not None and strategy_engine is not None: + from app.strategy import monitor_rules as mr_store + try: + if preferences.get_strategy_monitor_enabled(): + ids = preferences.get_strategy_monitor_ids() + names = {s.id: s.name for s in strategy_engine.list_strategies()} + mr_store.migrate_strategy_monitors(data_dir, ids, names) + else: + # 关闭策略监控: 停用所有策略规则 + mr_store.migrate_strategy_monitors(data_dir, [], {}) + # reload 规则到引擎 + monitor_engine.set_rules(mr_store.load_all(data_dir)) + except Exception: + pass + + return result + + +class PipelinePullTypesIn(BaseModel): + """盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。""" + pipeline_pull_a_share: bool | None = None + 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 + + +class SystemNotifyPrefsIn(BaseModel): + enabled: bool + + +@router.put("/preferences/system-notify") +def update_system_notify(req: SystemNotifyPrefsIn) -> dict: + """系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。 + + 纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可。 + quote_service 在每轮告警评估时读此开关决定是否发系统通知。 + """ + from app.services import preferences + saved = preferences.set_system_notify_enabled(req.enabled) + return {"system_notify_enabled": saved} + + +class FeishuWebhookPrefsIn(BaseModel): + url: str + secret: str = "" + + +@router.put("/preferences/feishu-webhook") +def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict: + """飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。 + + - url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。 + - secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。 + """ + from app.services import preferences + from app.services import webhook_adapter + + url = (req.url or "").strip() + if url and not webhook_adapter.is_valid_feishu_url(url): + raise HTTPException( + status_code=400, + detail="Webhook 地址非法, 需为飞书自定义机器人地址 " + "(https://open.feishu.cn/open-apis/bot/v2/hook/...)", + ) + saved_url = preferences.set_feishu_webhook_url(url) + saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip()) + return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret} + + +class WebhookEnabledDefaultIn(BaseModel): + enabled: bool + + +@router.put("/preferences/webhook-enabled-default") +def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict: + """新建监控规则时是否默认勾选「飞书推送」。 + + 数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。 + 单条规则仍可在规则编辑页独立修改此项。 + """ + from app.services import preferences + + saved = preferences.set_webhook_enabled_default(req.enabled) + return {"webhook_enabled_default": saved} + + +@router.put("/preferences/quote-interval") +def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict: + """更新行情轮询间隔。按档位自动 clamp。""" + qs = getattr(request.app.state, "quote_service", None) + if not qs: + return {"interval": req.interval, "min_interval": qs.get_min_interval(), "max_interval": 60.0} + clamped = qs.set_interval(req.interval) + return { + "interval": clamped, + "min_interval": qs.get_min_interval(), + "max_interval": qs.MAX_INTERVAL, + } + + +@router.get("/preferences/quote-interval") +def get_quote_interval(request: Request) -> dict: + """获取当前行情轮询间隔和档位限制。""" + qs = getattr(request.app.state, "quote_service", None) + if not qs: + return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0} + return { + "interval": qs._interval, + "min_interval": qs.get_min_interval(), + "max_interval": qs.MAX_INTERVAL, + } + + +class TestEndpointIn(BaseModel): + url: str + # 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5) + rounds: int | None = None + + +# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取 tickflow.org/endpoints.json +# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。 +ENDPOINTS_URL = "https://tickflow.org/endpoints.json" +ENDPOINTS_TTL = 300.0 # 秒 + +# 回退列表 —— 与官方 endpoints.json 的 endpoints[] 字段对齐。 +# 当远程拉取失败时使用,保证 UI 永远有内容可显示。 +_FALLBACK_ENDPOINTS: list[dict] = [ + { + "id": "default", + "url": "https://api.tickflow.org", + "label": "默认端点", + "region": "auto", + "description": "默认端点", + "premium": False, + }, + { + "id": "hk", + "url": "https://hk-api.tickflow.org", + "label": "香港端点", + "region": "ap-east-1", + "description": "备用端点,部分地区访问更稳定", + "premium": False, + }, + { + "id": "sg", + "url": "https://sg-api.tickflow.org", + "label": "新加坡端点", + "region": "ap-southeast-1", + "description": "备用端点,亚太地区访问更稳定", + "premium": False, + }, + { + "id": "us", + "url": "https://us-api.tickflow.org", + "label": "美国端点", + "region": "us-east-1", + "description": "备用端点,欧美地区访问更稳定", + "premium": False, + }, + { + "id": "cn", + "url": "https://139.196.55.234:50443", + "label": "中国大陆端点(Beta)", + "region": "cn-east-1", + "description": "备用端点,中国大陆地区访问更稳定,目前处于测试阶段,谨慎使用", + "premium": False, + }, + { + "id": "cn-premium", + "url": "https://106.15.238.72:50443", + "label": "中国大陆专线端点", + "region": "cn-east-1", + "description": "专线加速端点,需要专线加速权限(该权限包含在 Expert 及以上套餐中,也可通过自定义组合单独开通)", + "premium": True, + }, +] + +# 进程内缓存:{ "ts": float, "data": dict } +_endpoints_cache: dict = {"ts": 0.0, "data": None} + + +@router.get("/endpoints") +def list_endpoints() -> dict: + """代理拉取 tickflow.org/endpoints.json 并返回规范化端点列表。 + + 前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、 + 5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。 + 返回结构与原始 endpoints.json 一致(透传 schema/version 等元信息)。 + """ + import httpx + + now = time.monotonic() + cached = _endpoints_cache.get("data") + if cached is not None and (now - _endpoints_cache["ts"]) < ENDPOINTS_TTL: + return cached + + source = "remote" + data: dict | None = None + try: + resp = httpx.get(ENDPOINTS_URL, timeout=8.0, follow_redirects=True) + if resp.status_code == 200: + parsed = resp.json() + eps = parsed.get("endpoints") + # 校验:必须是列表且每项含必要字段,否则视为无效 + if isinstance(eps, list) and all( + isinstance(e, dict) and "url" in e for e in eps + ): + data = { + "version": parsed.get("version", 1), + "description": parsed.get( + "description", "TickFlow API 端点配置" + ), + "healthPath": parsed.get("healthPath", "/health"), + "testRounds": parsed.get("testRounds", 5), + "endpoints": eps, + } + except (httpx.HTTPError, ValueError): + logger.warning("拉取 endpoints.json 失败,使用内置回退列表", exc_info=True) + + if data is None: + source = "fallback" + data = { + "version": 1, + "description": "TickFlow API 端点配置", + "healthPath": "/health", + "testRounds": 5, + "endpoints": _FALLBACK_ENDPOINTS, + } + + # 标记数据来源,便于前端提示(回退时显示"内置列表")。 + data["source"] = source + _endpoints_cache["ts"] = now + _endpoints_cache["data"] = data + return data + + +async def _http_ping(url: str, timeout: float = 10.0) -> float | None: + """单次异步 GET 请求并返回延迟(ms),失败返回 None。 + + 对齐官方 latency_test.py:用 /health 轻量端点测真实网络延迟, + 不携带 API Key(/health 公开)。异步实现,保证多端点并行测速不阻塞。 + """ + import httpx + + t0 = time.perf_counter() + try: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.get(url) + dt = (time.perf_counter() - t0) * 1000 + # 只把 <400 视为成功;4xx/5xx 也算"不可达" + if resp.status_code < 400: + return round(dt, 2) + return None + except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError, OSError): + return None + + +@router.post("/test_endpoint") +async def test_endpoint(req: TestEndpointIn) -> dict: + """测试端点网络延迟:对 /health 多轮探测取中位数。 + + 参考 TickFlow 官方 latency_test.py: + - 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时 + - 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s + - 返回 median/min/max/success,前端显示中位数 + - 异步实现,保证"全部测速"时多端点真正并行 + """ + import asyncio + import statistics + + base = req.url.rstrip("/") + rounds = max(1, min(10, req.rounds or _endpoints_cache.get("data", {}).get("testRounds", 5))) + health_url = base + "/health" + + latencies: list[float] = [] + for _ in range(rounds): + ms = await _http_ping(health_url) + if ms is not None: + latencies.append(ms) + # 官方脚本间隔 0.3s;末轮无需等待 + await asyncio.sleep(0.3) + + success = len(latencies) + if success == 0: + return { + "ok": False, + "error": "不可达", + "url": req.url, + "rounds": rounds, + "success": 0, + "median_ms": None, + "min_ms": None, + "max_ms": None, + } + + median = round(statistics.median(latencies), 2) + return { + "ok": True, + "url": req.url, + "rounds": rounds, + "success": success, + "median_ms": median, + "min_ms": round(min(latencies), 2), + "max_ms": round(max(latencies), 2), + # 兼容旧字段:取中位数作为代表延迟 + "latency_ms": median, + } + + +class PipelineScheduleIn(BaseModel): + hour: int + minute: int + + +@router.put("/preferences/pipeline-schedule") +def update_pipeline_schedule(req: PipelineScheduleIn, request: Request) -> dict: + """保存盘后管道调度时间并立即 reschedule。""" + from app.services import preferences + sched = preferences.set_pipeline_schedule(req.hour, req.minute) + + # 动态 reschedule + from apscheduler.triggers.cron import CronTrigger + scheduler = getattr(request.app.state, "scheduler", None) + if scheduler: + scheduler.reschedule_job( + "daily_pipeline", + trigger=CronTrigger( + day_of_week="mon-fri", + hour=sched["hour"], + minute=sched["minute"], + timezone="Asia/Shanghai", + ), + ) + logger.info("pipeline rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"]) + + return sched + + +@router.put("/preferences/instruments-schedule") +def update_instruments_schedule(req: PipelineScheduleIn, request: Request) -> dict: + """保存盘前标的维表调度时间并立即 reschedule。""" + from app.services import preferences + sched = preferences.set_instruments_schedule(req.hour, req.minute) + + from apscheduler.triggers.cron import CronTrigger + scheduler = getattr(request.app.state, "scheduler", None) + if scheduler: + scheduler.reschedule_job( + "pre_market_instruments", + trigger=CronTrigger( + day_of_week="mon-fri", + hour=sched["hour"], + minute=sched["minute"], + timezone="Asia/Shanghai", + ), + ) + return sched + + +class EnrichedBatchSizeIn(BaseModel): + size: int + + +@router.put("/preferences/enriched-batch-size") +def update_enriched_batch_size(req: EnrichedBatchSizeIn) -> dict: + """保存 enriched 全量计算批次大小。""" + from app.services import preferences + size = preferences.set_enriched_batch_size(req.size) + return {"enriched_batch_size": size} + + +class IndexDailyBatchSizeIn(BaseModel): + size: int + + +@router.put("/preferences/index-daily-batch-size") +def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict: + """保存指数日 K 同步批次大小。""" + from app.services import preferences + size = preferences.set_index_daily_batch_size(req.size) + return {"index_daily_batch_size": size} + + +# ── 五档盘口 sealed 配置 ────────────────────────────── + +class LimitLadderMonitorIn(BaseModel): + enabled: bool + + +@router.put("/preferences/limit-ladder-monitor") +def update_limit_ladder_monitor(req: LimitLadderMonitorIn, request: Request) -> dict: + """连板梯队 5 档监控开关。开启→启动 depth 轮询, 关闭→停止。""" + from app.services import preferences + preferences.save({"limit_ladder_monitor_enabled": req.enabled}) + + # 立即应用: 启停 depth 轮询线程 + depth_svc = getattr(request.app.state, "depth_service", None) + if depth_svc: + depth_svc.apply_monitor_toggle(req.enabled) + + return {"limit_ladder_monitor_enabled": req.enabled} + + +@router.post("/preferences/limit-ladder-monitor/run") +def run_limit_ladder_fix(request: Request) -> dict: + """立即手动修正一次真假板(拉取五档盘口 + 更新缓存)。需 Pro+。""" + from app.tickflow.capabilities import Cap + capset = request.app.state.capabilities + capset.require(Cap.DEPTH5_BATCH) # 无能力抛 CapabilityDenied(403) + + depth_svc = getattr(request.app.state, "depth_service", None) + if not depth_svc: + raise HTTPException(status_code=503, detail="depth 服务未初始化") + return depth_svc.run_once() + + +class DepthPollingIntervalIn(BaseModel): + interval: float + + +@router.put("/preferences/depth-polling-interval") +def update_depth_polling_interval(req: DepthPollingIntervalIn, request: Request) -> dict: + """保存五档盘口盘中轮询间隔(秒)。需 Pro+。""" + from app.tickflow.capabilities import Cap + request.app.state.capabilities.require(Cap.DEPTH5_BATCH) + + from app.services import preferences + interval = preferences.set_depth_polling_interval(req.interval) + return {"depth_polling_interval": interval} + + +class DepthFinalizeTimeIn(BaseModel): + hour: int + minute: int + + +@router.put("/preferences/depth-finalize-time") +def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> dict: + """保存盘后 sealed 定版时间(范围15:01~18:00)并立即 reschedule。需 Pro+。""" + from app.tickflow.capabilities import Cap + request.app.state.capabilities.require(Cap.DEPTH5_BATCH) + + from app.services import preferences + sched = preferences.set_depth_finalize_time(req.hour, req.minute) + + from apscheduler.triggers.cron import CronTrigger + scheduler = getattr(request.app.state, "scheduler", None) + if scheduler: + scheduler.reschedule_job( + "depth_finalize", + trigger=CronTrigger( + day_of_week="mon-fri", + hour=sched["hour"], + minute=sched["minute"], + timezone="Asia/Shanghai", + ), + ) + logger.info("depth_finalize rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"]) + + return sched + + +class ReviewScheduleIn(BaseModel): + enabled: bool + hour: int + 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} + diff --git a/serve/backend/app/api/signals.py b/serve/backend/app/api/signals.py new file mode 100644 index 0000000..66e1772 --- /dev/null +++ b/serve/backend/app/api/signals.py @@ -0,0 +1,100 @@ +"""自定义信号 API 路由 — HTTP 请求 → 调用 custom_signals 模块 → 返回响应。 + +只做胶水:校验 → 持久化 → 失效缓存。不含表达式编译逻辑。 +""" +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from app.strategy import custom_signals + +router = APIRouter(prefix="/api/custom-signals", tags=["custom-signals"]) + + +def _data_dir(request: Request) -> Path: + return request.app.state.repo.store.data_dir + + +def _invalidate() -> None: + """失效 pipeline 的自定义信号缓存,下次计算重新加载。""" + from app.indicators.pipeline import invalidate_custom_signals + invalidate_custom_signals() + + +class ConditionModel(BaseModel): + left: str # 字段名(须在白名单) + op: str # > >= < <= == != + right: str # "field:xxx" 或数字字符串 + + +class SignalModel(BaseModel): + id: str + name: str + kind: str # entry | exit | both + conditions: list[ConditionModel] + enabled: bool = True + + +# ── 字段选项 / 运算符 ─────────────────────────────────── + + +@router.get("/options") +def get_options(): + """返回可选字段与运算符,供前端下拉框使用。""" + # 字段带中文标签(取自 ENRICHED_COLUMNS,回退为字段名本身) + from app.indicators.pipeline import ENRICHED_COLUMNS + + fields = [ + {"key": f, "label": ENRICHED_COLUMNS.get(f, f)} + for f in sorted(custom_signals.ALLOWED_FIELDS) + ] + return { + "fields": fields, + "operators": [">", ">=", "<", "<=", "==", "!="], + "kinds": [ + {"key": "entry", "label": "买入"}, + {"key": "exit", "label": "卖出"}, + {"key": "both", "label": "买卖通用"}, + ], + } + + +# ── 列表 ─────────────────────────────────────────────── + + +@router.get("") +def list_signals(request: Request): + sigs = custom_signals.load_all(_data_dir(request)) + return {"signals": sigs} + + +# ── 新建 / 更新 ──────────────────────────────────────── + + +@router.post("") +def save_signal(req: SignalModel, request: Request): + sig = req.model_dump() + try: + custom_signals.validate(sig) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + custom_signals.save_one(_data_dir(request), sig) + _invalidate() + return {"ok": True, "signal": sig} + + +# ── 删除 ─────────────────────────────────────────────── + + +@router.delete("/{signal_id}") +def delete_signal(signal_id: str, request: Request): + if not custom_signals.ID_RE.match(signal_id): + raise HTTPException(status_code=400, detail="信号 id 非法") + deleted = custom_signals.delete_one(_data_dir(request), signal_id) + if not deleted: + raise HTTPException(status_code=404, detail="信号不存在") + _invalidate() + return {"ok": True} diff --git a/serve/backend/app/api/stock_analysis.py b/serve/backend/app/api/stock_analysis.py new file mode 100644 index 0000000..8926fc1 --- /dev/null +++ b/serve/backend/app/api/stock_analysis.py @@ -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} diff --git a/serve/backend/app/api/strategy.py b/serve/backend/app/api/strategy.py new file mode 100644 index 0000000..b355209 --- /dev/null +++ b/serve/backend/app/api/strategy.py @@ -0,0 +1,441 @@ +"""策略 API 路由 — HTTP 请求 → 调用策略模块 → 返回响应。 + +只做胶水,不含业务逻辑。 +""" +from __future__ import annotations + +import math +from dataclasses import asdict +from datetime import date +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from app.strategy import config as strategy_config +from app.strategy.engine import StrategyEngine, StrategyDef +from app.strategy.ai_generator import AIStrategyGenerator +from app.strategy.prompt_builder import build_step1, build_step2 +from app.strategy.monitor import StrategyMonitorService, StrategyAlert + +router = APIRouter(prefix="/api/strategies", tags=["strategies"]) + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _get_engine(request: Request) -> StrategyEngine: + engine = getattr(request.app.state, "strategy_engine", None) + if not engine: + raise HTTPException(status_code=503, detail="策略引擎未初始化") + return engine + + +def _get_monitor(request: Request) -> StrategyMonitorService: + mon = getattr(request.app.state, "strategy_monitor", None) + if not mon: + raise HTTPException(status_code=503, detail="策略监控未初始化") + return mon + + +def _data_dir(request: Request) -> Path: + return request.app.state.repo.store.data_dir + + +def _safe(result_dict: dict) -> dict: + rows = result_dict.get("rows", []) + for r in rows: + for k, v in list(r.items()): + if isinstance(v, float) and not math.isfinite(v): + r[k] = None + return result_dict + + +def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict: + """策略详情(含用户覆盖)""" + bf = {**s.basic_filter} + scoring = dict(s.meta.get("scoring", {})) + params_defaults = {p["id"]: p["default"] for p in s.meta.get("params", [])} + + if overrides: + if overrides.get("basic_filter"): + bf.update(overrides["basic_filter"]) + if overrides.get("scoring"): + scoring.update(overrides["scoring"]) + # 用户保存的参数覆盖默认值: 合并进 params_defaults, 前端据此回显 + if overrides.get("params"): + params_defaults.update(overrides["params"]) + + # 名称/描述可被用户覆盖 + name = overrides.get("name", s.meta.get("name", "")) if overrides else s.meta.get("name", "") + description = overrides.get("description", s.meta.get("description", "")) if overrides else s.meta.get("description", "") + + return { + "id": s.meta["id"], + "name": name or s.meta.get("name", ""), + "description": description or s.meta.get("description", ""), + "tags": s.meta.get("tags", []), + "source": s.source, + "version": s.meta.get("version", "1.0.0"), + "basic_filter": bf, + "params": s.meta.get("params", []), + "params_defaults": params_defaults, + "scoring": scoring, + "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), + "max_hold_days": overrides.get("max_hold_days", s.max_hold_days) if overrides else s.max_hold_days, + "alerts": s.alerts, + "order_by": s.meta.get("order_by", "score"), + "descending": s.meta.get("descending", True), + "limit": s.meta.get("limit", 30), + "display_limit": overrides.get("display_limit") if overrides and "display_limit" in overrides else None, + } + + +# ── Request Models ─────────────────────────────────────────────────── + + +class RunRequest(BaseModel): + strategy_id: str + as_of: date | None = None + pool: list[str] | None = None + params: dict | None = None + + +class RunAllRequest(BaseModel): + as_of: date | None = None + + +class SaveConfigRequest(BaseModel): + strategy_id: str + overrides: dict + + +class AIGenerateRequest(BaseModel): + prompt: str + + +class AISaveRequest(BaseModel): + code: str + strategy_id: str + + +class MonitorStartRequest(BaseModel): + strategy_id: str + + +# ── 列表 / 详情 ───────────────────────────────────────────────────── + + +@router.get("") +def list_strategies(request: Request): + engine = _get_engine(request) + data_dir = _data_dir(request) + all_overrides = strategy_config.list_overrides(data_dir) + + result = [] + for meta in engine.list_strategies(): + sid = meta["id"] + s = engine.get(sid) + overrides = all_overrides.get(sid) + result.append(_strategy_detail(s, overrides)) + return {"strategies": result} + + +@router.get("/{strategy_id}") +def get_strategy(strategy_id: str, request: Request): + engine = _get_engine(request) + try: + s = engine.get(strategy_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + overrides = strategy_config.load_override(_data_dir(request), strategy_id) + return _strategy_detail(s, overrides or None) + + +# ── 执行选股 ───────────────────────────────────────────────────────── + + +@router.post("/run") +def run_strategy(req: RunRequest, request: Request): + engine = _get_engine(request) + data_dir = _data_dir(request) + + # 读取用户覆盖配置 + overrides = strategy_config.load_override(data_dir, req.strategy_id) + params = req.params or {} + # 合并用户保存的策略参数 + if overrides.get("params"): + merged = dict(overrides["params"]) + merged.update(params) # 请求里的优先 + params = merged + + # 确定日期 + as_of = req.as_of + if not as_of: + from app.services.screener import ScreenerService + svc = ScreenerService(request.app.state.repo) + as_of = svc.latest_date() + if not as_of: + raise HTTPException(status_code=400, detail="无可用数据日期") + + try: + result = engine.run( + req.strategy_id, as_of, + pool=req.pool, + params=params, + overrides=overrides or None, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + return _safe(asdict(result)) + + +@router.post("/run-all") +def run_all(req: RunAllRequest, request: Request): + engine = _get_engine(request) + data_dir = _data_dir(request) + + as_of = req.as_of + if not as_of: + from app.services.screener import ScreenerService + svc = ScreenerService(request.app.state.repo) + as_of = svc.latest_date() + if not as_of: + return {"as_of": None, "results": {}} + + all_overrides = strategy_config.list_overrides(data_dir) + results: dict[str, dict] = {} + for sid, result in engine.run_all(as_of, overrides_map=all_overrides).items(): + results[sid] = {"total": result.total, "as_of": str(as_of)} + + return {"as_of": str(as_of), "results": results} + + +# ── 配置持久化 ─────────────────────────────────────────────────────── + + +@router.post("/config") +def save_config(req: SaveConfigRequest, request: Request): + engine = _get_engine(request) + if not engine.has(req.strategy_id): + raise HTTPException(status_code=404, detail=f"策略 {req.strategy_id} 不存在") + + # 剥离与策略默认值相同的字段,只保存用户真正修改过的值 + overrides = _strip_defaults(req.strategy_id, req.overrides, engine) + + strategy_config.save_override(_data_dir(request), req.strategy_id, overrides) + return {"ok": True} + + +def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict: + """剥离与策略默认值相同的字段,避免默认值被固化到 override 中。 + + 核心问题: 前端把策略的默认 basic_filter 全量发回后端保存, + 导致隐含的默认过滤条件 (如 market_cap_min, amount_min) 被写入 override 文件。 + 即使前端 UI 不展示这些字段,它们仍会在策略运行时生效。 + """ + s = engine.get(strategy_id) + result = dict(overrides) + + # 处理 basic_filter: 只保留与策略默认值不同的键 + bf = result.get("basic_filter") + if bf and isinstance(bf, dict): + default_bf = s.basic_filter if s else {} + stripped_bf = {} + for k, v in bf.items(): + default_val = default_bf.get(k) + # 保留与默认值不同的键,以及没有默认值的键 + if k not in default_bf or v != default_val: + stripped_bf[k] = v + if stripped_bf: + result["basic_filter"] = stripped_bf + else: + del result["basic_filter"] + + return result + + +@router.delete("/config/{strategy_id}") +def reset_config(strategy_id: str, request: Request): + strategy_config.delete_override(_data_dir(request), strategy_id) + return {"ok": True} + + +# ── AI 生成 ─────────────────────────────────────────────────────────── + +class BuildRequest(BaseModel): + """两步策略构建请求""" + step: int # 1 / 2 + # step1 字段 + name: str = "" + description: str = "" + direction: str = "long" + rules: str = "" + strategy_id: str = "" + # step2 字段 + current_code: str = "" + instruction: str = "" + + +@router.get("/ai/status") +def ai_status(request: Request): + """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()) + 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") +def get_strategy_source(strategy_id: str, request: Request): + """获取策略源文件内容(用于 AI 修改)""" + from pathlib import Path + + # 先查 StrategyEngine 获取文件路径 + engine = _get_engine(request) + try: + s = engine.get(strategy_id) + except ValueError: + raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在") + + path = s.file_path + if not path or not path.exists(): + raise HTTPException(status_code=404, detail="策略源文件不存在") + + return {"code": path.read_text(encoding="utf-8"), "source": s.source} + + +@router.post("/ai/test") +async def ai_test(request: Request): + """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: + text = await generate_ai_text( + [{"role": "user", "content": "Reply exactly: OK"}], + temperature=0, + max_tokens=8, + timeout=15, + ) + return {"ok": True, "model": current_ai_model() or current_ai_provider(), "response": text[:80]} + except Exception as e: + return {"ok": False, "error": str(e)} + + +@router.post("/build") +async def build_strategy(req: BuildRequest, request: Request): + """两步策略构建。 + step1: name + description + direction + rules → 完整策略 + step2: current_code + instruction → 修改任意部分 + """ + gen = AIStrategyGenerator() + + if req.step == 1: + prompt = build_step1(req.name, req.description, req.direction, req.rules, req.strategy_id) + elif req.step == 2: + prompt = build_step2(req.current_code, req.instruction) + else: + raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}") + + try: + result = await gen.generate(prompt) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return result + + + +@router.post("/ai/generate") +async def ai_generate(req: AIGenerateRequest, request: Request): + try: + gen = AIStrategyGenerator() + result = await gen.generate(req.prompt) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"AI生成失败: {e}") from e + return result + + +@router.post("/ai/save") +async def ai_save(req: AISaveRequest, request: Request): + data_dir = _data_dir(request) + out_dir = data_dir / "strategies" / "ai" + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"{req.strategy_id}.py" + previous_code = path.read_text(encoding="utf-8") if path.exists() else None + path.write_text(req.code, encoding="utf-8") + + # 热重载,并确认保存的策略真的被引擎加载。 + engine = _get_engine(request) + engine.reload() + if not engine.has(req.strategy_id): + if previous_code is None: + path.unlink(missing_ok=True) + else: + path.write_text(previous_code, encoding="utf-8") + engine.reload() + raise HTTPException( + status_code=400, + detail=f"策略保存成功但加载失败: {req.strategy_id},请检查代码语法和 META.id 是否一致", + ) + return {"ok": True, "path": str(path)} + + +@router.delete("/{strategy_id}") +def delete_strategy(strategy_id: str, request: Request): + """删除自定义策略 — 清除 .py 文件 + overrides + 热重载。内置策略不可删除。""" + from pathlib import Path + + engine = _get_engine(request) + try: + s = engine.get(strategy_id) + except ValueError: + raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在") + + if s.source == "builtin": + raise HTTPException(status_code=403, detail="内置策略不可删除") + + # 删除策略文件 + if s.file_path and s.file_path.exists(): + s.file_path.unlink() + + # 删除 overrides + data_dir = _data_dir(request) + override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json" + if override_path.exists(): + override_path.unlink() + + # 热重载 + engine.reload() + return {"ok": True} + + +# ── 监控 ───────────────────────────────────────────────────────────── +# 注: 策略监控已统一迁移到 MonitorRuleEngine (监控通知页), 旧的 start/stop/status +# 路由已移除。StrategyMonitorService 类保留 (其 _check_signals 被 MonitorRuleEngine 复用)。 + + +# ── 热重载 ─────────────────────────────────────────────────────────── + + +@router.post("/reload") +def reload_strategies(request: Request): + engine = _get_engine(request) + engine.reload() + return {"ok": True, "count": len(engine.list_strategies())} diff --git a/serve/backend/app/api/watchlist.py b/serve/backend/app/api/watchlist.py new file mode 100644 index 0000000..81fc34d --- /dev/null +++ b/serve/backend/app/api/watchlist.py @@ -0,0 +1,222 @@ +"""自选股 API。""" +from __future__ import annotations + +import logging +import math +import time +from datetime import date + +import polars as pl +from fastapi import APIRouter, Query, Request +from pydantic import BaseModel + +from app.services import watchlist + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/watchlist", tags=["watchlist"]) + + +class AddRequest(BaseModel): + symbol: str + note: str = "" + + +class BatchAddRequest(BaseModel): + symbols: list[str] + note: str = "" + + +def _with_names(rows: list[dict], request: Request) -> list[dict]: + if not rows: + return rows + try: + df_i = request.app.state.repo.get_instruments() + if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns: + return rows + name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows()) + return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows] + except Exception as e: # noqa: BLE001 + logger.debug("attach watchlist names failed: %s", e) + return rows + + +@router.get("") +def list_all(request: Request): + return {"symbols": _with_names(watchlist.list_symbols(), request)} + + +@router.post("") +def add_one(req: AddRequest, request: Request): + rows = watchlist.add(req.symbol, req.note) + return {"symbols": _with_names(rows, request)} + + +@router.post("/batch") +def add_batch(req: BatchAddRequest, request: Request): + for sym in req.symbols: + watchlist.add(sym, req.note) + return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)} + + +@router.post("/{symbol}/top") +def move_one_to_top(symbol: str, request: Request): + rows = watchlist.move_to_top(symbol) + return {"symbols": _with_names(rows, request)} + + +@router.delete("/{symbol}") +def remove_one(symbol: str, request: Request): + rows = watchlist.remove(symbol) + return {"symbols": _with_names(rows, request)} + + +@router.delete("") +def clear_all(): + """清空自选列表。""" + count = watchlist.clear() + return {"removed": count} + + +# 自选页需要的列 +_WATCHLIST_COLS = [ + "symbol", "close", "change_pct", "change_amount", "amount", + "turnover_rate", + "amplitude", "annual_vol_20d", + "vol_ratio_5d", + "ma5", "ma10", "ma20", "ma60", + "vol_ma5", "vol_ma10", + "high_60d", "low_60d", + "rsi_6", "rsi_14", "rsi_24", + "macd_dif", "macd_dea", "macd_hist", + "kdj_k", "kdj_d", "kdj_j", + "boll_upper", "boll_lower", + "atr_14", + "momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d", + "consecutive_limit_ups", "consecutive_limit_downs", + "signal_limit_up", "signal_limit_down", "signal_volume_surge", + "signal_ma_golden_5_20", "signal_macd_golden", "signal_n_day_high", + "signal_boll_breakout_upper", "signal_ma20_breakout", + "signal_ma_dead_5_20", "signal_macd_dead", "signal_n_day_low", + "signal_boll_breakdown_lower", "signal_ma20_breakdown", +] + + +@router.get("/enriched") +def watchlist_enriched( + request: Request, + ext_columns: str | None = Query(None, description="逗号分隔的 ext 列: config_id.field_name"), +): + """自选股 enriched 数据 — 直接从 enriched 最新日读取, 无即时计算。 + + ext_columns 参数示例: "industry_rating.score,fund_flow.net_inflow" + 会动态 LEFT JOIN 对应的 ext_{config_id} DuckDB view。 + """ + t0 = time.perf_counter() + + repo = request.app.state.repo + symbols = [r["symbol"] for r in watchlist.list_symbols()] + if not symbols: + return {"rows": [], "as_of": None, "elapsed_ms": 0} + + df_e, cache_date = repo.get_enriched_latest() + if df_e.is_empty(): + return {"rows": [], "as_of": None, "elapsed_ms": 0} + + # 按 symbol 过滤 + df = df_e.filter(pl.col("symbol").is_in(symbols)) + if df.is_empty(): + return {"rows": [], "as_of": str(cache_date) if cache_date else None, "elapsed_ms": 0} + + # JOIN instruments 取 name + float_shares + df_i = repo.get_instruments() + if not df_i.is_empty() and "name" in df_i.columns: + inst_cols = [c for c in ["symbol", "name", "float_shares"] if c in df_i.columns] + df = df.join(df_i.select(inst_cols), on="symbol", how="left") + + # 选择内置需要的列 + keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares"] if c in df.columns] + df = df.select(keep) + + # 动态 JOIN 扩展数据表 + ext_specs = _parse_ext_columns(ext_columns) if ext_columns else [] + if ext_specs: + db = repo.store.db + data_dir = repo.store.data_dir + from app.services.ext_data import ExtConfigStore + from app.api.ext_data import _read_ext_dataframe + + ext_store = ExtConfigStore(data_dir) + configs = {c.id: c for c in ext_store.load_all()} + + for config_id, field_name in ext_specs: + view_name = f"ext_{config_id}" + ext_col_name = f"{config_id}__{field_name}" + try: + # 扩展时序数据必须只取最新分区;否则一个 symbol 会按历史分区数被 JOIN 放大。 + cfg = configs.get(config_id) + if cfg: + ext_df, _ = _read_ext_dataframe(cfg, data_dir) + else: + ext_df = pl.from_arrow(db.query( + f"SELECT symbol, \"{field_name}\" FROM {view_name}" + ).arrow()) + if not ext_df.is_empty() and "symbol" in ext_df.columns: + ext_df = ( + ext_df + .select(["symbol", field_name]) + .unique(subset=["symbol"], keep="last") + .rename({field_name: ext_col_name}) + ) + df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left") + except Exception: + # view 不存在或字段不存在,尝试直接读 parquet + cfg = configs.get(config_id) + if cfg: + try: + ext_df, _ = _read_ext_dataframe(cfg, data_dir) + if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns: + ext_df = ( + ext_df + .select(["symbol", field_name]) + .unique(subset=["symbol"], keep="last") + .rename({field_name: ext_col_name}) + ) + df = df.join(ext_df, on="symbol", how="left") + except Exception as e2: + logger.debug("ext join fallback failed for %s.%s: %s", config_id, field_name, e2) + + # sanitize NaN / Inf + float_cols = [c for c in df.columns if df[c].dtype.is_float()] + if float_cols: + df = df.with_columns([ + pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite()) + .then(None) + .otherwise(pl.col(c)) + .alias(c) + for c in float_cols + ]) + + # 按自选添加顺序(新加的在前)重排行 + order_map = {s: i for i, s in enumerate(symbols)} + df = df.with_columns(pl.col("symbol").map_elements(lambda s: order_map.get(s, len(symbols)), return_dtype=pl.Int32).alias("_sort_order")) + df = df.sort("_sort_order").drop("_sort_order") + + rows = df.to_dicts() + elapsed = (time.perf_counter() - t0) * 1000 + return {"rows": rows, "as_of": str(cache_date) if cache_date else None, "elapsed_ms": elapsed} + + +def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]: + """解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]""" + result = [] + for part in ext_columns.split(","): + part = part.strip() + if "." not in part: + continue + config_id, field_name = part.split(".", 1) + config_id = config_id.strip() + field_name = field_name.strip() + if config_id and field_name: + result.append((config_id, field_name)) + return result diff --git a/serve/backend/app/backtest/__init__.py b/serve/backend/app/backtest/__init__.py new file mode 100644 index 0000000..adfd49b --- /dev/null +++ b/serve/backend/app/backtest/__init__.py @@ -0,0 +1,4 @@ +"""回测模块 — 因子回测 + 策略回测 + 信号回测。 + +架构: BacktestEngine (共享) → FactorBacktestService / StrategyBacktestService +""" diff --git a/serve/backend/app/backtest/engine.py b/serve/backend/app/backtest/engine.py new file mode 100644 index 0000000..943490c --- /dev/null +++ b/serve/backend/app/backtest/engine.py @@ -0,0 +1,1532 @@ +"""回测引擎 — 共享数据加载 + 撮合 + 统计计算。 + +纯 Polars/NumPy 实现,不依赖 pandas/vectorbt。 +""" +from __future__ import annotations + +import hashlib +import logging +import time +from collections import OrderedDict +from dataclasses import dataclass +from datetime import date +from typing import Callable + +logger = logging.getLogger(__name__) +from typing import Literal + +import numpy as np +import polars as pl + +from app.tickflow.repository import KlineRepository + +logger = logging.getLogger(__name__) + + +# ================================================================ +# 数据结构 +# ================================================================ + +@dataclass +class MatcherConfig: + # matching 为向后兼容入口: 仅传 matching 时, entry_fill/exit_fill 都取 matching 的值。 + # 显式传入 entry_fill/exit_fill 时以二者为准 (允许建仓/清仓口径不同)。 + matching: Literal["close_t", "open_t+1"] = "close_t" + entry_fill: Literal["close_t", "open_t+1"] | None = None + exit_fill: Literal["close_t", "open_t+1"] | None = None + fees_pct: float = 0.0002 + slippage_bps: float = 5.0 + 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 + max_hold_days: int | None = None + max_positions: int = 10 + max_exposure_pct: float = 1.0 + score_min: float | None = None + score_max: float | None = None + initial_capital: float = 1_000_000.0 + position_sizing: Literal["equal", "score_weight"] = "equal" + + def __post_init__(self) -> None: + # 解析最终口径: 优先 entry_fill/exit_fill, 否则回退到 matching (向后兼容)。 + if self.entry_fill is None: + self.entry_fill = self.matching + if self.exit_fill is None: + self.exit_fill = self.matching + + +@dataclass +class TradeRecord: + symbol: str + entry_date: date + exit_date: date + entry_price: float + exit_price: float + pnl_pct: float + duration: int + 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 + lots: float = 0.0 + position_pct: float = 0.0 + entry_value: float = 0.0 + exit_value: float = 0.0 + pnl_amount: float = 0.0 + entry_score: float | None = None + entry_signal_date: date | str | None = None + exit_signal_date: date | str | None = None + blocked_exit_days: int = 0 + + +@dataclass +class SimResult: + equity_curve: list[dict] # [{date, value}] + drawdown_curve: list[dict] # [{date, value}] + trades: list[TradeRecord] + per_symbol_stats: list[dict] + stats: dict + + +# ================================================================ +# PanelCache — 避免重复 scan_parquet + compute_all +# ================================================================ + +class _CacheEntry: + __slots__ = ("df", "ts") + + def __init__(self, df: pl.DataFrame, ts: float): + self.df = df + self.ts = ts + + +class PanelCache: + """LRU + TTL 数据面板缓存。""" + + def __init__(self, max_size: int = 2, ttl_seconds: int = 180): + self._cache: OrderedDict[str, _CacheEntry] = OrderedDict() + self._max_size = max_size + self._ttl = ttl_seconds + + def get_or_compute( + self, + symbols: list[str] | None, + start: date, + end: date, + columns: list[str] | None, + compute_fn, + ) -> pl.DataFrame: + key = self._make_key(symbols, start, end, columns) + now = time.monotonic() + + if key in self._cache: + entry = self._cache[key] + if now - entry.ts < self._ttl: + self._cache.move_to_end(key) + return entry.df + del self._cache[key] + + df = compute_fn(symbols, start, end, columns) + self._cache[key] = _CacheEntry(df=df, ts=now) + if len(self._cache) > self._max_size: + self._cache.popitem(last=False) + return df + + def invalidate(self) -> None: + self._cache.clear() + + @staticmethod + def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | None) -> str: + if symbols is None: + h = "all" + else: + h = hashlib.md5(",".join(sorted(symbols)).encode()).hexdigest()[:12] + cols = "all" if columns is None else hashlib.md5(",".join(sorted(columns)).encode()).hexdigest()[:8] + return f"{h}:{start}:{end}:{cols}" + + +# ================================================================ +# BacktestEngine +# ================================================================ + +class BacktestEngine: + """回测引擎 — 数据加载 + 撮合模拟 + 统计计算。""" + + def __init__(self, repo: KlineRepository) -> None: + self.repo = repo + self._cache = PanelCache() + + # ── 数据加载 ────────────────────────────────────── + + def load_panel( + self, + symbols: list[str] | None, + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + """加载 enriched 数据面板,带缓存。""" + return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner) + + def _load_panel_inner( + self, + symbols: list[str] | None, + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + t0 = time.perf_counter() + + # 近期区间优先复用 repository 的预计算 enriched 历史缓存,避免重复 scan_parquet + compute_all。 + try: + if self.repo is not None and hasattr(self.repo, "get_enriched_range"): + cached = self.repo.get_enriched_range(start, end, symbols=symbols, columns=columns) + if cached is not None and not cached.is_empty(): + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("load_panel(cache): %.0fms, %d rows, %d columns", elapsed, len(cached), len(cached.columns)) + return cached + except Exception as e: # noqa: BLE001 + logger.debug("backtest load panel cache miss: %s", e) + + enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet") + + try: + lf = pl.scan_parquet(enriched_glob) + if symbols is not None: + lf = lf.filter(pl.col("symbol").is_in(symbols)) + if columns is not None: + available = set(lf.collect_schema().names()) + selected = [c for c in columns if c in available] + if "symbol" not in selected and "symbol" in available: + selected.insert(0, "symbol") + if "date" not in selected and "date" in available: + selected.insert(1, "date") + lf = lf.select(selected) + df = ( + lf.filter( + (pl.col("date") >= start) + & (pl.col("date") <= end) + ) + .sort(["symbol", "date"]) + .collect(streaming=True) + ) + except Exception as e: + logger.warning("backtest load panel failed: %s", e) + return pl.DataFrame() + + if df.is_empty(): + return df + + if columns is not None: + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("load_panel: %.0fms, %d rows, %d columns", elapsed, len(df), len(df.columns)) + return df + + from app.indicators.pipeline import compute_all + instruments = self.repo.get_instruments() + df = compute_all(df, instruments=instruments) + if not instruments.is_empty() and "name" not in df.columns: + inst_cols = [c for c in ["symbol", "name"] if c in instruments.columns] + if len(inst_cols) == 2: + df = df.join( + instruments.select(inst_cols).unique(subset=["symbol"]), + on="symbol", + how="left", + ) + + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("load_panel: %.0fms, %d rows", elapsed, len(df)) + return df + + # ── 撮合模拟 ────────────────────────────────────── + + def simulate( + self, + panel: pl.DataFrame, + entries: pl.Series | None, + exits: pl.Series | None, + config: MatcherConfig, + ) -> SimResult: + """纯 NumPy 撮合模拟 — 逐 symbol 状态机。""" + if panel.is_empty(): + return self._empty_result() + + n = len(panel) + panel_dates = panel["date"].to_numpy() + panel_symbols = panel["symbol"].to_numpy() + + # 构建信号数组 + ent = np.zeros(n, dtype=bool) + ext = np.zeros(n, dtype=bool) + if entries is not None and len(entries) == n: + ent = entries.to_numpy().astype(bool) + if exits is not None and len(exits) == n: + ext = exits.to_numpy().astype(bool) + + if not ent.any(): + return self._empty_result() + + # 成交口径: entry/exit 可分别配置 close_t (信号当日收盘) 或 open_t+1 (次日开盘)。 + # open_t+1 时信号右移 1 天 (用前一根的信号 + 当根的 open 成交)。 + open_prices = panel["open"].to_numpy() + close_prices = panel["close"].to_numpy() + + # 同一 symbol 内相邻行掩码, 跨 symbol 边界不允许 shift (避免错配)。 + same_prev_symbol = np.zeros(n, dtype=bool) + same_prev_symbol[1:] = panel_symbols[1:] == panel_symbols[:-1] + + entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices + exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices + + if config.entry_fill == "open_t+1": + ent_s = np.zeros(n, dtype=bool) + ent_s[1:] = ent[:-1] & same_prev_symbol + ent = ent_s + if config.exit_fill == "open_t+1": + ext_s = np.zeros(n, dtype=bool) + ext_s[1:] = ext[:-1] & same_prev_symbol + ext = ext_s + + # 逐 symbol 撮合 + trades: list[TradeRecord] = [] + unique_symbols = np.unique(panel_symbols) + + for sym in unique_symbols: + mask = panel_symbols == sym + sym_ent = ent[mask] + sym_ext = ext[mask] + sym_entry_prices = entry_prices[mask] + sym_exit_prices = exit_prices[mask] + sym_close = close_prices[mask] + sym_dates = panel_dates[mask] + + holding = False + entry_idx = -1 + entry_price = 0.0 + hold_days = 0 + + for i in range(len(sym_ent)): + if not holding: + if sym_ent[i]: + holding = True + entry_idx = i + entry_price = float(sym_entry_prices[i]) + hold_days = 0 + else: + hold_days += 1 + exit_triggered = False + exit_reason = "" + + # 止损 — 用当日 close 检测 (优先级最高) + if config.stop_loss_pct is not None: + pnl = (float(sym_close[i]) - entry_price) / entry_price + if pnl <= -abs(config.stop_loss_pct): + exit_triggered = True + exit_reason = "stop_loss" + + # 信号退出 (优先于 max_hold: 卖点信号是策略主动离场) + if not exit_triggered and sym_ext[i]: + exit_triggered = True + exit_reason = "signal" + + # 最大持仓天数 (兜底: 无信号/未止损时强制平仓) + if not exit_triggered and config.max_hold_days is not None: + if hold_days >= config.max_hold_days: + exit_triggered = True + exit_reason = "max_hold" + + if exit_triggered: + exit_price = float(sym_exit_prices[i]) + pnl_pct = (exit_price - entry_price) / entry_price if entry_price > 0 else 0.0 + fee_cost = config.fees_pct * 2 + config.slippage_bps / 10000.0 * 2 + pnl_pct -= fee_cost + + e_date = sym_dates[entry_idx] + x_date = sym_dates[i] + trades.append(TradeRecord( + symbol=str(sym), + entry_date=e_date.item() if hasattr(e_date, "item") else e_date, + exit_date=x_date.item() if hasattr(x_date, "item") else x_date, + entry_price=round(entry_price, 4), + exit_price=round(exit_price, 4), + pnl_pct=round(pnl_pct, 6), + duration=int(hold_days), + exit_reason=exit_reason, + )) + holding = False + + # 净值曲线: 按出场日期归集收益 + all_dates_sorted = np.sort(np.unique(panel_dates)) + equity_curve, drawdown_curve = self._build_curves(trades, all_dates_sorted, config.initial_capital) + + # 统计 + date_min = panel_dates.min() + date_max = panel_dates.max() + d_min = date_min.item() if hasattr(date_min, "item") else date_min + d_max = date_max.item() if hasattr(date_max, "item") else date_max + stats = self._calc_stats(trades, config.initial_capital, d_min, d_max) + per_symbol = self._calc_per_symbol(trades) + + return SimResult( + equity_curve=equity_curve, + drawdown_curve=drawdown_curve, + trades=trades, + per_symbol_stats=per_symbol, + stats=stats, + ) + + def simulate_independent_candidates( + self, + panel: pl.DataFrame, + entries: pl.Series | None, + exits: pl.Series | None, + config: MatcherConfig, + progress_cb: "Callable[[dict], None] | None" = None, + cancel_event: "threading.Event | None" = None, + ) -> SimResult: + """全量候选独立执行:每个买入信号都是独立样本, 不受资金/仓位限制。""" + if panel.is_empty(): + return self._empty_result() + + n = len(panel) + panel_dates = panel["date"].to_numpy() + panel_symbols = panel["symbol"].to_numpy() + + ent_raw = np.zeros(n, dtype=bool) + ext_raw = np.zeros(n, dtype=bool) + if entries is not None and len(entries) == n: + ent_raw = entries.to_numpy().astype(bool) + if exits is not None and len(exits) == n: + ext_raw = exits.to_numpy().astype(bool) + n_candidates = int(ent_raw.sum()) + if n_candidates <= 0: + return self._empty_result() + + entry_signal_dates = np.array([None] * n, dtype=object) + exit_signal_dates = np.array([None] * n, dtype=object) + same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1] + + # 建仓口径: close_t 用信号日收盘, open_t+1 右移到次日 open 成交。 + ent = np.zeros(n, dtype=bool) + if config.entry_fill == "open_t+1": + ent[1:] = ent_raw[:-1] & same_prev_symbol + for idx in np.flatnonzero(ent): + entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) + else: + ent = ent_raw + for idx in np.flatnonzero(ent): + entry_signal_dates[idx] = self._date_str(panel_dates[idx]) + + # 清仓口径: 独立于建仓, close_t 用信号日收盘, open_t+1 右移到次日 open。 + ext = np.zeros(n, dtype=bool) + if config.exit_fill == "open_t+1": + ext[1:] = ext_raw[:-1] & same_prev_symbol + for idx in np.flatnonzero(ext): + exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) + else: + ext = ext_raw + for idx in np.flatnonzero(ext): + exit_signal_dates[idx] = self._date_str(panel_dates[idx]) + + open_prices = panel["open"].to_numpy() + high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices + low_prices = panel["low"].to_numpy() + close_prices = panel["close"].to_numpy() + # 撮合价: 建仓/清仓各自独立选列。 + entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices + exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices + has_volume = "volume" in panel.columns + volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float) + names = panel["name"].fill_null("").to_numpy() if "name" in panel.columns else np.array([""] * n) + scores = panel["score"].fill_null(0).to_numpy() if "score" in panel.columns else np.zeros(n, dtype=float) + trade_scores = scores.copy() + # 评分跟随建仓口径 shift (评分在买入日生效)。 + if config.entry_fill == "open_t+1": + trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:]) + limit_up_flags = ( + panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool) + if "signal_limit_up" in panel.columns else np.zeros(n, dtype=bool) + ) + limit_down_flags = ( + panel["signal_limit_down"].fill_null(False).to_numpy().astype(bool) + if "signal_limit_down" in panel.columns else np.zeros(n, dtype=bool) + ) + + symbol_rows: dict[str, list[int]] = {} + row_pos_in_symbol = np.zeros(n, dtype=int) + for i, sym_value in enumerate(panel_symbols): + sym = str(sym_value) + rows = symbol_rows.setdefault(sym, []) + row_pos_in_symbol[i] = len(rows) + rows.append(i) + + buy_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 + sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 + score_min = getattr(config, "score_min", None) + score_max = getattr(config, "score_max", None) + trades: list[TradeRecord] = [] + execution_stats: dict[str, int] = { + "buy_invalid_price": 0, + "buy_suspended": 0, + "buy_limit_up": 0, + "buy_score_filter": 0, + "buy_no_next_bar": max(n_candidates - int(ent.sum()), 0), + "sell_invalid_price": 0, + "sell_suspended": 0, + "sell_limit_down": 0, + "sell_no_future": 0, + "pending_exit": 0, + } + + def _count(key: str) -> None: + execution_stats[key] = execution_stats.get(key, 0) + 1 + + def _valid_price(value) -> bool: + try: + v = float(value) + except (TypeError, ValueError): + return False + return v > 0 and np.isfinite(v) + + def _is_suspended(idx: int) -> bool: + o = float(open_prices[idx]) + h = float(high_prices[idx]) + l = float(low_prices[idx]) + c = float(close_prices[idx]) + valid_bar = any(_valid_price(x) for x in (o, h, l, c)) + if not valid_bar: + return True + if has_volume and float(volumes[idx] or 0) <= 0: + same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01) + if same_price: + return True + return False + + def _is_one_price_limit(idx: int, direction: str) -> bool: + if _is_suspended(idx): + return False + o = float(open_prices[idx]) + h = float(high_prices[idx]) + l = float(low_prices[idx]) + c = float(close_prices[idx]) + if not all(_valid_price(x) for x in (o, h, l, c)): + return False + same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01) + if direction == "up": + return bool(limit_up_flags[idx]) and same_price + return bool(limit_down_flags[idx]) and same_price + + def _can_buy(idx: int) -> tuple[bool, str]: + if _is_suspended(idx): + return False, "buy_suspended" + if not _valid_price(entry_prices[idx]): + return False, "buy_invalid_price" + if _is_one_price_limit(idx, "up"): + return False, "buy_limit_up" + return True, "" + + def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]: + if _is_suspended(idx): + return False, "sell_suspended" + exit_price = exit_price_override if exit_price_override is not None else exit_prices[idx] + if not _valid_price(exit_price): + return False, "sell_invalid_price" + if _is_one_price_limit(idx, "down"): + return False, "sell_limit_down" + return True, "" + + def _risk_exit(pos: dict, idx: int) -> tuple[str | None, float | None]: + if pos.get("pending_exit_reason") or pos.get("entry_idx") == idx: + return None, None + entry_price = float(pos["entry_price"]) + if entry_price <= 0: + 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]] = [] + + if config.stop_loss_pct is not None: + risk_lines.append((entry_price * (1 - abs(config.stop_loss_pct)), "stop_loss")) + if config.trailing_stop_pct is not None and peak_price > 0: + risk_lines.append((peak_price * (1 - abs(config.trailing_stop_pct)), "trailing_stop")) + + activate_pct = getattr(config, "trailing_take_profit_activate_pct", None) + drawdown_pct = getattr(config, "trailing_take_profit_drawdown_pct", None) + if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price: + peak_profit = peak_price / entry_price - 1 + if peak_profit >= abs(float(activate_pct)): + 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 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: + ok, block_reason = _can_sell(idx, exit_price_override) + if not ok: + if not pos.get("pending_exit_reason"): + pos["pending_exit_reason"] = reason + pos["pending_exit_signal_date"] = signal_date + _count("pending_exit") + pos["blocked_exit_days"] = int(pos.get("blocked_exit_days", 0)) + 1 + _count(block_reason) + return False + + exit_price = float(exit_price_override) if exit_price_override is not None else float(exit_prices[idx]) + shares = 100.0 + entry_value = shares * float(pos["entry_price"]) * (1 + buy_cost_pct) + exit_value = shares * exit_price * (1 - sell_cost_pct) + pnl_amount = exit_value - entry_value + pnl_pct = pnl_amount / entry_value if entry_value > 0 else 0.0 + trades.append(TradeRecord( + symbol=str(pos["symbol"]), + name=str(pos.get("name", "")), + entry_date=pos["entry_date"], + exit_date=self._date_str(panel_dates[idx]), + entry_price=round(float(pos["entry_price"]), 4), + exit_price=round(exit_price, 4), + pnl_pct=round(float(pnl_pct), 6), + duration=int(pos["hold_days"]), + exit_reason=reason, + shares=shares, + lots=1.0, + position_pct=0.0, + entry_value=round(float(entry_value), 2), + exit_value=round(float(exit_value), 2), + pnl_amount=round(float(pnl_amount), 2), + entry_score=round(float(pos["entry_score"]), 2) if pos.get("entry_score") is not None else None, + entry_signal_date=pos.get("entry_signal_date"), + exit_signal_date=signal_date, + blocked_exit_days=int(pos.get("blocked_exit_days", 0)), + )) + return True + + candidate_indices = np.flatnonzero(ent) + for seq, entry_idx in enumerate(candidate_indices, start=1): + if cancel_event is not None and cancel_event.is_set(): + logger.info("全量模拟被用户取消 (第 %d/%d 个候选)", seq, len(candidate_indices)) + break + if progress_cb is not None and (seq == 1 or seq % 500 == 0): + try: + progress_cb({ + "day": seq, + "total": len(candidate_indices), + "date": self._date_str(panel_dates[entry_idx]), + "equity": 0, + }) + except Exception: + pass + + ok, block_reason = _can_buy(entry_idx) + if not ok: + _count(block_reason) + continue + score = float(trade_scores[entry_idx] or 0.0) + if score_min is not None and score < score_min: + _count("buy_score_filter") + continue + if score_max is not None and score > score_max: + _count("buy_score_filter") + continue + + sym = str(panel_symbols[entry_idx]) + rows = symbol_rows.get(sym, []) + start_pos = int(row_pos_in_symbol[entry_idx]) + if start_pos >= len(rows): + _count("sell_no_future") + continue + + entry_price = float(entry_prices[entry_idx]) + pos = { + "symbol": sym, + "name": str(names[entry_idx] or ""), + "entry_idx": entry_idx, + "entry_date": self._date_str(panel_dates[entry_idx]), + "entry_signal_date": entry_signal_dates[entry_idx] or self._date_str(panel_dates[entry_idx]), + "entry_price": entry_price, + "entry_score": score, + "hold_days": 0, + "max_high": entry_price, + "pending_exit_reason": None, + "pending_exit_signal_date": None, + "blocked_exit_days": 0, + } + hi = float(high_prices[entry_idx]) + if _valid_price(hi): + pos["max_high"] = max(float(pos["max_high"]), hi) + + closed = False + last_idx = entry_idx + for idx in rows[start_pos + 1:]: + last_idx = idx + pos["hold_days"] = int(pos["hold_days"]) + 1 + d_str = self._date_str(panel_dates[idx]) + + def _scheduled_reason() -> tuple[str | None, str]: + if pos.get("pending_exit_reason"): + return str(pos["pending_exit_reason"]), str(pos.get("pending_exit_signal_date") or d_str) + # 卖点信号优先于到期: 策略主动离场先于 max_hold 兜底。 + if ext[idx]: + return "signal", str(exit_signal_dates[idx] or d_str) + if config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days: + return "max_hold", d_str + if idx == rows[-1]: + return "end", d_str + return None, d_str + + # 统一退出顺序: 风控(止损/移动止损/止盈)先于计划出场 (signal/max_hold/end)。 + # 无论 entry/exit 口径如何, 风控都是保护性离场, 必须最高优先级。 + reason, override_price = _risk_exit(pos, idx) + if reason and _try_close(pos, idx, reason, d_str, override_price): + closed = True + break + reason, signal_date = _scheduled_reason() + if reason and _try_close(pos, idx, reason, signal_date): + closed = True + break + + hi = float(high_prices[idx]) + if _valid_price(hi): + pos["max_high"] = max(float(pos.get("max_high", entry_price)), hi) + + if not closed: + if last_idx == entry_idx: + _count("sell_no_future") + elif not pos.get("pending_exit_reason"): + _try_close(pos, last_idx, "end", self._date_str(panel_dates[last_idx])) + + return self._calc_independent_candidate_result(trades, n_candidates, execution_stats) + + def simulate_portfolio( + self, + panel: pl.DataFrame, + entries: pl.Series | None, + exits: pl.Series | None, + config: MatcherConfig, + progress_cb: "Callable[[dict], None] | None" = None, + cancel_event: "threading.Event | None" = None, + ) -> SimResult: + """账户级组合回测:日线信号 → 成交约束 → 仓位/现金撮合。""" + if panel.is_empty(): + return self._empty_result() + + n = len(panel) + panel_dates = panel["date"].to_numpy() + panel_symbols = panel["symbol"].to_numpy() + + ent_raw = np.zeros(n, dtype=bool) + ext_raw = np.zeros(n, dtype=bool) + if entries is not None and len(entries) == n: + ent_raw = entries.to_numpy().astype(bool) + if exits is not None and len(exits) == n: + ext_raw = exits.to_numpy().astype(bool) + if not ent_raw.any(): + return self._empty_result() + + entry_signal_dates = np.array([None] * n, dtype=object) + exit_signal_dates = np.array([None] * n, dtype=object) + same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1] + + # 建仓口径: close_t 用信号日收盘, open_t+1 右移到次日 open 成交。 + ent = np.zeros(n, dtype=bool) + if config.entry_fill == "open_t+1": + ent[1:] = ent_raw[:-1] & same_prev_symbol + for idx in np.flatnonzero(ent): + entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) + else: + ent = ent_raw + for idx in np.flatnonzero(ent): + entry_signal_dates[idx] = self._date_str(panel_dates[idx]) + + # 清仓口径: 独立于建仓。 + ext = np.zeros(n, dtype=bool) + if config.exit_fill == "open_t+1": + ext[1:] = ext_raw[:-1] & same_prev_symbol + for idx in np.flatnonzero(ext): + exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) + else: + ext = ext_raw + for idx in np.flatnonzero(ext): + exit_signal_dates[idx] = self._date_str(panel_dates[idx]) + + open_prices = panel["open"].to_numpy() + high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices + low_prices = panel["low"].to_numpy() + close_prices = panel["close"].to_numpy() + # 撮合价: 建仓/清仓各自独立选列。 + entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices + exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices + has_volume = "volume" in panel.columns + volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float) + names = ( + panel["name"].fill_null("").to_numpy() + if "name" in panel.columns else np.array([""] * n) + ) + scores = ( + panel["score"].fill_null(0).to_numpy() + if "score" in panel.columns else np.zeros(n, dtype=float) + ) + trade_scores = scores.copy() + # 评分跟随建仓口径 shift (评分在买入日生效)。 + if config.entry_fill == "open_t+1": + trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:]) + limit_up_flags = ( + panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool) + if "signal_limit_up" in panel.columns else np.zeros(n, dtype=bool) + ) + limit_down_flags = ( + panel["signal_limit_down"].fill_null(False).to_numpy().astype(bool) + if "signal_limit_down" in panel.columns else np.zeros(n, dtype=bool) + ) + + date_to_indices: dict[str, list[int]] = {} + for i, d in enumerate(panel_dates): + d_str = self._date_str(d) + date_to_indices.setdefault(d_str, []).append(i) + all_dates = sorted(date_to_indices.keys()) + if not all_dates: + return self._empty_result() + + buy_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 + sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 + cash = float(config.initial_capital) + peak = cash + max_positions = max(int(config.max_positions), 0) + max_exposure_pct = min(max(float(getattr(config, "max_exposure_pct", 1.0)), 0.0), 1.0) + score_min = getattr(config, "score_min", None) + score_max = getattr(config, "score_max", None) + positions: dict[str, dict] = {} + last_close: dict[str, float] = {} + trades: list[TradeRecord] = [] + equity_curve: list[dict] = [] + drawdown_curve: list[dict] = [] + execution_stats: dict[str, int] = { + "buy_invalid_price": 0, + "buy_suspended": 0, + "buy_limit_up": 0, + "buy_no_slot": 0, + "buy_cash": 0, + "buy_lot_size": 0, + "buy_same_day_reentry": 0, + "buy_exposure": 0, + "buy_score_filter": 0, + "sell_invalid_price": 0, + "sell_suspended": 0, + "sell_limit_down": 0, + "pending_exit": 0, + } + + def _count(key: str) -> None: + execution_stats[key] = execution_stats.get(key, 0) + 1 + + def _valid_price(value) -> bool: + try: + v = float(value) + except (TypeError, ValueError): + return False + return v > 0 and np.isfinite(v) + + def _market_value() -> float: + value = 0.0 + for pos in positions.values(): + mark = last_close.get(pos["symbol"], pos["entry_price"]) + value += pos["shares"] * mark + return value + + def _is_suspended(idx: int) -> bool: + o = float(open_prices[idx]) + h = float(high_prices[idx]) + l = float(low_prices[idx]) + c = float(close_prices[idx]) + valid_bar = any(_valid_price(x) for x in (o, h, l, c)) + if not valid_bar: + return True + if has_volume and float(volumes[idx] or 0) <= 0: + same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01) + if same_price: + return True + return False + + def _is_one_price_limit(idx: int, direction: str) -> bool: + if _is_suspended(idx): + return False + o = float(open_prices[idx]) + h = float(high_prices[idx]) + l = float(low_prices[idx]) + c = float(close_prices[idx]) + if not all(_valid_price(x) for x in (o, h, l, c)): + return False + same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01) + if direction == "up": + return bool(limit_up_flags[idx]) and same_price + return bool(limit_down_flags[idx]) and same_price + + def _can_buy(idx: int) -> tuple[bool, str]: + if _is_suspended(idx): + return False, "buy_suspended" + if not _valid_price(entry_prices[idx]): + return False, "buy_invalid_price" + if _is_one_price_limit(idx, "up"): + return False, "buy_limit_up" + return True, "" + + def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]: + if _is_suspended(idx): + return False, "sell_suspended" + exit_price = exit_price_override if exit_price_override is not None else exit_prices[idx] + if not _valid_price(exit_price): + return False, "sell_invalid_price" + if _is_one_price_limit(idx, "down"): + return False, "sell_limit_down" + return True, "" + + def _mark_pending(sym: str, reason: str, signal_date: str) -> None: + pos = positions[sym] + if not pos.get("pending_exit_reason"): + pos["pending_exit_reason"] = reason + pos["pending_exit_signal_date"] = signal_date + _count("pending_exit") + pos["blocked_exit_days"] = int(pos.get("blocked_exit_days", 0)) + 1 + + def _sell( + sym: str, + idx: int, + reason: str, + signal_date: str, + sold_today: set[str], + exit_price_override: float | None = None, + ) -> None: + nonlocal cash + pos = positions.pop(sym) + exit_price = float(exit_price_override) if exit_price_override is not None else float(exit_prices[idx]) + exit_value = pos["shares"] * exit_price * (1 - sell_cost_pct) + cash += exit_value + pnl_amount = exit_value - pos["entry_value"] + pnl_pct = (exit_value - pos["entry_value"]) / pos["entry_value"] if pos["entry_value"] > 0 else 0.0 + sold_today.add(sym) + trades.append(TradeRecord( + symbol=sym, + name=pos.get("name", ""), + entry_date=pos["entry_date"], + exit_date=self._date_str(panel_dates[idx]), + entry_price=round(float(pos["entry_price"]), 4), + exit_price=round(exit_price, 4), + pnl_pct=round(float(pnl_pct), 6), + duration=int(pos["hold_days"]), + exit_reason=reason, + shares=round(float(pos["shares"]), 4), + lots=round(float(pos["lots"]), 2), + position_pct=round(float(pos.get("position_pct", 0.0)), 6), + entry_value=round(float(pos["entry_value"]), 2), + exit_value=round(float(exit_value), 2), + pnl_amount=round(float(pnl_amount), 2), + entry_score=round(float(pos["entry_score"]), 2) if pos.get("entry_score") is not None else None, + entry_signal_date=pos.get("entry_signal_date"), + exit_signal_date=signal_date, + blocked_exit_days=int(pos.get("blocked_exit_days", 0)), + )) + + def _try_sell( + sym: str, + idx: int | None, + reason: str, + signal_date: str, + sold_today: set[str], + exit_price_override: float | None = None, + ) -> bool: + if idx is None: + _mark_pending(sym, reason, signal_date) + _count("sell_suspended") + return False + ok, block_reason = _can_sell(idx, exit_price_override) + if not ok: + _mark_pending(sym, reason, signal_date) + _count(block_reason) + return False + _sell(sym, idx, reason, signal_date, sold_today, exit_price_override) + return True + + def _process_scheduled_exits( + d_idx: int, + d_str: str, + row_by_symbol: dict[str, int], + sold_today: set[str], + ) -> None: + for sym in list(positions.keys()): + pos = positions.get(sym) + if pos is None: + continue + idx = row_by_symbol.get(sym) + reason = "" + signal_date = d_str + if pos.get("pending_exit_reason"): + reason = str(pos["pending_exit_reason"]) + signal_date = str(pos.get("pending_exit_signal_date") or d_str) + # 卖点信号优先于到期: 策略主动离场先于 max_hold 兜底。 + elif idx is not None and ext[idx]: + reason = "signal" + signal_date = str(exit_signal_dates[idx] or d_str) + elif config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days: + reason = "max_hold" + elif d_idx == len(all_dates) - 1: + reason = "end" + if reason: + _try_sell(sym, idx, reason, signal_date, sold_today) + + def _process_risk_exits(d_str: str, row_by_symbol: dict[str, int], sold_today: set[str]) -> None: + for sym in list(positions.keys()): + pos = positions.get(sym) + if pos is None or pos.get("pending_exit_reason"): + continue + if pos.get("entry_date") == d_str: + continue + idx = row_by_symbol.get(sym) + if idx is None or pos["entry_price"] <= 0: + 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]] = [] + + if config.stop_loss_pct is not None: + risk_lines.append((entry_price * (1 - abs(config.stop_loss_pct)), "stop_loss")) + + if config.trailing_stop_pct is not None and peak_price > 0: + risk_lines.append((peak_price * (1 - abs(config.trailing_stop_pct)), "trailing_stop")) + + activate_pct = getattr(config, "trailing_take_profit_activate_pct", None) + drawdown_pct = getattr(config, "trailing_take_profit_drawdown_pct", None) + if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price: + peak_profit = peak_price / entry_price - 1 + if peak_profit >= abs(float(activate_pct)): + 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 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, + idxs: list[int], + sold_today: set[str], + ) -> None: + nonlocal cash + if max_positions <= 0: + return + candidates: list[tuple[int, str, float]] = [] + for idx in idxs: + if not ent[idx]: + continue + sym = str(panel_symbols[idx]) + if sym in positions: + continue + if sym in sold_today: + _count("buy_same_day_reentry") + continue + ok, block_reason = _can_buy(idx) + if not ok: + _count(block_reason) + continue + score = float(trade_scores[idx] or 0.0) + if score_min is not None and score < score_min: + _count("buy_score_filter") + continue + if score_max is not None and score > score_max: + _count("buy_score_filter") + continue + candidates.append((idx, sym, score)) + if not candidates: + return + candidates.sort(key=lambda x: x[2], reverse=True) + + slots = max_positions - len(positions) + if slots <= 0: + execution_stats["buy_no_slot"] += len(candidates) + return + + selected = candidates[:slots] + market_value_before = _market_value() + account_equity_before_buy = cash + market_value_before + if account_equity_before_buy <= 0 or max_exposure_pct <= 0: + execution_stats["buy_exposure"] += len(selected) + return + target_position_value = account_equity_before_buy * max_exposure_pct / max_positions + max_exposure_value = account_equity_before_buy * max_exposure_pct + exposure_capacity = max_exposure_value - market_value_before + if exposure_capacity <= 0: + execution_stats["buy_exposure"] += len(selected) + return + + weights = np.repeat(1 / len(selected), len(selected)) + if config.position_sizing == "score_weight": + raw = np.array([max(x[2], 0.0) for x in selected], dtype=float) + if raw.sum() > 0: + weights = raw / raw.sum() + total_budget = min(cash, exposure_capacity, target_position_value * len(selected)) + + for (idx, sym, _score), weight in zip(selected, weights): + if len(positions) >= max_positions: + _count("buy_no_slot") + break + current_market_value = _market_value() + current_equity = cash + current_market_value + current_exposure_capacity = current_equity * max_exposure_pct - current_market_value + allocation = min(total_budget * float(weight), target_position_value, cash, current_exposure_capacity) + if allocation <= 0: + _count("buy_exposure") + continue + entry_price = float(entry_prices[idx]) + shares = np.floor(allocation / (entry_price * (1 + buy_cost_pct)) / 100) * 100 + entry_value = shares * entry_price * (1 + buy_cost_pct) + if shares <= 0: + _count("buy_lot_size") + continue + if entry_value > cash + 1e-6: + _count("buy_cash") + continue + if entry_value > current_exposure_capacity + 1e-6: + _count("buy_exposure") + continue + cash -= entry_value + positions[sym] = { + "symbol": sym, + "name": str(names[idx] or ""), + "entry_date": self._date_str(panel_dates[idx]), + "entry_signal_date": entry_signal_dates[idx] or self._date_str(panel_dates[idx]), + "entry_price": entry_price, + "entry_value": entry_value, + "shares": shares, + "lots": shares / 100, + "position_pct": entry_value / account_equity_before_buy if account_equity_before_buy > 0 else 0.0, + "entry_score": _score, + "max_high": entry_price, + "hold_days": 0, + "pending_exit_reason": None, + "pending_exit_signal_date": None, + "blocked_exit_days": 0, + } + + for d_idx, d_str in enumerate(all_dates): + if d_idx % 20 == 0: + if cancel_event is not None and cancel_event.is_set(): + logger.info("回测被用户取消 (第 %d/%d 天)", d_idx, len(all_dates)) + break + if progress_cb is not None: + try: + progress_cb({ + "day": d_idx + 1, + "total": len(all_dates), + "date": str(d_str)[:10], + "equity": round(cash + _market_value(), 2), + }) + except Exception: + pass + + idxs = date_to_indices[d_str] + row_by_symbol = {str(panel_symbols[i]): i for i in idxs} + sold_today: set[str] = set() + + for pos in positions.values(): + pos["hold_days"] += 1 + + # 统一执行顺序 (不分口径): 风控(止损/移动止损/止盈) → 计划出场(signal/max_hold/end) → 建仓。 + # 风控是保护性离场, 必须最先; 计划出场次之; 建仓最后 (卖出释放的现金/仓位先用于满足新买)。 + # 当天新建仓不会被风控误杀 (_process_risk_exits 跳过 entry_date == d_str 的仓位)。 + _process_risk_exits(d_str, row_by_symbol, sold_today) + _process_scheduled_exits(d_idx, d_str, row_by_symbol, sold_today) + if d_idx < len(all_dates) - 1: + _process_entries(d_str, idxs, sold_today) + + for sym, pos in positions.items(): + idx = row_by_symbol.get(sym) + if idx is not None: + hi = float(high_prices[idx]) + if _valid_price(hi): + pos["max_high"] = max(float(pos.get("max_high", pos["entry_price"])), hi) + + for i in idxs: + c = float(close_prices[i]) + if c > 0 and np.isfinite(c): + last_close[str(panel_symbols[i])] = c + + market_value = _market_value() + equity = cash + market_value + peak = max(peak, equity) + dd = (equity - peak) / peak if peak > 0 else 0.0 + exposure = market_value / equity if equity > 0 else 0.0 + equity_curve.append({ + "date": d_str[:10], + "value": round(float(equity), 2), + "cash": round(float(cash), 2), + "positions": len(positions), + "exposure": round(float(exposure), 4), + }) + drawdown_curve.append({"date": d_str[:10], "value": round(float(dd), 4)}) + + stats = self._calc_portfolio_stats(equity_curve, trades, config.initial_capital) + stats["execution"] = execution_stats + stats["pending_exit_positions"] = sum(1 for p in positions.values() if p.get("pending_exit_reason")) + per_symbol = self._calc_per_symbol(trades) + return SimResult( + equity_curve=equity_curve, + drawdown_curve=drawdown_curve, + trades=trades, + per_symbol_stats=per_symbol, + stats=stats, + ) + + # ── 净值曲线 ────────────────────────────────────── + + @staticmethod + def _build_curves( + trades: list[TradeRecord], + all_dates: np.ndarray, + initial_capital: float, + ) -> tuple[list[dict], list[dict]]: + """从交易记录构建日频净值曲线和回撤曲线。 + + 资金模型: 每笔交易等权分配 (1/N_capital),N_capital = 同时持仓数上限。 + 简化版: 按出场日归集所有已平仓交易的平均收益作为当日组合收益。 + """ + if not trades or len(all_dates) == 0: + return [], [] + + # 按出场日归集 pnl + exit_pnl: dict[str, list[float]] = {} + for t in trades: + d_str = str(t.exit_date) + exit_pnl.setdefault(d_str, []).append(t.pnl_pct) + + equity = initial_capital + peak = initial_capital + curve: list[dict] = [] + dd_curve: list[dict] = [] + + for d in all_dates: + d_str = str(d.item() if hasattr(d, "item") else d) + pnls = exit_pnl.get(d_str, []) + # 当日组合收益 = 该日所有出场交易的平均收益 + daily_ret = float(np.mean(pnls)) if pnls else 0.0 + equity *= (1 + daily_ret) + peak = max(peak, equity) + dd = (equity - peak) / peak if peak > 0 else 0.0 + curve.append({"date": d_str[:10], "value": round(equity, 2)}) + dd_curve.append({"date": d_str[:10], "value": round(dd, 4)}) + + return curve, dd_curve + + # ── 统计计算 ────────────────────────────────────── + + @staticmethod + def _calc_stats( + trades: list[TradeRecord], + initial_capital: float, + start: date, + end: date, + ) -> dict: + if not trades: + return {"total_return": 0, "n_trades": 0} + + pnls = np.array([t.pnl_pct for t in trades]) + n_trades = len(trades) + + # 从净值曲线推算总收益 (等权组合) + cumulative = 1.0 + for p in pnls: + cumulative *= (1 + p) + # 修正: 等权组合的总收益不等于各笔复乘,用曲线终点更准 + # 但这里作为简化,用各笔复乘作为近似 + total_return = cumulative - 1.0 + + # 年化 + n_days = max((end - start).days, 1) + years = n_days / 365.25 + if total_return > -1.0 and years > 0: + annual_return = (1 + total_return) ** (1 / years) - 1 + else: + annual_return = total_return + + # 胜率 + wins = pnls[pnls > 0] + losses = pnls[pnls <= 0] + win_rate = len(wins) / n_trades + + # 盈亏比 + avg_win = float(np.mean(wins)) if len(wins) > 0 else 0.0 + avg_loss = abs(float(np.mean(losses))) if len(losses) > 0 else 0.0 + profit_factor = avg_win / avg_loss if avg_loss > 0 else (float("inf") if avg_win > 0 else 0.0) + + # 最大回撤 — 用交易序列近似 + equity = initial_capital + peak = initial_capital + max_dd = 0.0 + for p in pnls: + equity *= (1 + p) + peak = max(peak, equity) + dd = (equity - peak) / peak + max_dd = min(max_dd, dd) + + # 夏普 — 用交易收益标准差近似 + sharpe = float(np.mean(pnls) / np.std(pnls)) * np.sqrt(252) if np.std(pnls) > 0 else 0.0 + + # Calmar + calmar = annual_return / abs(max_dd) if abs(max_dd) > 0.001 else 0.0 + + return { + "total_return": round(float(total_return), 4), + "annual_return": round(float(annual_return), 4), + "max_drawdown": round(float(max_dd), 4), + "sharpe": round(float(sharpe), 2), + "calmar": round(float(calmar), 2), + "win_rate": round(float(win_rate), 4), + "profit_factor": round(float(profit_factor), 2) if np.isfinite(profit_factor) else None, + "n_trades": n_trades, + "avg_pnl": round(float(np.mean(pnls)), 4), + "avg_win": round(avg_win, 4), + "avg_loss": round(avg_loss, 4), + } + + @staticmethod + def _calc_per_symbol(trades: list[TradeRecord]) -> list[dict]: + if not trades: + return [] + by_sym: dict[str, dict] = {} + for t in trades: + s = t.symbol + d = by_sym.setdefault(s, { + "symbol": s, "n_trades": 0, "total_return": 1.0, + "best": -999.0, "worst": 999.0, "wins": 0, "pnls": [], + }) + d["n_trades"] += 1 + d["pnls"].append(t.pnl_pct) + d["total_return"] *= (1 + t.pnl_pct) + d["best"] = max(d["best"], t.pnl_pct) + d["worst"] = min(d["worst"], t.pnl_pct) + if t.pnl_pct > 0: + d["wins"] += 1 + + result = [] + for d in by_sym.values(): + result.append({ + "symbol": d["symbol"], + "n_trades": d["n_trades"], + "total_return": round(d["total_return"] - 1.0, 4), + "win_rate": round(d["wins"] / d["n_trades"], 4) if d["n_trades"] > 0 else 0.0, + "best": round(d["best"], 4), + "worst": round(d["worst"], 4), + }) + return sorted(result, key=lambda x: x["total_return"], reverse=True) + + @staticmethod + def _calc_independent_candidate_result( + trades: list[TradeRecord], + n_candidates: int, + execution_stats: dict[str, int], + ) -> SimResult: + """全量独立候选统计:按每个候选样本的实际执行收益聚合。""" + if not trades: + return SimResult( + equity_curve=[], + drawdown_curve=[], + trades=[], + per_symbol_stats=[], + stats={ + "mode": "full", + "full_kind": "candidate_execution", + "error": "no executable trades", + "n_candidates": int(n_candidates), + "n_trades": 0, + "execution": execution_stats, + }, + ) + + pnls = np.array([t.pnl_pct for t in trades], dtype=float) + durations = np.array([t.duration for t in trades], dtype=float) + wins = pnls[pnls > 0] + losses = pnls[pnls <= 0] + avg_win = float(np.mean(wins)) if len(wins) else 0.0 + avg_loss = abs(float(np.mean(losses))) if len(losses) else 0.0 + + # 按退出日聚合已实现样本收益, 构造“样本收益曲线”。它不是账户净值。 + daily_returns: dict[str, list[float]] = {} + for t in trades: + daily_returns.setdefault(str(t.exit_date)[:10], []).append(float(t.pnl_pct)) + + equity_curve: list[dict] = [] + drawdown_curve: list[dict] = [] + equity = 1.0 + peak = 1.0 + daily_avg: list[float] = [] + for d_str in sorted(daily_returns.keys()): + values = daily_returns[d_str] + avg_ret = float(np.mean(values)) if values else 0.0 + daily_avg.append(avg_ret) + equity *= (1 + avg_ret) + peak = max(peak, equity) + dd = (equity - peak) / peak if peak > 0 else 0.0 + equity_curve.append({ + "date": d_str, + "value": round(float(equity), 4), + "positions": len(values), + }) + drawdown_curve.append({"date": d_str, "value": round(float(dd), 4)}) + + values = np.array([r["value"] for r in equity_curve], dtype=float) + total_return = float(values[-1] - 1.0) if len(values) else 0.0 + peaks = np.maximum.accumulate(values) if len(values) else np.array([]) + drawdowns = values / peaks - 1 if len(values) else np.array([]) + max_drawdown = float(drawdowns.min()) if len(drawdowns) else 0.0 + daily = np.array(daily_avg, dtype=float) + sharpe = float(np.mean(daily) / np.std(daily) * np.sqrt(252)) if len(daily) > 1 and np.std(daily) > 0 else 0.0 + + lo, hi, nbins = -0.20, 0.20, 20 + clipped = np.clip(pnls, lo, hi) + counts, edges = np.histogram(clipped, bins=nbins, range=(lo, hi)) + dist = [ + { + "range": f"{(edges[i]*100):+.0f}~{(edges[i+1]*100):+.0f}%", + "count": int(counts[i]), + "ratio": round(float(counts[i] / pnls.size), 4) if pnls.size else 0.0, + } + for i in range(nbins) + ] + + stats = { + "mode": "full", + "full_kind": "candidate_execution", + "n_candidates": int(n_candidates), + "n_trades": int(len(trades)), + "n_days": int(len(daily_returns)), + "avg_daily_candidates": round(float(len(trades) / max(len(daily_returns), 1)), 1), + "avg_return": round(float(np.mean(pnls)), 4), + "median_return": round(float(np.median(pnls)), 4), + "win_rate": round(float(len(wins) / len(pnls)), 4) if len(pnls) else 0.0, + "profit_factor": round(float(avg_win / avg_loss), 2) if avg_loss > 0 else None, + "best": round(float(np.max(pnls)), 4), + "worst": round(float(np.min(pnls)), 4), + "avg_duration": round(float(np.mean(durations)), 1) if len(durations) else 0.0, + "total_return": round(float(total_return), 4), + "max_drawdown": round(float(max_drawdown), 4), + "sharpe": round(float(sharpe), 2), + "return_distribution": dist, + "execution": execution_stats, + } + + return SimResult( + equity_curve=equity_curve, + drawdown_curve=drawdown_curve, + trades=trades, + per_symbol_stats=BacktestEngine._calc_per_symbol(trades), + stats=stats, + ) + + @staticmethod + def _calc_portfolio_stats( + equity_curve: list[dict], + trades: list[TradeRecord], + initial_capital: float, + ) -> dict: + if not equity_curve: + return {"total_return": 0, "n_trades": 0} + final_equity = float(equity_curve[-1]["value"]) + total_return = final_equity / initial_capital - 1 if initial_capital > 0 else 0.0 + values = np.array([float(r["value"]) for r in equity_curve], dtype=float) + daily = values[1:] / values[:-1] - 1 if len(values) > 1 else np.array([]) + annual_return = (1 + total_return) ** (252 / max(len(equity_curve), 1)) - 1 if total_return > -1 else total_return + peaks = np.maximum.accumulate(values) + drawdowns = values / peaks - 1 + max_drawdown = float(drawdowns.min()) if len(drawdowns) else 0.0 + sharpe = float(np.mean(daily) / np.std(daily) * np.sqrt(252)) if len(daily) and np.std(daily) > 0 else 0.0 + pnls = np.array([t.pnl_pct for t in trades], dtype=float) if trades else np.array([]) + exposures = np.array([float(r.get("exposure", 0.0)) for r in equity_curve], dtype=float) + wins = pnls[pnls > 0] + losses = pnls[pnls <= 0] + avg_win = float(np.mean(wins)) if len(wins) else 0.0 + avg_loss = abs(float(np.mean(losses))) if len(losses) else 0.0 + return { + "total_return": round(float(total_return), 4), + "annual_return": round(float(annual_return), 4), + "max_drawdown": round(float(max_drawdown), 4), + "sharpe": round(float(sharpe), 2), + "calmar": round(float(annual_return / abs(max_drawdown)), 2) if abs(max_drawdown) > 0.001 else 0.0, + "win_rate": round(float(len(wins) / len(pnls)), 4) if len(pnls) else 0.0, + "profit_factor": round(float(avg_win / avg_loss), 2) if avg_loss > 0 else None, + "n_trades": len(trades), + "avg_pnl": round(float(np.mean(pnls)), 4) if len(pnls) else 0.0, + "avg_win": round(avg_win, 4), + "avg_loss": round(avg_loss, 4), + "final_equity": round(final_equity, 2), + "initial_capital": round(float(initial_capital), 2), + "avg_exposure": round(float(np.mean(exposures)), 4) if len(exposures) else 0.0, + "max_exposure": round(float(np.max(exposures)), 4) if len(exposures) else 0.0, + } + + @staticmethod + def _date_str(value) -> str: + value = value.item() if hasattr(value, "item") else value + return str(value)[:10] + + @staticmethod + def _empty_result() -> SimResult: + return SimResult( + equity_curve=[], drawdown_curve=[], trades=[], + per_symbol_stats=[], stats={"error": "no data or no signals"}, + ) + + # ── 截面工具 (因子回测用) ───────────────────────── + + @staticmethod + def cross_section_rank(panel: pl.DataFrame, col: str) -> pl.DataFrame: + return panel.with_columns( + pl.col(col).rank(method="random").over("date").alias(f"{col}_rank") + ) + + @staticmethod + def cross_section_qcut(panel: pl.DataFrame, col: str, n_groups: int) -> pl.DataFrame: + return panel.with_columns( + pl.col(col).qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)]) + .over("date").alias("_group") + ) diff --git a/serve/backend/app/backtest/factor.py b/serve/backend/app/backtest/factor.py new file mode 100644 index 0000000..740582a --- /dev/null +++ b/serve/backend/app/backtest/factor.py @@ -0,0 +1,480 @@ +"""因子回测服务 — IC/IR 分析 + 分层回测 + 多空组合。 + +纯 Polars 向量化实现,无 pandas 依赖。 +""" +from __future__ import annotations + +import logging +import time +import uuid +from dataclasses import dataclass, field +from datetime import date, timedelta +from typing import Literal + +import numpy as np +import polars as pl + +from app.backtest.engine import BacktestEngine + +logger = logging.getLogger(__name__) + +# 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标) +FACTOR_COLUMNS: list[dict] = [ + {"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5日涨跌幅,正值表示上涨趋势"}, + {"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10日涨跌幅,中短期趋势指标"}, + {"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "月度涨跌幅,常用因子"}, + {"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30日涨跌幅"}, + {"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "季度涨跌幅,中期动量"}, + {"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标,敏感度高"}, + {"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标,经典周期"}, + {"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"}, + {"id": "annual_vol_20d","label": "20日波动率", "group": "波动率", "desc": "20日年化波动率"}, + {"id": "atr_14", "label": "ATR(14)", "group": "波动率", "desc": "14日平均真实波幅"}, + {"id": "vol_ratio_5d", "label": "量比(5日)", "group": "量价", "desc": "当日成交量 / 5日均量"}, + {"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "当日换手率"}, + {"id": "macd_hist", "label": "MACD柱", "group": "趋势", "desc": "MACD柱状图值"}, + {"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"}, + {"id": "change_pct", "label": "日涨跌幅", "group": "基础", "desc": "当日涨跌幅"}, + {"id": "amplitude", "label": "日振幅", "group": "基础", "desc": "当日振幅 (最高-最低)/昨收"}, +] + +FACTOR_WARMUP_DAYS = 120 + + +@dataclass +class FactorConfig: + factor_name: str + symbols: list[str] | None + start: date + end: date + n_groups: int = 5 + rebalance: Literal["daily", "weekly", "monthly"] = "monthly" + weight: Literal["equal", "factor_weight"] = "equal" + fees_pct: float = 0.0002 + slippage_bps: float = 5.0 + + +@dataclass +class GroupStats: + group: int + label: str + total_return: float + annual_return: float + max_drawdown: float + sharpe: float + win_rate: float + + +@dataclass +class FactorResult: + run_id: str + config: dict + # IC 分析 + ic_mean: float | None = None + ic_std: float | None = None + ir: float | None = None + ic_win_rate: float | None = None + ic_series: list[dict] = field(default_factory=list) + # 分层 + group_stats: list[dict] = field(default_factory=list) + group_nav: list[dict] = field(default_factory=list) + # 多空 + long_short_stats: dict = field(default_factory=dict) + long_short_nav: list[dict] = field(default_factory=list) + # 元信息 + elapsed_ms: float = 0.0 + n_symbols: int = 0 + n_dates: int = 0 + error: str | None = None + + +class FactorBacktestService: + def __init__(self, engine: BacktestEngine) -> None: + self.engine = engine + + def run(self, config: FactorConfig) -> FactorResult: + t0 = time.perf_counter() + run_id = uuid.uuid4().hex[:10] + + def _err(msg: str) -> FactorResult: + return FactorResult( + run_id=run_id, + config=self._config_to_dict(config), + error=msg, + elapsed_ms=(time.perf_counter() - t0) * 1000, + ) + + # 加载基础面板: 当前 enriched parquet 只持久化基础列, 指标因子可能需要运行时计算。 + panel_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "turnover_rate"] + if config.factor_name not in panel_columns: + panel_columns.append(config.factor_name) + load_start = config.start + if config.factor_name not in {"turnover_rate"}: + load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS) + + panel = self.engine.load_panel( + config.symbols, + load_start, + config.end, + columns=panel_columns, + ) + if panel.is_empty(): + return _err("无数据,请检查日期范围或先运行盘后管道") + + factor_col = config.factor_name + if factor_col not in panel.columns: + panel = self._compute_missing_factor(panel, factor_col) + if factor_col not in panel.columns: + return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算") + if "close" not in panel.columns: + return _err("enriched 数据缺少收盘价 close") + panel = panel.select(["symbol", "date", "close", factor_col]) + panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end)) + + # 过滤有效行 + panel = panel.filter( + pl.col(factor_col).is_not_null() + & pl.col("close").is_not_null() + & (pl.col("close") > 0) + ) + if panel.is_empty(): + return _err("过滤后无有效数据") + + n_symbols = panel["symbol"].n_unique() + n_dates = panel["date"].n_unique() + + # 计算下期收益 + # 根据调仓频率计算不同周期的 forward return + if config.rebalance == "daily": + panel = panel.with_columns( + (pl.col("close").shift(-1).over("symbol") / pl.col("close") - 1) + .alias("_next_return") + ) + else: + # weekly/monthly: 计算到下个调仓日的收益 + panel = self._calc_period_return(panel, config.rebalance) + + # ── 1. IC 分析 ── + ic_df = self._calc_ic(panel, factor_col) + ic_series = [ + {"date": str(row["date"]), "ic": round(float(row["ic"]), 4)} + for row in ic_df.iter_rows(named=True) + if row["ic"] is not None and not np.isnan(float(row["ic"])) + ] + ic_values = [r["ic"] for r in ic_series] + ic_mean = float(np.mean(ic_values)) if ic_values else None + ic_std = float(np.std(ic_values)) if ic_values else None + ir = (ic_mean / ic_std) if (ic_mean is not None and ic_std and ic_std > 1e-8) else None + ic_win_rate = (sum(1 for v in ic_values if v > 0) / len(ic_values)) if ic_values else None + + # ── 2. 分层回测 ── + panel = self._add_groups(panel, factor_col, config.n_groups) + group_nav = self._calc_group_nav(panel, config) + group_stats = self._calc_group_stats(group_nav, config.start, config.end) + + # ── 3. 多空组合 ── + long_short_nav, long_short_stats = self._calc_long_short(group_nav, config) + + elapsed = (time.perf_counter() - t0) * 1000 + return FactorResult( + run_id=run_id, + config=self._config_to_dict(config), + ic_mean=round(ic_mean, 4) if ic_mean is not None else None, + ic_std=round(ic_std, 4) if ic_std is not None else None, + ir=round(ir, 4) if ir is not None else None, + ic_win_rate=round(ic_win_rate, 4) if ic_win_rate is not None else None, + ic_series=ic_series, + group_stats=group_stats, + group_nav=group_nav, + long_short_stats=long_short_stats, + long_short_nav=long_short_nav, + elapsed_ms=round(elapsed, 1), + n_symbols=n_symbols, + n_dates=n_dates, + ) + + @staticmethod + def _compute_missing_factor(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame: + required = {"symbol", "date", "open", "high", "low", "close", "volume"} + if not required.issubset(panel.columns): + missing = sorted(required - set(panel.columns)) + logger.warning("factor %s cannot be computed, missing columns: %s", factor_col, missing) + return panel + + from app.indicators.pipeline import compute_indicators + + computed = compute_indicators(panel) + if factor_col not in computed.columns: + return panel + return computed.select(["symbol", "date", "close", factor_col]) + + # ── IC 计算 ── + + @staticmethod + def _calc_ic(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame: + """计算截面 Rank IC (因子值 rank vs 下期收益 rank 的相关系数)。""" + return ( + panel.filter(pl.col("_next_return").is_not_null()) + .group_by("date") + .agg( + pl.corr( + pl.col(factor_col).rank(method="random"), + pl.col("_next_return").rank(method="random"), + ).alias("ic") + ) + .sort("date") + ) + + # ── 调仓期收益 ── + + @staticmethod + def _calc_period_return(panel: pl.DataFrame, rebalance: str) -> pl.DataFrame: + """计算到下个调仓日的收益。 + + weekly: 下周一的 open / 今日 close - 1 + monthly: 下月首个交易日的 open / 今日 close - 1 + 只在调仓日标记行有效,其他行为 null。 + """ + import datetime as _dt + + all_dates = sorted(panel["date"].unique().to_list()) + date_set = set(all_dates) + + if rebalance == "weekly": + # 调仓日 = 每周一 + rebalance_dates = set() + for d in all_dates: + if hasattr(d, "weekday"): + wd = d.weekday() + else: + wd = _dt.date.fromisoformat(str(d)).weekday() + if wd == 0: # Monday + rebalance_dates.add(d) + else: # monthly + # 调仓日 = 每月首个交易日 + seen_months: set[str] = set() + rebalance_dates = set() + for d in sorted(all_dates): + m = str(d)[:7] # "YYYY-MM" + if m not in seen_months: + seen_months.add(m) + rebalance_dates.add(d) + + if not rebalance_dates: + panel = panel.with_columns(pl.lit(None).cast(pl.Float64).alias("_next_return")) + return panel + + # 对每个调仓日,找到下一个调仓日 + sorted_rebalance = sorted(rebalance_dates) + next_rebalance_map: dict = {} + for i, d in enumerate(sorted_rebalance): + if i + 1 < len(sorted_rebalance): + next_rebalance_map[d] = sorted_rebalance[i + 1] + # 最后一个调仓日没有下一个,不计算收益 + + # 构建 (date, symbol) → next_rebalance_date 的 close 价格映射 + # 简化: 用下个调仓日的 close / 当前 close + panel = panel.sort(["symbol", "date"]) + dates_col = panel["date"].to_list() + close_col = panel["close"].to_list() + symbol_col = panel["symbol"].to_list() + + # 先找下个调仓日的 close + # 建立 (date, symbol) → close 的快速查找 + price_map: dict[tuple, float] = {} + for i in range(len(dates_col)): + price_map[(str(dates_col[i]), symbol_col[i])] = close_col[i] + + next_returns = [None] * len(panel) + for i in range(len(panel)): + d = dates_col[i] + d_val = d if isinstance(d, _dt.date) else _dt.date.fromisoformat(str(d)) + if d not in rebalance_dates: + continue + next_d = next_rebalance_map.get(d) + if next_d is None: + continue + next_d_str = str(next_d)[:10] + d_str = str(d)[:10] + sym = symbol_col[i] + next_close = price_map.get((next_d_str, sym)) + cur_close = close_col[i] + if next_close is not None and cur_close and cur_close > 0: + next_returns[i] = (next_close / cur_close - 1.0) + + panel = panel.with_columns( + pl.Series("_next_return", next_returns, dtype=pl.Float64) + ) + return panel + + # ── 分组 ── + + @staticmethod + def _add_groups(panel: pl.DataFrame, factor_col: str, n_groups: int) -> pl.DataFrame: + """截面分位数分组。""" + return panel.with_columns( + pl.col(factor_col) + .qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)]) + .over("date") + .alias("_group") + ) + + # ── 分组净值 ── + + @staticmethod + def _calc_group_nav(panel: pl.DataFrame, config: FactorConfig) -> list[dict]: + """计算分组净值曲线 — 只在调仓日更新净值。""" + # 只保留有下期收益的行 (= 调仓日) + group_ret = ( + panel.filter(pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null()) + .group_by(["date", "_group"]) + .agg(pl.col("_next_return").mean().alias("group_return")) + ) + + # pivot: date × group + pivot = group_ret.pivot(index="date", columns="_group", values="group_return").sort("date") + + if pivot.is_empty(): + return [] + + group_cols = [c for c in pivot.columns if c != "date"] + + # 累乘净值曲线 + result: list[dict] = [] + nav_values: dict[str, float] = {c: 1.0 for c in group_cols} + + for row in pivot.iter_rows(named=True): + entry: dict = {"date": str(row["date"])[:10]} + for c in group_cols: + ret = float(row[c]) if row[c] is not None else 0.0 + nav_values[c] *= (1 + ret) + entry[c] = round(nav_values[c], 4) + result.append(entry) + + return result + + # ── 分组统计 ── + + @staticmethod + def _calc_group_stats( + group_nav: list[dict], start: date, end: date, + ) -> list[dict]: + if not group_nav: + return [] + + group_cols = [k for k in group_nav[0] if k != "date"] + n_days = max((end - start).days, 1) + years = n_days / 365.25 + + stats = [] + for i, c in enumerate(sorted(group_cols)): + values = [r[c] for r in group_nav if r.get(c) is not None] + if not values: + continue + total_return = values[-1] - 1.0 + annual_return = (values[-1]) ** (1 / max(years, 0.01)) - 1 if values[-1] > 0 else 0.0 + + # 最大回撤 + peak = 1.0 + max_dd = 0.0 + for v in values: + peak = max(peak, v) + dd = (v - peak) / peak + max_dd = min(max_dd, dd) + + # 日收益序列 + daily_rets = [] + for j in range(1, len(values)): + if values[j - 1] > 0: + daily_rets.append(values[j] / values[j - 1] - 1) + + # 夏普 + if daily_rets: + arr = np.array(daily_rets) + sharpe = float(np.mean(arr) / np.std(arr)) * np.sqrt(252) if np.std(arr) > 0 else 0.0 + win_rate = float(np.mean(arr > 0)) + else: + sharpe = 0.0 + win_rate = 0.0 + + stats.append({ + "group": i + 1, + "label": c, + "total_return": round(total_return, 4), + "annual_return": round(annual_return, 4), + "max_drawdown": round(max_dd, 4), + "sharpe": round(sharpe, 2), + "win_rate": round(win_rate, 4), + }) + + return stats + + # ── 多空组合 ── + + @staticmethod + def _calc_long_short( + group_nav: list[dict], config: FactorConfig, + ) -> tuple[list[dict], dict]: + """多空组合: 做多最高组 + 做空最低组。""" + if not group_nav: + return [], {} + + group_cols = sorted([k for k in group_nav[0] if k != "date"]) + if len(group_cols) < 2: + return [], {} + + top_col = group_cols[-1] # Q5 (最高) + bottom_col = group_cols[0] # Q1 (最低) + + # 独立计算 top 和 bottom 的日收益,然后合成 + ls_value = 1.0 + prev_top = 1.0 + prev_bot = 1.0 + peak = 1.0 + max_dd = 0.0 + ls_nav: list[dict] = [] + + for row in group_nav: + top_nav = float(row.get(top_col, 1.0)) if row.get(top_col) is not None else 1.0 + bot_nav = float(row.get(bottom_col, 1.0)) if row.get(bottom_col) is not None else 1.0 + + # top 组收益 (做多) + top_ret = (top_nav / prev_top - 1) if prev_top > 0 else 0.0 + # bottom 组收益 (做空 = 取反) + bot_ret = -(bot_nav / prev_bot - 1) if prev_bot > 0 else 0.0 + # 多空组合收益 + ls_ret = (top_ret + bot_ret) / 2 # 各分配 50% 资金 + ls_value *= (1 + ls_ret) + + prev_top = top_nav + prev_bot = bot_nav + + peak = max(peak, ls_value) + dd = (ls_value - peak) / peak if peak > 0 else 0.0 + max_dd = min(max_dd, dd) + + ls_nav.append({"date": row["date"], "value": round(ls_value, 4)}) + + total_ret = ls_value - 1.0 + ls_stats = { + "total_return": round(total_ret, 4), + "max_drawdown": round(max_dd, 4), + "top_group": top_col, + "bottom_group": bottom_col, + } + + return ls_nav, ls_stats + + @staticmethod + def _config_to_dict(c: FactorConfig) -> dict: + return { + "factor_name": c.factor_name, + "symbols": c.symbols, + "start": str(c.start), + "end": str(c.end), + "n_groups": c.n_groups, + "rebalance": c.rebalance, + "weight": c.weight, + "fees_pct": c.fees_pct, + "slippage_bps": c.slippage_bps, + } diff --git a/serve/backend/app/backtest/strategy.py b/serve/backend/app/backtest/strategy.py new file mode 100644 index 0000000..0518fb3 --- /dev/null +++ b/serve/backend/app/backtest/strategy.py @@ -0,0 +1,721 @@ +"""策略回测服务 — 复用 StrategyDef 体系做全周期回测。 + +核心优化: 向量化 filter_fn,不逐日调用 StrategyEngine.run()。 +""" +from __future__ import annotations + +import logging +import time +import uuid +from dataclasses import dataclass, field +from datetime import date, timedelta +from typing import Callable, Literal + +import numpy as np +import polars as pl + +from app.backtest.engine import BacktestEngine, MatcherConfig, SimResult +from app.strategy.engine import StrategyEngine, StrategyDef + +logger = logging.getLogger(__name__) + +BENCHMARK_SYMBOL = "000001.SH" + + +@dataclass +class StrategyBacktestConfig: + strategy_id: str + symbols: list[str] | None + start: date + end: date + params: dict | None = None + overrides: dict | None = None + # matching 为向后兼容入口; 显式传 entry_fill/exit_fill 时以二者为准。 + matching: Literal["close_t", "open_t+1"] = "open_t+1" + entry_fill: Literal["close_t", "open_t+1"] | None = None + exit_fill: Literal["close_t", "open_t+1"] | None = None + fees_pct: float = 0.0002 + slippage_bps: float = 5.0 + max_positions: int = 10 + max_exposure_pct: float = 1.0 + initial_capital: float = 1_000_000.0 + position_sizing: Literal["equal", "score_weight"] = "equal" + mode: Literal["position", "full"] = "position" + holding_days: int = 5 + + def __post_init__(self) -> None: + if self.entry_fill is None: + self.entry_fill = self.matching + if self.exit_fill is None: + self.exit_fill = self.matching + + +@dataclass +class StrategyBacktestResult: + run_id: str + config: dict + stats: dict = field(default_factory=dict) + equity_curve: list[dict] = field(default_factory=list) + drawdown_curve: list[dict] = field(default_factory=list) + benchmark_curve: list[dict] = field(default_factory=list) + trades: list[dict] = field(default_factory=list) + per_symbol_stats: list[dict] = field(default_factory=list) + strategy_info: dict = field(default_factory=dict) + elapsed_ms: float = 0.0 + error: str | None = None + + +class StrategyBacktestService: + def __init__( + self, + engine: BacktestEngine, + strategy_engine: StrategyEngine, + ) -> None: + self.engine = engine + self.strategy_engine = strategy_engine + + def run( + self, + config: StrategyBacktestConfig, + progress_cb: "Callable[[dict], None] | None" = None, + cancel_event: "threading.Event | None" = None, + ) -> StrategyBacktestResult: + t0 = time.perf_counter() + run_id = uuid.uuid4().hex[:10] + + def _err(msg: str) -> StrategyBacktestResult: + return StrategyBacktestResult( + run_id=run_id, + config=self._config_to_dict(config), + error=msg, + elapsed_ms=(time.perf_counter() - t0) * 1000, + ) + + # 获取策略定义 + try: + s = self.strategy_engine.get(config.strategy_id) + except ValueError as e: + return _err(str(e)) + + params = self._normalize_params(config.params or {}, s) + overrides = config.overrides or {} + basic_filter = self._effective_basic_filter(s, overrides) + entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals) + exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals) + stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss) + take_profit = self._normalize_pct( + self._override_value(overrides, "take_profit", getattr(s, "take_profit", None)), + 0.01, + 5.0, + ) + trailing_stop = self._normalize_pct( + self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)), + 0.005, + 0.5, + ) + trailing_take_profit_activate = self._normalize_pct( + self._override_value(overrides, "trailing_take_profit_activate", getattr(s, "trailing_take_profit_activate", None)), + 0.01, + 2.0, + ) + trailing_take_profit_drawdown = self._normalize_pct( + self._override_value(overrides, "trailing_take_profit_drawdown", getattr(s, "trailing_take_profit_drawdown", None)), + 0.005, + 0.5, + ) + if trailing_take_profit_activate is not None and trailing_take_profit_drawdown is not None: + trailing_take_profit_drawdown = min(trailing_take_profit_drawdown, trailing_take_profit_activate) + max_hold_days = self._override_value(overrides, "max_hold_days", s.max_hold_days) + score_min, score_max = self._normalize_score_range( + overrides.get("score_min"), + overrides.get("score_max"), + ) + + timing_ms: dict[str, float] = {} + + # 加载面板 (含 warmup + 全量指标 + 信号)。warmup 只用于指标/形态计算, 不参与正式交易。 + warmup_days = max(120, int(max(s.lookback_days or 1, 1) * 1.5)) + load_start = config.start - timedelta(days=warmup_days) + + # 全量模式: entries 只在正式区间触发, exits 需要 end 之后的尾部数据继续执行策略卖点。 + # 若策略有 max_hold_days, 用它决定尾部窗口;否则 holding_days 只作为兜底观察上限。 + full_horizon_days = int(max_hold_days or config.holding_days or 5) + full_horizon_days = max(full_horizon_days, 1) + load_end = config.end + if config.mode == "full": + fwd_buffer = full_horizon_days + 5 # 多取几天, 容错停牌缺口/open_t+1 + load_end = config.end + timedelta(days=fwd_buffer * 2) # 日历日放宽, 确保覆盖 N 个交易日 + + t_load = time.perf_counter() + panel = self.engine.load_panel(config.symbols, load_start, load_end) + timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1) + if panel.is_empty(): + return _err("无数据,请检查日期范围或先运行盘后管道") + + formal_range = self._date_range_mask(panel, config.start, config.end) + if not formal_range.any(): + return _err("正式回测区间内无数据") + + t_signal = time.perf_counter() + + # basic_filter 只影响买入候选, 不能删除行情 panel, 否则持仓 mark / 卖出 / full forward return 都会失真。 + basic_mask = pl.Series("_basic", [True] * len(panel), dtype=pl.Boolean) + if basic_filter and basic_filter.get("enabled", True): + expr = StrategyEngine._basic_filter_expr(panel, basic_filter) + if expr is not None: + try: + basic_mask = panel.select(expr.alias("_basic"))["_basic"].fill_null(False).cast(pl.Boolean) + except Exception as e: # noqa: BLE001 + logger.warning("basic_filter mask failed: %s", e) + return _err(f"基础过滤计算失败: {e}") + + # 策略候选层用于评分归一化;entry_signals 只是买点层, 不参与 score universe。 + candidate_filter_mask = self._build_candidate_filter_mask(panel, s, params) + candidate_mask = basic_mask & candidate_filter_mask + panel = self._apply_score(panel, s, overrides, universe_mask=candidate_mask) + + entry_mask = self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals) + entry_mask = entry_mask & formal_range + raw_exit_mask = self._build_signal_mask(panel, exit_signals, "_exit") + exit_mask = raw_exit_mask & (self._date_range_mask(panel, config.start, load_end) if config.mode == "full" else formal_range) + timing_ms["signals_score"] = round((time.perf_counter() - t_signal) * 1000, 1) + + if not entry_mask.any(): + return _err("在指定区间内未产生买入信号") + + # warmup 之后才交给撮合;full mode 保留 end 之后前瞻段用于 shift(-N)。 + sim_end = load_end if config.mode == "full" else config.end + sim_range = self._date_range_mask(panel, config.start, sim_end) + sim_panel = panel.filter(sim_range) + sim_entry_mask = entry_mask.filter(sim_range) + sim_exit_mask = exit_mask.filter(sim_range) + if sim_panel.is_empty(): + return _err("正式回测区间内无数据") + + t_sim = time.perf_counter() + matcher_config = MatcherConfig( + matching=config.matching, + entry_fill=config.entry_fill, + exit_fill=config.exit_fill, + fees_pct=config.fees_pct, + slippage_bps=config.slippage_bps, + stop_loss_pct=stop_loss, + take_profit_pct=take_profit, + trailing_stop_pct=trailing_stop, + trailing_take_profit_activate_pct=trailing_take_profit_activate, + trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown, + max_hold_days=max_hold_days, + max_positions=config.max_positions, + max_exposure_pct=config.max_exposure_pct, + score_min=score_min, + score_max=score_max, + initial_capital=config.initial_capital, + position_sizing=config.position_sizing, + ) + # 撮合 — full 为全候选独立执行;position 为账户级仓位模拟。 + if config.mode == "full": + result = self.engine.simulate_independent_candidates( + sim_panel, + sim_entry_mask, + sim_exit_mask, + matcher_config, + progress_cb, + cancel_event, + ) + else: + result = self.engine.simulate_portfolio(sim_panel, sim_entry_mask, sim_exit_mask, matcher_config, progress_cb, cancel_event) + timing_ms["simulate"] = round((time.perf_counter() - t_sim) * 1000, 1) + + # 检查是否被取消 + if cancel_event is not None and cancel_event.is_set(): + return StrategyBacktestResult( + run_id=run_id, + config=self._config_to_dict(config), + error="cancelled", + elapsed_ms=round((time.perf_counter() - t0) * 1000, 1), + ) + + if result.stats.get("error"): + return _err(result.stats["error"]) + + timing_ms["total"] = round((time.perf_counter() - t0) * 1000, 1) + result.stats["timing_ms"] = timing_ms + result.stats["panel_rows"] = int(sim_panel.height) + + benchmark_curve = self._build_benchmark_curve(config.start, config.end) + + # 构建策略信息 + strategy_info = { + "id": s.meta.get("id", config.strategy_id), + "name": s.meta.get("name", config.strategy_id), + "description": s.meta.get("description", ""), + "entry_signals": entry_signals, + "exit_signals": exit_signals, + "stop_loss": stop_loss, + "take_profit": take_profit, + "trailing_stop": trailing_stop, + "trailing_take_profit_activate": trailing_take_profit_activate, + "trailing_take_profit_drawdown": trailing_take_profit_drawdown, + "max_hold_days": max_hold_days, + "full_horizon_days": full_horizon_days, + "score_min": score_min, + "score_max": score_max, + "source": s.source, + } + + elapsed = (time.perf_counter() - t0) * 1000 + + return StrategyBacktestResult( + run_id=run_id, + config=self._config_to_dict(config), + stats=result.stats, + equity_curve=result.equity_curve, + drawdown_curve=result.drawdown_curve, + benchmark_curve=benchmark_curve, + trades=[self._trade_to_dict(t) for t in result.trades], + per_symbol_stats=result.per_symbol_stats, + strategy_info=strategy_info, + elapsed_ms=round(elapsed, 1), + ) + + # ── 全量模拟 (选股能力统计, 不建组合不算净值) ── + + def _run_full_simulation( + self, + panel: pl.DataFrame, + entry_mask: pl.Series, + holding_days: int, + ) -> SimResult: + """对 entry_mask 命中的全部候选, 算持有 N 天后的前瞻收益统计。 + + 不受 max_positions/资金约束, 反映策略选股能力本身。 + equity_curve 复用为"累计日均超额收益曲线"(基准归零)。 + """ + n = holding_days if holding_days and holding_days > 0 else 5 + + df = panel.with_columns([ + entry_mask.cast(pl.Boolean).alias("_is_candidate"), + (pl.col("close").shift(-n).over("symbol") / pl.col("close") - 1).alias("_fwd_return"), + ]).filter( + pl.col("_is_candidate") + & pl.col("_fwd_return").is_not_null() + & pl.col("_fwd_return").is_not_nan() + ) + + if df.is_empty(): + return self.engine._empty_result() + + fwd = df["_fwd_return"].to_numpy() + wins = fwd[fwd > 0] + losses = fwd[fwd <= 0] + avg_win = float(wins.mean()) if wins.size else 0.0 + avg_loss = abs(float(losses.mean())) if losses.size else 0.0 + + # 按日聚合: 当日候选的平均前瞻收益 + daily = ( + df.group_by("date").agg( + pl.col("_fwd_return").mean().alias("avg_ret"), + pl.col("_fwd_return").count().alias("n_cand"), + ).sort("date") + ) + + # 累计超额曲线: 每日复利平均收益 (基准归零, 故 equity 即累计策略收益) + equity_curve: list[dict] = [] + equity = 1.0 + peak = 1.0 + drawdown_curve: list[dict] = [] + for row in daily.iter_rows(named=True): + ret = float(row["avg_ret"] or 0.0) + equity *= (1 + ret) + peak = max(peak, equity) + dd = (equity - peak) / peak if peak > 0 else 0.0 + d_str = str(row["date"])[:10] + equity_curve.append({ + "date": d_str, + "value": round(equity, 4), + "positions": int(row["n_cand"]), + }) + drawdown_curve.append({"date": d_str, "value": round(dd, 4)}) + + # 同期上证收益 (用 benchmark close 算) + benchmark_curve = self._build_benchmark_curve( + daily["date"].min(), daily["date"].max() + ) + benchmark_return = 0.0 + if benchmark_curve: + closes = [b["close"] for b in benchmark_curve if b.get("close")] + if len(closes) >= 2 and closes[0] > 0: + benchmark_return = closes[-1] / closes[0] - 1 + + total_return = equity - 1.0 + max_dd = min((d["value"] for d in drawdown_curve), default=0.0) + + # 日收益序列算 Sharpe (年化) + daily_rets = daily["avg_ret"].to_numpy() + sharpe = ( + float(daily_rets.mean() / daily_rets.std() * np.sqrt(252)) + if daily_rets.size > 1 and daily_rets.std() > 0 else 0.0 + ) + + # 收益分布直方图: 按 [-20%, +20%] 分 21 档 (每档 2%), 超出归入首尾档 + lo, hi, nbins = -0.20, 0.20, 20 + clipped = np.clip(fwd, lo, hi) + counts, edges = np.histogram(clipped, bins=nbins, range=(lo, hi)) + dist = [ + { + "range": f"{(edges[i]*100):+.0f}~{(edges[i+1]*100):+.0f}%", + "count": int(counts[i]), + "ratio": round(float(counts[i] / fwd.size), 4) if fwd.size else 0.0, + } + for i in range(nbins) + ] + + stats = { + "mode": "full", + "n_candidates": int(fwd.size), + "n_days": int(daily.height), + "avg_daily_candidates": round(float(daily["n_cand"].mean()), 1), + "avg_return": round(float(fwd.mean()), 4), + "median_return": round(float(np.median(fwd)), 4), + "win_rate": round(float(wins.size / fwd.size), 4) if fwd.size else 0.0, + "profit_factor": round(avg_win / avg_loss, 2) if avg_loss > 0 else None, + "best": round(float(fwd.max()), 4), + "worst": round(float(fwd.min()), 4), + "total_return": round(float(total_return), 4), + "max_drawdown": round(float(max_dd), 4), + "sharpe": round(sharpe, 2), + "benchmark_return": round(float(benchmark_return), 4), + "excess": round(float(total_return - benchmark_return), 4), + "return_distribution": dist, + } + + return SimResult( + equity_curve=equity_curve, + drawdown_curve=drawdown_curve, + trades=[], + per_symbol_stats=[], + stats=stats, + ) + + # ── 向量化信号生成 ── + + @staticmethod + def _date_range_mask(panel: pl.DataFrame, start: date, end: date) -> pl.Series: + return panel.select( + ((pl.col("date") >= start) & (pl.col("date") <= end)).alias("_range") + )["_range"].fill_null(False).cast(pl.Boolean) + + def _build_candidate_filter_mask( + self, + panel: pl.DataFrame, + s: StrategyDef, + params: dict, + ) -> pl.Series: + """生成策略候选层 mask。filter_history/filter 决定候选池, 不包含 entry_signals。""" + false_mask = pl.Series("_candidate_filter", [False] * len(panel), dtype=pl.Boolean) + true_mask = pl.Series("_candidate_filter", [True] * len(panel), dtype=pl.Boolean) + + history_failed = False + # 优先: filter_history_fn 策略 (涨停/反包等多日形态, 与选股路径共用同一逻辑) + if s.filter_history_fn: + try: + hit_df = s.filter_history_fn(panel, params) + if hit_df is None or hit_df.is_empty(): + return false_mask + # 命中行 (symbol,date) → 转 panel 等长布尔 mask + hits = hit_df.select(["symbol", "date"]).unique() + marked = ( + panel.select(["symbol", "date"]) + .join( + hits.with_columns(pl.lit(True).alias("_hit")), + on=["symbol", "date"], + how="left", + ) + ) + return marked["_hit"].fill_null(False).cast(pl.Boolean) + except Exception as e: + history_failed = True + logger.warning("strategy filter_history_fn failed: %s", e) + # 失败则回退到 filter_fn (若存在) + + # 策略 filter_fn: 候选层 (filter_history 不可用或失败时) + if s.filter_fn: + try: + expr = s.filter_fn(panel, params) + if expr is not None: + result = panel.select(expr.alias("_candidate_filter")) + if not result.is_empty(): + return result["_candidate_filter"].fill_null(False).cast(pl.Boolean) + except Exception as e: + logger.warning("strategy filter_fn failed: %s", e) + return false_mask + + if history_failed: + return false_mask + + # 没有策略候选层时, 由 entry_signals 直接决定买点。 + return true_mask + + def _build_entry_mask_from_candidate( + self, + panel: pl.DataFrame, + candidate_mask: pl.Series, + s: StrategyDef, + entry_signals: list[str], + ) -> pl.Series: + """向量化生成买入掩码:候选层 AND 买点层;无买点时只用策略候选层。""" + signal_mask = self._build_signal_mask(panel, entry_signals, "_entry_signal") + if entry_signals: + return candidate_mask & signal_mask + if s.filter_history_fn or s.filter_fn: + return candidate_mask + return pl.Series("_entry", [False] * len(panel), dtype=pl.Boolean) + + def _build_entry_mask( + self, + panel: pl.DataFrame, + s: StrategyDef, + params: dict, + entry_signals: list[str], + ) -> pl.Series: + """兼容旧调用: 候选层 AND 买点层。""" + candidate_mask = self._build_candidate_filter_mask(panel, s, params) + return self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals) + + @staticmethod + def _build_signal_mask(panel: pl.DataFrame, signals: list[str], name: str) -> pl.Series: + """向量化合并信号列,多个信号 OR。支持内置 signal_ 与自定义 csg_ 前缀。""" + masks: list[pl.Series] = [] + for sig in signals: + # csg_ (自定义信号) 直接用;否则按 signal_ 解析 + col = sig if (sig.startswith("signal_") or sig.startswith("csg_")) else f"signal_{sig}" + if col in panel.columns: + masks.append(panel[col].fill_null(False).cast(pl.Boolean)) + + if not masks: + return pl.Series(name, [False] * len(panel), dtype=pl.Boolean) + + combined = masks[0] + for m in masks[1:]: + combined = combined | m + return combined + + def _build_benchmark_curve(self, start: date, end: date) -> list[dict]: + try: + df = self.engine.repo.get_index_daily(BENCHMARK_SYMBOL, start, end, columns=["date", "close"]) + except Exception as e: + logger.warning("load benchmark %s failed: %s", BENCHMARK_SYMBOL, e) + return [] + + if df.is_empty() or "close" not in df.columns: + return [] + + df = df.filter(pl.col("close").is_not_null() & (pl.col("close") > 0)).sort("date") + if df.is_empty(): + return [] + + return [ + { + "date": str(row["date"])[:10], + "value": round(float(row["close"]), 4), + "close": round(float(row["close"]), 4), + "name": "上证指数", + "symbol": BENCHMARK_SYMBOL, + } + for row in df.iter_rows(named=True) + if row["close"] is not None + ] + + # ── 工具 ── + + @staticmethod + def _effective_basic_filter(s: StrategyDef, overrides: dict) -> dict: + basic_filter = dict(s.basic_filter or {}) + override_filter = overrides.get("basic_filter") + if isinstance(override_filter, dict): + basic_filter.update(override_filter) + return basic_filter + + @staticmethod + def _effective_signals(overrides: dict, key: str, default: list[str]) -> list[str]: + value = overrides.get(key) + if isinstance(value, list): + return [str(v) for v in value if v] + return list(default or []) + + @staticmethod + def _override_value(overrides: dict, key: str, default): + if key in overrides: + return overrides.get(key) + return default + + @staticmethod + def _normalize_pct(value, min_value: float, max_value: float) -> float | None: + if value is None or value == "": + return None + try: + pct = abs(float(value)) + except (TypeError, ValueError): + return None + return min(max(pct, min_value), max_value) + + @staticmethod + def _normalize_score_range(min_value, max_value) -> tuple[float | None, float | None]: + def _bound(value) -> float | None: + if value is None or value == "": + return None + try: + score = float(value) + except (TypeError, ValueError): + return None + if not np.isfinite(score): + return None + return min(max(score, 0.0), 100.0) + + score_min = _bound(min_value) + score_max = _bound(max_value) + if score_min is not None and score_max is not None and score_min > score_max: + score_min, score_max = score_max, score_min + return score_min, score_max + + @staticmethod + def _normalize_params(params: dict, s: StrategyDef) -> dict: + normalized = dict(params) + for param in s.meta.get("params", []): + pid = param.get("id") + if not pid: + continue + value = normalized.get(pid, param.get("default")) + p_type = param.get("type") + if p_type in {"float", "int"}: + try: + num = float(value) + except (TypeError, ValueError): + num = float(param.get("default", 0) or 0) + if param.get("min") is not None: + num = max(num, float(param["min"])) + if param.get("max") is not None: + num = min(num, float(param["max"])) + normalized[pid] = int(num) if p_type == "int" else num + elif p_type == "select" and param.get("options"): + normalized[pid] = value if value in param["options"] else param.get("default") + elif p_type == "bool": + if isinstance(value, bool): + normalized[pid] = value + elif isinstance(value, str): + normalized[pid] = value.lower() == "true" + else: + normalized[pid] = bool(param.get("default", False)) + else: + normalized[pid] = value + return normalized + + @staticmethod + def _trade_to_dict(t) -> dict: + return { + "symbol": t.symbol, + "name": t.name, + "entry_date": str(t.entry_date) if isinstance(t.entry_date, date) else str(t.entry_date), + "exit_date": str(t.exit_date) if isinstance(t.exit_date, date) else str(t.exit_date), + "entry_price": t.entry_price, + "exit_price": t.exit_price, + "pnl_pct": t.pnl_pct, + "duration": t.duration, + "exit_reason": t.exit_reason, + "shares": t.shares, + "lots": t.lots, + "position_pct": t.position_pct, + "entry_value": t.entry_value, + "exit_value": t.exit_value, + "pnl_amount": t.pnl_amount, + "entry_score": getattr(t, "entry_score", None), + "entry_signal_date": str(t.entry_signal_date) if getattr(t, "entry_signal_date", None) is not None else None, + "exit_signal_date": str(t.exit_signal_date) if getattr(t, "exit_signal_date", None) is not None else None, + "blocked_exit_days": getattr(t, "blocked_exit_days", 0), + } + + @staticmethod + def _config_to_dict(c: StrategyBacktestConfig) -> dict: + score_min, score_max = StrategyBacktestService._normalize_score_range( + (c.overrides or {}).get("score_min"), + (c.overrides or {}).get("score_max"), + ) + return { + "strategy_id": c.strategy_id, + "symbols": c.symbols, + "start": str(c.start), + "end": str(c.end), + "params": c.params, + "overrides": c.overrides, + "score_min": score_min, + "score_max": score_max, + "matching": c.matching, + "entry_fill": c.entry_fill, + "exit_fill": c.exit_fill, + "fees_pct": c.fees_pct, + "slippage_bps": c.slippage_bps, + "max_positions": c.max_positions, + "max_exposure_pct": c.max_exposure_pct, + "initial_capital": c.initial_capital, + "position_sizing": c.position_sizing, + "mode": c.mode, + "holding_days": c.holding_days, + } + + @staticmethod + def _apply_score( + panel: pl.DataFrame, + s: StrategyDef, + overrides: dict | None, + universe_mask: pl.Series | None = None, + ) -> pl.DataFrame: + scoring = s.meta.get("scoring", {}) + scoring_overrides = (overrides or {}).get("scoring") + if scoring_overrides: + scoring = {**scoring, **scoring_overrides} + + work = panel + has_universe = universe_mask is not None and len(universe_mask) == len(panel) + if has_universe: + work = work.with_columns(universe_mask.rename("_score_universe")) + + def _value_in_universe(col: str) -> pl.Expr: + if has_universe: + return pl.when(pl.col("_score_universe")).then(pl.col(col)).otherwise(None) + return pl.col(col) + + def _finish(df: pl.DataFrame) -> pl.DataFrame: + return df.drop("_score_universe") if "_score_universe" in df.columns else df + + if scoring: + total_weight = sum(scoring.values()) + if total_weight > 0: + score_parts: list[pl.Expr] = [] + for col, weight in scoring.items(): + if col not in work.columns: + continue + w = weight / total_weight + value = _value_in_universe(col) + col_min = value.min().over("date") + col_max = value.max().over("date") + col_range = col_max - col_min + normalized = pl.when(col_range > 0).then( + (pl.col(col) - col_min) / col_range + ).otherwise(pl.lit(0.5)) + if has_universe: + normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0) + score_parts.append(normalized * w) + if score_parts: + score_expr = score_parts[0] + for part in score_parts[1:]: + score_expr = score_expr + part + return _finish(work.with_columns((score_expr * 100).fill_null(0).alias("score"))) + + order_by = s.meta.get("order_by") + if order_by and order_by != "score" and order_by in work.columns: + direction = 1 if s.meta.get("descending", True) else -1 + score_expr = pl.col(order_by).fill_null(0) * direction + if has_universe: + score_expr = pl.when(pl.col("_score_universe")).then(score_expr).otherwise(0.0) + return _finish(work.with_columns(score_expr.alias("score"))) + return _finish(work.with_columns(pl.lit(0.0).alias("score"))) diff --git a/serve/backend/app/config.py b/serve/backend/app/config.py new file mode 100644 index 0000000..f6c4f43 --- /dev/null +++ b/serve/backend/app/config.py @@ -0,0 +1,127 @@ +"""全局配置 — 从环境变量 / .env 读取。""" +from __future__ import annotations + +import sys +from pathlib import Path + +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=str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # TickFlow + tickflow_api_key: str = Field(default="", description="留空启用 free 模式") + + # AI + ai_provider: str = "openai_compat" + ai_base_url: str = "https://api.alysc.top" + ai_api_key: str = "" + 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" + port: int = 3018 + log_level: str = "INFO" + backtest_range_guard: bool = False + + # Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env) + # 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。 + auth_password: str = "" + + # 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: + """是否走 Free 模式。优先看 secrets.json,其次看 .env。""" + from app import secrets_store + return not secrets_store.get_tickflow_key() + + +settings = Settings() diff --git a/serve/backend/app/data_providers/__init__.py b/serve/backend/app/data_providers/__init__.py new file mode 100644 index 0000000..4d3e9fd --- /dev/null +++ b/serve/backend/app/data_providers/__init__.py @@ -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"] diff --git a/serve/backend/app/data_providers/base.py b/serve/backend/app/data_providers/base.py new file mode 100644 index 0000000..c17ca21 --- /dev/null +++ b/serve/backend/app/data_providers/base.py @@ -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.""" diff --git a/serve/backend/app/data_providers/normalizer.py b/serve/backend/app/data_providers/normalizer.py new file mode 100644 index 0000000..edf047e --- /dev/null +++ b/serve/backend/app/data_providers/normalizer.py @@ -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") diff --git a/serve/backend/app/data_providers/registry.py b/serve/backend/app/data_providers/registry.py new file mode 100644 index 0000000..e7a39e6 --- /dev/null +++ b/serve/backend/app/data_providers/registry.py @@ -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() diff --git a/serve/backend/app/data_providers/schemas.py b/serve/backend/app/data_providers/schemas.py new file mode 100644 index 0000000..51e8dc3 --- /dev/null +++ b/serve/backend/app/data_providers/schemas.py @@ -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", +] diff --git a/serve/backend/app/data_providers/tickflow_provider.py b/serve/backend/app/data_providers/tickflow_provider.py new file mode 100644 index 0000000..a086949 --- /dev/null +++ b/serve/backend/app/data_providers/tickflow_provider.py @@ -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 []) diff --git a/serve/backend/app/desktop.py b/serve/backend/app/desktop.py new file mode 100644 index 0000000..82efaf6 --- /dev/null +++ b/serve/backend/app/desktop.py @@ -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()) diff --git a/serve/backend/app/indicators/__init__.py b/serve/backend/app/indicators/__init__.py new file mode 100644 index 0000000..47c096f --- /dev/null +++ b/serve/backend/app/indicators/__init__.py @@ -0,0 +1 @@ +"""技术指标 — Polars 实现(§7.5 / §7.7)。""" diff --git a/serve/backend/app/indicators/levels.py b/serve/backend/app/indicators/levels.py new file mode 100644 index 0000000..ad3f204 --- /dev/null +++ b/serve/backend/app/indicators/levels.py @@ -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 diff --git a/serve/backend/app/indicators/pipeline.py b/serve/backend/app/indicators/pipeline.py new file mode 100644 index 0000000..c753943 --- /dev/null +++ b/serve/backend/app/indicators/pipeline.py @@ -0,0 +1,1512 @@ +"""enriched 表计算流水线(§7.5 / §7.7 Step 2)。 + +存储层 (enriched parquet): + 仅存储基础行情窄表 (14 列), 指标和信号由各服务即时计算。 + + 存储列: symbol, date, OHLCV(前复权), volume, amount, + raw_close, raw_high, raw_low, turnover_rate, + consecutive_limit_ups, consecutive_limit_downs + +设计: + - 100% Polars 表达式(SQL 窗口无法表达递归 EMA) + - 每只标的独立计算(`.over("symbol")`) + - 有 adj_factor 时先应用前复权再算指标;无因子时直接用 raw + - streaming collect 控制内存 +""" +from __future__ import annotations + +import logging +from collections.abc import Callable +from pathlib import Path + +import polars as pl + +from app.config import settings + +logger = logging.getLogger(__name__) + + +# ── 自定义信号缓存 ───────────────────────────────────── +# 从 data/user_data/custom_signals/*.json 加载并编译为 Polars 表达式。 +# 模块级缓存:首次调用时加载,invalidate_custom_signals() 后下次重载。 +_custom_signal_exprs: dict[str, pl.Expr] | None = None + + +def _get_custom_signal_exprs() -> dict[str, pl.Expr]: + """懒加载自定义信号表达式(带模块级缓存)。""" + global _custom_signal_exprs + if _custom_signal_exprs is None: + from app.strategy import custom_signals + try: + sigs = custom_signals.load_all(settings.data_dir) + _custom_signal_exprs = custom_signals.build_expressions(sigs) + except Exception as e: + logger.warning("custom signals load failed: %s", e) + _custom_signal_exprs = {} + return _custom_signal_exprs + + +def invalidate_custom_signals() -> None: + """失效自定义信号缓存(保存/删除信号后调用,下次计算重新加载)。""" + global _custom_signal_exprs + _custom_signal_exprs = None + + +# enriched parquet 仅存储的列 (14 列) +ENRICHED_STORAGE_COLS = [ + "symbol", "date", + "open", "high", "low", "close", # 前复权 + "volume", "amount", + "raw_close", "raw_high", "raw_low", # 不复权原始价 + "turnover_rate", # 依赖当时的 float_shares, 不可回推 + "consecutive_limit_ups", # 递推状态, 需从历史 cum_sum + "consecutive_limit_downs", +] + + +# ================================================================ +# enriched 完整列清单 (存储 + 运行时计算) +# 供 AI 审查代码时参考: 策略/筛选/回测 可直接使用以下列名。 +# 分类: 存储列 → 指标列 → 信号列 → JOIN 列 +# ================================================================ +ENRICHED_COLUMNS: dict[str, dict[str, str]] = { + # ── 存储列 (parquet 持久化) ────────────────────────── + "symbol": "股票代码", + "date": "交易日期", + "open": "前复权开盘价", + "high": "前复权最高价", + "low": "前复权最低价", + "close": "前复权收盘价", + "volume": "成交量", + "amount": "成交额", + "raw_close": "原始收盘价(未复权)", + "raw_high": "原始最高价(未复权)", + "raw_low": "原始最低价(未复权)", + "turnover_rate": "换手率", + "consecutive_limit_ups": "连板数", + "consecutive_limit_downs": "连跌数", + # ── 基础指标 ───────────────────────────────────────── + "prev_close": "前收盘价", + "change_pct": "日涨跌幅(小数, 如 0.05 = 5%)", + "change_amount": "日涨跌额", + "amplitude": "日振幅 (最高-最低)/昨收", + # ── 均线 MA ────────────────────────────────────────── + "ma5": "5日简单均线", + "ma10": "10日简单均线", + "ma20": "20日简单均线", + "ma30": "30日简单均线", + "ma60": "60日简单均线(季线)", + # ── 指数均线 EMA ───────────────────────────────────── + "ema5": "5日指数均线", + "ema10": "10日指数均线", + "ema20": "20日指数均线", + "ema30": "30日指数均线", + "ema60": "60日指数均线", + # ── MACD ───────────────────────────────────────────── + "macd_dif": "MACD DIF线(快线-慢线)", + "macd_dea": "MACD DEA线(信号线)", + "macd_hist": "MACD柱状图 (DIF-DEA)×2", + # ── 布林带 BOLL ────────────────────────────────────── + "boll_upper": "布林带上轨 MA20+2σ", + "boll_lower": "布林带下轨 MA20-2σ", + # ── KDJ ────────────────────────────────────────────── + "kdj_k": "KDJ K值", + "kdj_d": "KDJ D值", + "kdj_j": "KDJ J值 (3K-2D)", + # ── ATR ────────────────────────────────────────────── + "atr_14": "14日平均真实波幅", + # ── 量价 ───────────────────────────────────────────── + "vol_ma5": "5日成交均量", + "vol_ma10": "10日成交均量", + "vol_ratio_5d": "量比 (成交量/5日均量)", + # ── 极值 ───────────────────────────────────────────── + "high_60d": "60日最高价", + "low_60d": "60日最低价", + # ── 动量 ───────────────────────────────────────────── + "momentum_5d": "5日动量(涨跌幅小数)", + "momentum_10d": "10日动量", + "momentum_20d": "20日动量", + "momentum_30d": "30日动量", + "momentum_60d": "60日动量", + # ── 波动率 ─────────────────────────────────────────── + "annual_vol_20d": "20日年化波动率", + # ── RSI ────────────────────────────────────────────── + "rsi_6": "6日相对强弱指标", + "rsi_14": "14日相对强弱指标", + "rsi_24": "24日相对强弱指标", + # ── 信号列 (bool) ──────────────────────────────────── + "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金叉 (DIF上穿DEA)", + "signal_macd_dead": "MACD死叉 (DIF下穿DEA)", + "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": "放量 (量比≥2.0)", + "signal_limit_up": "涨停", + "signal_limit_down": "跌停", + "signal_limit_down_recovery": "跌停翘板(跌停后回升)", + "signal_broken_limit_up": "炸板(最高触及涨停但收盘未封住)", + # ── JOIN 列 (由 repository 从 instruments 表补充) ─── + "name": "股票名称 (来自 instruments)", + "total_shares": "总股本 (来自 instruments)", + "float_shares": "流通股本 (来自 instruments)", +} + +# 仅供 AI/开发者快速索引: 按类别的列名列表 +ENRICHED_COLUMNS_BY_CATEGORY: dict[str, list[str]] = { + "storage": [k for k in ENRICHED_COLUMNS if k in ENRICHED_STORAGE_COLS], + "basic": ["prev_close", "change_pct", "change_amount", "amplitude"], + "ma": ["ma5", "ma10", "ma20", "ma30", "ma60"], + "ema": ["ema5", "ema10", "ema20", "ema30", "ema60"], + "macd": ["macd_dif", "macd_dea", "macd_hist"], + "boll": ["boll_upper", "boll_lower"], + "kdj": ["kdj_k", "kdj_d", "kdj_j"], + "atr": ["atr_14"], + "volume": ["vol_ma5", "vol_ma10", "vol_ratio_5d"], + "extremes": ["high_60d", "low_60d"], + "momentum": ["momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d"], + "volatility": ["annual_vol_20d"], + "rsi": ["rsi_6", "rsi_14", "rsi_24"], + "signals": [k for k in ENRICHED_COLUMNS if k.startswith("signal_")], + "join": ["name", "total_shares", "float_shares"], +} + + +def _ema_alpha(span: int) -> float: + return 2.0 / (span + 1) + + +def _math_half_up(expr: pl.Expr, decimals: int = 2) -> pl.Expr: + """交易所四舍五入 (round half up),替代 Python round()(银行家舍入)。 + + round(2.625, 2) = 2.62 ← Python 银行家舍入 + exchange_round(2.625) = 2.63 ← 交易所四舍五入 + """ + factor = 10 ** decimals + return (expr * factor + 0.5).floor() / factor + + +def _limit_price(prev: pl.Expr, limit_pct: pl.Expr, up: bool) -> pl.Expr: + """用「分」为单位的整数算术计算涨跌停价,规避浮点精度问题。 + + 交易所涨跌停价 = round(prev × (1 ± limit), 2),标准四舍五入。 + 若直接用浮点 prev × (1 ± limit) 会丢精度: + 18.90 × 0.95 = 17.955,浮点存储为 17.954999..., 四舍五入后得 17.95(错)。 + 本函数先把 prev 转成整数「分」(round 到分避免输入含厘误差), + 再用整数系数 105/95、110/90、120/80、130/70 相乘后四舍五入回元,全程不丢精度。 + """ + sign = 1 if up else -1 + # limit_pct ∈ {0.05, 0.10, 0.20, 0.30} → 系数分子 105/95、110/90、120/80、130/70 + num = ((1 + sign * limit_pct) * 100).cast(pl.Int64) # 105, 110, 120, 130 等 + cents = (prev * 100 + 0.5).floor().cast(pl.Int64) # 价格转「分」(四舍五入到分) + # cents × num / 100, 四舍五入到分(加 50) + return (((cents * num + 50) // 100) / 100) + + +def _apply_adj_factor(raw: pl.DataFrame, factors: pl.DataFrame) -> pl.DataFrame: + """对 raw K 线应用前复权 (forward adjustment)。 + + adj_factor 结构: symbol, trade_date, ex_factor + ex_factor 含义: 每次除权事件的 pre/post 比值(个股级,非累积)。 + + 前复权原理: + - 保持最新价格不变,将历史价格向下调整以消除除权缺口 + - adjusted = raw × cumprod_at_D / total_cumprod + - 等价于: adjusted = raw / (该日期之后所有事件的 ex_factor 乘积) + """ + if factors.is_empty(): + return raw + + # 确保类型一致 + factors = factors.with_columns( + pl.col("trade_date").cast(pl.Date, strict=False), + pl.col("ex_factor").cast(pl.Float64, strict=False), + ).select("symbol", "trade_date", "ex_factor").drop_nulls() + + if factors.is_empty(): + return raw + + # 去重 + 排序 + 累积乘积 (一趟完成) + factors_sorted = ( + factors.sort(["symbol", "trade_date"]) + .unique(subset=["symbol", "trade_date"]) + .sort(["symbol", "trade_date"]) + .with_columns( + pl.col("ex_factor").cum_prod().over("symbol").alias("cum_factor"), + ) + ) + + # 每个 symbol 的总累积因子 + total_factors = ( + factors_sorted + .group_by("symbol") + .agg(pl.col("cum_factor").last().alias("total_factor")) + ) + + raw_sorted = raw.sort(["symbol", "date"]) + + # join_asof backward: 每根 K 线取 <= 其 date 的最新累积因子 + # 同时带 trade_date 列用于判断除权日标记 + df = raw_sorted.join_asof( + factors_sorted.select("symbol", "trade_date", "cum_factor"), + left_on="date", + right_on="trade_date", + by="symbol", + strategy="backward", + ) + + # 补充 total_factor + 前复权 + 除权标记,一次 with_columns 完成 + df = df.join(total_factors, on="symbol", how="left") + + is_ex = pl.col("trade_date") == pl.col("date") + ratio = pl.col("cum_factor").fill_null(1.0) / pl.col("total_factor").fill_null(1.0) + price_cols = [c for c in ("open", "high", "low", "close") if c in df.columns] + + df = df.with_columns( + [pl.col(c) * ratio for c in price_cols] + + [ + is_ex.alias("ex_rights"), + ] + ).drop(["trade_date", "cum_factor", "total_factor"]) + + return df + + +# ================================================================ +# 技术指标计算 (从 OHLCV 计算) +# ================================================================ + +def compute_indicators(df: pl.DataFrame) -> pl.DataFrame: + """从 OHLCV 数据计算全套技术指标。 + + 输入必须包含: symbol, date, open, high, low, close, volume + 返回添加了所有指标列的 DataFrame。 + """ + if df.is_empty(): + return df + + import time as _time + _t0 = _time.perf_counter() + + df = df.sort(["symbol", "date"]) + + # Pass 1: 均线 + EMA + MACD 基础 + BOLL 基础 + KDJ 基础 + ATR 基础 + 量价 + 极值 + prev_close = pl.col("close").shift(1).over("symbol") + df = df.with_columns([ + # 前收盘价 + prev_close.alias("prev_close"), + # MA (最大 MA60) + pl.col("close").rolling_mean(5).over("symbol").alias("ma5"), + pl.col("close").rolling_mean(10).over("symbol").alias("ma10"), + pl.col("close").rolling_mean(20).over("symbol").alias("ma20"), + pl.col("close").rolling_mean(30).over("symbol").alias("ma30"), + pl.col("close").rolling_mean(60).over("symbol").alias("ma60"), + # EMA (不含 ema12/ema26, MACD 内部自算) + pl.col("close").ewm_mean(alpha=_ema_alpha(5), adjust=False).over("symbol").alias("ema5"), + pl.col("close").ewm_mean(alpha=_ema_alpha(10), adjust=False).over("symbol").alias("ema10"), + pl.col("close").ewm_mean(alpha=_ema_alpha(20), adjust=False).over("symbol").alias("ema20"), + pl.col("close").ewm_mean(alpha=_ema_alpha(30), adjust=False).over("symbol").alias("ema30"), + pl.col("close").ewm_mean(alpha=_ema_alpha(60), adjust=False).over("symbol").alias("ema60"), + # MACD base (内部计算, 不存 ema12/ema26) + pl.col("close").ewm_mean(alpha=_ema_alpha(12), adjust=False).over("symbol").alias("_ema12"), + pl.col("close").ewm_mean(alpha=_ema_alpha(26), adjust=False).over("symbol").alias("_ema26"), + # BOLL base + pl.col("close").rolling_std(20).over("symbol").alias("_boll_std"), + # KDJ base + pl.col("low").rolling_min(9).over("symbol").alias("_kdj_ln"), + pl.col("high").rolling_max(9).over("symbol").alias("_kdj_hn"), + # ATR base + pl.max_horizontal( + pl.col("high") - pl.col("low"), + (pl.col("high") - prev_close).abs(), + (pl.col("low") - prev_close).abs(), + ).alias("_tr"), + # 量价 base + pl.col("volume").rolling_mean(5).over("symbol").alias("vol_ma5"), + pl.col("volume").rolling_mean(10).over("symbol").alias("vol_ma10"), + pl.col("volume").rolling_mean(5).over("symbol").alias("_vol_ma5"), + # 极值 + pl.col("close").rolling_max(60).over("symbol").alias("high_60d"), + pl.col("close").rolling_min(60).over("symbol").alias("low_60d"), + ]) + + # Pass 2: MACD + BOLL (基于 Pass 1 基础列) + df = df.with_columns([ + (pl.col("_ema12") - pl.col("_ema26")).alias("macd_dif"), + (pl.col("ma20") + 2 * pl.col("_boll_std")).alias("boll_upper"), + (pl.col("ma20") - 2 * pl.col("_boll_std")).alias("boll_lower"), + ]).with_columns( + pl.col("macd_dif").ewm_mean(alpha=_ema_alpha(9), adjust=False).over("symbol").alias("macd_dea"), + ).with_columns( + ((pl.col("macd_dif") - pl.col("macd_dea")) * 2).alias("macd_hist"), + ) + + # Pass 3: KDJ + _kdj_rsv = ( + 100 * (pl.col("close") - pl.col("_kdj_ln")) + / (pl.col("_kdj_hn") - pl.col("_kdj_ln")).fill_null(1e-12) + ) + df = df.with_columns([ + _kdj_rsv.ewm_mean(alpha=1.0 / 3, adjust=False).over("symbol").alias("kdj_k"), + ]).with_columns([ + pl.col("kdj_k").ewm_mean(alpha=1.0 / 3, adjust=False).over("symbol").alias("kdj_d"), + ]).with_columns([ + (3 * pl.col("kdj_k") - 2 * pl.col("kdj_d")).alias("kdj_j"), + ]) + + # Pass 4: ATR + 量比 + 动量 + 波动 + 涨跌幅 + 涨跌额 + 振幅 + df = df.with_columns( + pl.col("_tr").ewm_mean(alpha=1.0 / 14, adjust=False).over("symbol").alias("atr_14"), + ).with_columns( + (pl.col("volume") / pl.col("_vol_ma5")).alias("vol_ratio_5d"), + ).with_columns([ + # 动量: 5d/10d/20d/30d/60d + (pl.col("close") / pl.col("close").shift(5).over("symbol") - 1).alias("momentum_5d"), + (pl.col("close") / pl.col("close").shift(10).over("symbol") - 1).alias("momentum_10d"), + (pl.col("close") / pl.col("close").shift(20).over("symbol") - 1).alias("momentum_20d"), + (pl.col("close") / pl.col("close").shift(30).over("symbol") - 1).alias("momentum_30d"), + (pl.col("close") / pl.col("close").shift(60).over("symbol") - 1).alias("momentum_60d"), + # 日涨跌幅 + (pl.col("close") / pl.col("close").shift(1).over("symbol") - 1).alias("change_pct"), + ]).with_columns( + # 涨跌额 + (pl.col("close") - pl.col("close").shift(1).over("symbol")).alias("change_amount"), + ).with_columns( + # 振幅 = (high - low) / prev_close + pl.when(pl.col("close").shift(1).over("symbol") > 0) + .then((pl.col("high") - pl.col("low")) / pl.col("close").shift(1).over("symbol")) + .otherwise(None) + .alias("amplitude"), + ).with_columns( + # 日涨跌幅 (用于波动率) + pl.col("close").pct_change().over("symbol").alias("_daily_pct"), + ).with_columns( + # 年化波动率 + (pl.col("_daily_pct").rolling_std(20).over("symbol") * (252 ** 0.5)) + .alias("annual_vol_20d"), + ) + + # Pass 5: RSI + df = df.with_columns( + pl.col("close").diff().over("symbol").alias("_delta"), + ).with_columns([ + pl.when(pl.col("_delta") > 0).then(pl.col("_delta")).otherwise(0.0).alias("_gain"), + pl.when(pl.col("_delta") < 0).then(-pl.col("_delta")).otherwise(0.0).alias("_loss"), + ]) + for n in (6, 14, 24): + a = 1.0 / n + df = df.with_columns([ + pl.col("_gain").ewm_mean(alpha=a, adjust=False).over("symbol").alias(f"_rsi_avg_gain_{n}"), + pl.col("_loss").ewm_mean(alpha=a, adjust=False).over("symbol").alias(f"_rsi_avg_loss_{n}"), + ]).with_columns( + (100 - 100 / (1 + pl.col(f"_rsi_avg_gain_{n}") / + pl.when(pl.col(f"_rsi_avg_loss_{n}") == 0) + .then(1e-12) + .otherwise(pl.col(f"_rsi_avg_loss_{n}")) + )).alias(f"rsi_{n}"), + ) + + # Pass 6: 换手率 (需要 float_shares, 后续在 compute_all 中 JOIN instruments 后补充) + + # 清理临时列 + df = df.drop(["_boll_std", "_tr", "_ema12", "_ema26", + "_kdj_ln", "_kdj_hn", "_vol_ma5", "_daily_pct", + "_delta", "_gain", "_loss", + "_rsi_avg_gain_6", "_rsi_avg_loss_6", + "_rsi_avg_gain_14", "_rsi_avg_loss_14", + "_rsi_avg_gain_24", "_rsi_avg_loss_24"]) + + _elapsed = (_time.perf_counter() - _t0) * 1000 + import logging as _logging + _logging.getLogger(__name__).debug("compute_indicators: %.1fms, %d rows", _elapsed, len(df)) + + return df + + +def compute_signals(df: pl.DataFrame) -> pl.DataFrame: + """从已有指标列计算原子信号布尔列。 + + 输入必须包含 compute_indicators() 产出的指标列。 + """ + if df.is_empty(): + return df + + df = df.with_columns([ + ((pl.col("ma5") > pl.col("ma20")) & + (pl.col("ma5").shift(1).over("symbol") <= pl.col("ma20").shift(1).over("symbol"))) + .alias("signal_ma_golden_5_20"), + ((pl.col("ma5") < pl.col("ma20")) & + (pl.col("ma5").shift(1).over("symbol") >= pl.col("ma20").shift(1).over("symbol"))) + .alias("signal_ma_dead_5_20"), + ((pl.col("ma20") > pl.col("ma60")) & + (pl.col("ma20").shift(1).over("symbol") <= pl.col("ma60").shift(1).over("symbol"))) + .alias("signal_ma_golden_20_60"), + ((pl.col("macd_dif") > pl.col("macd_dea")) & + (pl.col("macd_dif").shift(1).over("symbol") <= pl.col("macd_dea").shift(1).over("symbol"))) + .alias("signal_macd_golden"), + ((pl.col("macd_dif") < pl.col("macd_dea")) & + (pl.col("macd_dif").shift(1).over("symbol") >= pl.col("macd_dea").shift(1).over("symbol"))) + .alias("signal_macd_dead"), + ((pl.col("close") > pl.col("ma20")) & + (pl.col("close").shift(1).over("symbol") <= pl.col("ma20").shift(1).over("symbol"))) + .alias("signal_ma20_breakout"), + ((pl.col("close") < pl.col("ma20")) & + (pl.col("close").shift(1).over("symbol") >= pl.col("ma20").shift(1).over("symbol"))) + .alias("signal_ma20_breakdown"), + (pl.col("close") >= pl.col("high_60d")).alias("signal_n_day_high"), + (pl.col("close") <= pl.col("low_60d")).alias("signal_n_day_low"), + (pl.col("close") > pl.col("boll_upper")).alias("signal_boll_breakout_upper"), + (pl.col("close") < pl.col("boll_lower")).alias("signal_boll_breakdown_lower"), + (pl.col("vol_ratio_5d") >= 2.0).alias("signal_volume_surge"), + ]) + + # 自定义信号(用户配置的字段+运算符+值组合,编译为布尔列) + from app.strategy import custom_signals + df = custom_signals.inject(df, _get_custom_signal_exprs()) + + return df + + +def compute_limit_signals(df: pl.DataFrame, instruments: pl.DataFrame) -> pl.DataFrame: + """计算涨跌停相关信号。 + + 产出: + signal_limit_up, consecutive_limit_ups + signal_limit_down, consecutive_limit_downs + signal_limit_down_recovery (跌停翘板) + signal_broken_limit_up (炸板: 最高价触及涨停价但收盘未封住) + + 输入必须包含: symbol, date, raw_close, raw_high, open, high, low, close, + change_pct, vol_ratio_5d。 + """ + if df.is_empty(): + return df + + # 从 instruments 取 ST 标记 + 流通股本(换手率用) + inst_cols = ["symbol"] + if "name" in instruments.columns: + inst_cols.append("name") + if "float_shares" in instruments.columns: + inst_cols.append("float_shares") + inst_subset = instruments.select(inst_cols).unique(subset=["symbol"]) + + if "name" in instruments.columns: + st_flag = ( + instruments + .select("symbol", pl.col("name").str.contains("ST").alias("_is_st")) + .unique(subset=["symbol"]) + ) + inst_subset = inst_subset.join(st_flag, on="symbol", how="left") + + df = df.join(inst_subset, on="symbol", how="left", suffix="_inst") + + # 计算换手率(%) = volume(手) * 10000 / float_shares(股) + if "float_shares" in df.columns and "volume" in df.columns: + df = df.with_columns( + pl.when(pl.col("float_shares") > 0) + .then(pl.col("volume") * 10000.0 / pl.col("float_shares")) + .otherwise(None) + .alias("turnover_rate") + ) + elif "turnover_rate" not in df.columns: + df = df.with_columns(pl.lit(None).cast(pl.Float64).alias("turnover_rate")) + + # 前一日参考收盘价(交易所涨跌停基准价) + # 仅在 adj_factor 发生变化(除权除息 XD/DR)时使用前复权昨收作为交易所参考价; + # 否则使用原始 raw_close.shift(1) 以避免浮点精度误差。 + _adj_today = pl.col("close") / pl.col("raw_close") + _adj_yesterday = pl.col("close").shift(1).over("symbol") / pl.col("raw_close").shift(1).over("symbol") + _adj_changed = (_adj_today - _adj_yesterday).abs() > 1e-6 + df = df.with_columns( + pl.when(_adj_changed) + .then(pl.col("close").shift(1).over("symbol")) # 除权: 使用前复权昨收 + .otherwise(pl.col("raw_close").shift(1).over("symbol")) # 正常: 使用原始昨收 + .alias("_prev_raw_close") + ) + + # 板块涨跌停比例 + is_chinext = pl.col("symbol").str.starts_with("300") | pl.col("symbol").str.starts_with("301") + is_star = pl.col("symbol").str.starts_with("688") | pl.col("symbol").str.starts_with("689") + is_bj = pl.col("symbol").str.ends_with(".BJ") + + df = df.with_columns( + pl.when(is_chinext).then(0.20) + .when(is_star).then(0.20) + .when(is_bj).then(0.30) + .otherwise(0.10) + .alias("_board_pct") + ) + + # ST → 5%(覆盖板块默认值) + if "_is_st" in df.columns: + df = df.with_columns( + pl.when(pl.col("_is_st").fill_null(False)) + .then(0.05) + .otherwise(pl.col("_board_pct")) + .alias("_limit_pct") + ) + else: + df = df.with_columns(pl.col("_board_pct").alias("_limit_pct")) + + # 理论涨停价 = prev_close × (1 + limit_pct) 整数算术,避免浮点误差 + df = df.with_columns( + _limit_price(pl.col("_prev_raw_close"), pl.col("_limit_pct"), up=True) + .alias("_theoretical_limit_up") + ) + + # 理论跌停价 = prev_close × (1 - limit_pct) + df = df.with_columns( + _limit_price(pl.col("_prev_raw_close"), pl.col("_limit_pct"), up=False) + .alias("_theoretical_limit_down") + ) + + # ── signal_limit_up ── + df = df.with_columns( + pl.when( + pl.col("_prev_raw_close").is_not_null() + & (pl.col("_prev_raw_close") > 0) + & (pl.col("raw_close") > 0) + ).then( + (pl.col("raw_close") - pl.col("_theoretical_limit_up")).abs() < 0.005 + ).otherwise(None).cast(pl.Boolean) + .alias("signal_limit_up") + ) + + # ── consecutive_limit_ups ── + df = df.with_columns( + (~pl.col("signal_limit_up").fill_null(False)) + .cast(pl.UInt32) + .cum_sum() + .over("symbol") + .alias("_grp_up") + ).with_columns( + pl.col("signal_limit_up") + .cast(pl.UInt32) + .cum_sum() + .over("symbol", "_grp_up") + .cast(pl.UInt32) + .alias("consecutive_limit_ups") + ).with_columns( + pl.when(pl.col("signal_limit_up").fill_null(False)) + .then(pl.col("consecutive_limit_ups")) + .otherwise(0) + .cast(pl.UInt32) + .alias("consecutive_limit_ups") + ) + + # ── signal_limit_down ── + df = df.with_columns( + pl.when( + pl.col("_prev_raw_close").is_not_null() + & (pl.col("_prev_raw_close") > 0) + & (pl.col("raw_close") > 0) + ).then( + (pl.col("raw_close") - pl.col("_theoretical_limit_down")).abs() < 0.005 + ).otherwise(None).cast(pl.Boolean) + .alias("signal_limit_down") + ) + + # ── consecutive_limit_downs ── + df = df.with_columns( + (~pl.col("signal_limit_down").fill_null(False)) + .cast(pl.UInt32) + .cum_sum() + .over("symbol") + .alias("_grp_down") + ).with_columns( + pl.col("signal_limit_down") + .cast(pl.UInt32) + .cum_sum() + .over("symbol", "_grp_down") + .cast(pl.UInt32) + .alias("consecutive_limit_downs") + ).with_columns( + pl.when(pl.col("signal_limit_down").fill_null(False)) + .then(pl.col("consecutive_limit_downs")) + .otherwise(0) + .cast(pl.UInt32) + .alias("consecutive_limit_downs") + ) + + # ── signal_limit_down_recovery (跌停翘板) ── + # 条件: 当日最低价曾触及跌停价 + 最终没有跌停 + 收阳 + df = df.with_columns( + pl.when( + pl.col("_prev_raw_close").is_not_null() + & (pl.col("_prev_raw_close") > 0) + ).then( + (~pl.col("signal_limit_down").fill_null(False)) # 最终没跌停 + & (pl.col("low") <= pl.col("_theoretical_limit_down") + 0.005) # 曾触及跌停 + & (pl.col("close") > pl.col("open")) # 收阳 + ).otherwise(None).cast(pl.Boolean) + .alias("signal_limit_down_recovery") + ) + + # ── signal_broken_limit_up (炸板) ── + # 条件: 最高价曾触及涨停价 + 最终没有封住涨停 + df = df.with_columns( + pl.when( + pl.col("_prev_raw_close").is_not_null() + & (pl.col("_prev_raw_close") > 0) + & (pl.col("raw_high") > 0) + ).then( + (~pl.col("signal_limit_up").fill_null(False)) # 最终没封住涨停 + & (pl.col("raw_high") >= pl.col("_theoretical_limit_up") - 0.005) # 曾触及涨停价 + ).otherwise(None).cast(pl.Boolean) + .alias("signal_broken_limit_up") + ) + + # 清理临时列 + JOIN 引入的 instruments 列 (不存入 enriched) + cleanup = ["_prev_raw_close", "_board_pct", "_limit_pct", + "_theoretical_limit_up", "_theoretical_limit_down", + "_grp_up", "_grp_down"] + if "_is_st" in df.columns: + cleanup.append("_is_st") + # 清理 join 产生的重复列 + for c in df.columns: + if c.endswith("_inst"): + cleanup.append(c) + # name 和 float_shares 只用于计算, 不存入 enriched + for c in ["name", "float_shares"]: + if c in df.columns and c != "turnover_rate": + cleanup.append(c) + df = df.drop([c for c in cleanup if c in df.columns]) + + return df + + +def compute_all(df: pl.DataFrame, instruments: pl.DataFrame | None = None) -> pl.DataFrame: + """从 OHLCV 计算全套指标 + 信号。一站式调用。 + + 输入: symbol, date, open, high, low, close, volume, amount, raw_close + """ + df = compute_indicators(df) + df = compute_signals(df) + if instruments is not None and not instruments.is_empty(): + df = compute_limit_signals(df, instruments) + + # 清理 NaN / Inf + float_cols = [c for c in df.columns if df[c].dtype.is_float()] + if float_cols: + df = df.with_columns([ + pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite()) + .then(None) + .otherwise(pl.col(c)) + .alias(c) + for c in float_cols + ]) + + return df + + +def filter_halt_days(df: pl.DataFrame) -> pl.DataFrame: + """过滤停牌日。 + + 停牌日的 open/high 必然为 0 (无集合竞价)。注意 close 可能被数据源 + 填充为前收盘价而非 0, 因此不能用 "OHLC 全零" 判断, 否则会漏过这类 + 停牌记录 (如 *ST 撤销风险警示的停牌日), 污染 MA/ATR 等指标。 + """ + if df.is_empty() or "open" not in df.columns or "high" not in df.columns: + return df + return df.filter(~((pl.col("open") == 0) & (pl.col("high") == 0))) + + +# ================================================================ +# Pipeline: 盘后全量计算 + 写入 +# ================================================================ + +def compute_enriched( + raw: pl.DataFrame, + factors: pl.DataFrame | None = None, + instruments: pl.DataFrame | None = None, +) -> pl.DataFrame: + """对原始日 K 应用前复权 + 全量计算指标 + 信号, 产出完整 enriched (含全部指标列)。 + + 输入应包含至少: symbol, date, open, high, low, close, volume (可选 amount)。 + 如果提供了 factors, 先应用前复权再算指标。 + 如果提供了 instruments, 计算涨跌停信号和换手率。 + """ + if raw.is_empty(): + return raw + + # 过滤停牌日 (会污染指标计算) + raw = filter_halt_days(raw) + + if raw.is_empty(): + return raw + + # 保留不复权原始价格(涨停/炸板/跌停判断需用不复权价格) + raw = raw.with_columns( + pl.col("close").alias("raw_close"), + pl.col("high").alias("raw_high"), + pl.col("low").alias("raw_low"), + ) + + # 应用前复权(只改 open/high/low/close,raw_close 不受影响) + if factors is not None and not factors.is_empty(): + raw = _apply_adj_factor(raw, factors) + + # 排序 + df = raw.sort(["symbol", "date"]) + + # 全量计算指标 + 信号 + df = compute_all(df, instruments=instruments) + + return df + + +def _select_storage_cols(df: pl.DataFrame) -> pl.DataFrame: + """写入 parquet 前裁剪到存储列 (14 列)。""" + cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] + return df.select(cols) + + +def run_pipeline(data_dir: Path | None = None, + symbols: list[str] | None = None, + new_dates_only: bool = False, + on_batch_done: Callable[[int, int], None] | None = None) -> int: + """运行盘后管道:读 kline_daily + adj_factor → 前复权 + 计算存储列 → 写 enriched。 + + enriched 表仅存储 14 列基础行情窄表 (OHLCV + raw_close/high/low + turnover_rate + 连板数)。 + + 模式: + - 全量 (symbols=None, new_dates_only=False): + 读全部 kline_daily, 全部重写 enriched 分区。 + 用于首次同步、往前扩展历史。 + - 向后增量 (new_dates_only=True): + 只读 enriched 中尚不存在的日期分区对应的 daily 数据, + 为所有标的生成新的 enriched 分区; + 若同时传 symbols, 还会对这些个股的全部已有日期做重算 + (因为除权因子链变了,历史数据的复权比例也要更新)。 + - 除权因子增量 (symbols 指定, new_dates_only=False): + 只对指定 symbol 做局部重算并合并回已有 enriched。 + 用于无新日K数据、仅除权因子变更的场景。 + 返回写入的行数。 + """ + import time as _t + t0 = _t.perf_counter() + + d = Path(data_dir or settings.data_dir) + daily_dir = d / "kline_daily" + enriched_base = d / "kline_daily_enriched" + factor_path = d / "adj_factor" / "all.parquet" + inst_glob = str(d / "instruments" / "**" / "*.parquet") + + if not daily_dir.exists() or not any(daily_dir.rglob("*.parquet")): + logger.info("无日K数据, 跳过管道") + return 0 + + daily_glob = (daily_dir / "**" / "*.parquet").as_posix() + _cast = pl.ScanCastOptions(integer_cast="allow-float") + written = 0 + + # 加载 instruments (涨跌停+换手率需要) + instruments = pl.DataFrame() + try: + instruments = pl.scan_parquet(inst_glob, cast_options=_cast).collect() + except Exception as e: # noqa: BLE001 + logger.warning("instruments 读取失败: %s", e) + + if new_dates_only: + # ── 向后增量模式 ── + # 1. 找出 daily 有但 enriched 还没有的日期 + enriched_dates = set() + if enriched_base.exists(): + enriched_dates = {p.stem.split("=")[1] for p in enriched_base.glob("date=*")} + + # 读新增日期的 daily 数据 (所有标的) + new_date_dirs = sorted( + p for p in daily_dir.glob("date=*") + if p.stem.split("=")[1] not in enriched_dates + ) + if not new_date_dirs and not symbols: + logger.info("增量模式: 无新日期, 无需重算") + return 0 + + # 加载复权因子 (全量,因为所有标的都可能需要) + factors = _load_factors(factor_path) + + # 2. 为新日期计算 enriched (所有标的) + if new_date_dirs: + raw_new = pl.scan_parquet(new_date_dirs[0] / "*.parquet", cast_options=_cast) + for nd in new_date_dirs[1:]: + raw_new = pl.concat([raw_new, pl.scan_parquet(nd / "*.parquet", cast_options=_cast)], how="diagonal_relaxed") + raw_new = raw_new.sort(["symbol", "date"]).collect(streaming=True) + + # 增量模式: 只算新日期, 但指标需要历史窗口 + # 读已有 enriched 最近 60 天作为历史前缀 + sym_list = raw_new["symbol"].unique().to_list() + hist_df = _load_recent_history(enriched_base, sym_list, days=60) + + # 合并历史 + 新数据 + if not hist_df.is_empty(): + # 只取基础行情列做历史前缀 + hist_cols = [c for c in ["symbol", "date", "open", "high", "low", "close", + "volume", "amount", "raw_close", "raw_high", "raw_low"] + if c in hist_df.columns] + raw_full = pl.concat([hist_df.select(hist_cols), raw_new], how="diagonal_relaxed") + else: + raw_full = raw_new + + enriched_new = compute_enriched(raw_full, factors=factors, instruments=instruments) + + # 只保留新日期的行 + new_date_set = set() + for nd in new_date_dirs: + ds = nd.stem.split("=")[1] + new_date_set.add(ds) + enriched_new = enriched_new.filter( + pl.col("date").map_elements(lambda x: x.isoformat(), return_dtype=pl.Utf8).is_in(list(new_date_set)) + ) + + t_new = _t.perf_counter() + logger.info("增量计算: %d 个新日期, %d 行, 耗时 %.2fs", + len(new_date_dirs), enriched_new.height, t_new - t0) + + if not enriched_new.is_empty(): + for date_df in enriched_new.partition_by("date"): + dt = date_df["date"][0] + ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) + out = enriched_base / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + date_df = _select_storage_cols(date_df).sort(["symbol"]) + date_df.write_parquet(out) + written += date_df.height + t_write_new = _t.perf_counter() + logger.info("增量写入: %.2fs, %d 行", t_write_new - t_new, written) + + # 3. 受除权因子影响的个股: 重算全部已有日期 (累积因子链变了) + if symbols: + sym_set = set(symbols) + raw_sym = pl.scan_parquet(daily_glob, cast_options=_cast).sort(["symbol", "date"]) + raw_sym = raw_sym.filter(pl.col("symbol").is_in(list(sym_set))) + raw_sym = raw_sym.collect(streaming=True) + if not raw_sym.is_empty(): + factors_sym = factors.filter(pl.col("symbol").is_in(list(sym_set))) if not factors.is_empty() else factors + inst_sym = instruments.filter(pl.col("symbol").is_in(list(sym_set))) if not instruments.is_empty() else instruments + enriched_sym = compute_enriched(raw_sym, factors=factors_sym, instruments=inst_sym) + for date_df in enriched_sym.partition_by("date"): + dt = date_df["date"][0] + ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) + out = enriched_base / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + date_df_storage = _select_storage_cols(date_df) + if out.exists(): + existing = pl.read_parquet(out) + existing = existing.filter(~pl.col("symbol").is_in(list(sym_set))) + date_df_storage = pl.concat([existing, date_df_storage], how="diagonal_relaxed") + date_df_storage = date_df_storage.sort(["symbol"]) + date_df_storage.write_parquet(out) + written += date_df.height + logger.info("除权重算: %d 只, 共写入 %d 行", len(sym_set), written) + + t_done = _t.perf_counter() + logger.info("增量管道完成: %.2fs, %d 行", t_done - t0, written) + return written + + # ── 全量 或 除权因子增量 模式 ── + mode = f"incremental ({len(symbols)} symbols)" if symbols else "full" + base = d / "kline_daily_enriched" + + # 加载复权因子 (全量加载一次,每批复用) + factors = _load_factors(factor_path) + + # 局部模式: 过滤 instruments + inst_use = instruments + + import gc + + # ── 按 symbol 分批处理: 每只股只有 ~244 行, 无冗余计算 ── + # 先获取全部 symbol 列表 + lf_all = pl.scan_parquet(daily_glob, cast_options=_cast) + if symbols: + sym_set = set(symbols) + lf_all = lf_all.filter(pl.col("symbol").is_in(list(sym_set))) + + all_symbols = ( + lf_all.select("symbol").unique().sort("symbol") + .collect(streaming=True)["symbol"].to_list() + ) + if not all_symbols: + logger.info("无日K数据, 跳过管道") + return 0 + + total_syms = len(all_symbols) + logger.info("全量计算: %d 只标的, 按 symbol 分批 [%s]", total_syms, mode) + + if not factors.is_empty() and symbols: + factors = factors.filter(pl.col("symbol").is_in(list(sym_set))) + if not factors.is_empty(): + logger.info("读取复权因子: %d 行", factors.height) + if not instruments.is_empty() and symbols: + inst_use = instruments.filter(pl.col("symbol").is_in(list(sym_set))) + + from app.services import preferences as prefs_mod + SYM_BATCH = prefs_mod.get_enriched_batch_size() # 每批 N 只 × ~244 天, 可在设置中调整 + total_batches = (total_syms + SYM_BATCH - 1) // SYM_BATCH + + # 全量模式: 先清理旧 enriched 目录, 最后一次性按日期写入 + # 收集所有批次结果, 按日期分区写入 + from collections import defaultdict + date_buffers: dict[str, list[pl.DataFrame]] = defaultdict(list) + + for batch_start in range(0, total_syms, SYM_BATCH): + batch_end = min(batch_start + SYM_BATCH, total_syms) + batch_syms = all_symbols[batch_start:batch_end] + + # 只读取本批 symbol 的数据 + lf_batch = pl.scan_parquet(daily_glob, cast_options=_cast) + lf_batch = lf_batch.filter(pl.col("symbol").is_in(batch_syms)) + raw = lf_batch.sort(["symbol", "date"]).collect(streaming=True) + + if raw.is_empty(): + continue + + # 本批的 factors / instruments + batch_factors = ( + factors.filter(pl.col("symbol").is_in(batch_syms)) + if not factors.is_empty() else factors + ) + batch_inst = ( + inst_use.filter(pl.col("symbol").is_in(batch_syms)) + if not inst_use.is_empty() else inst_use + ) + + # 计算 + enriched = compute_enriched(raw, factors=batch_factors, instruments=batch_inst) + + if not enriched.is_empty(): + if symbols: + # 局部模式: 直接按日期合并写入 + for date_df in enriched.partition_by("date"): + dt = date_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_storage = _select_storage_cols(date_df) + if out.exists(): + existing = pl.read_parquet(out) + existing = existing.filter(~pl.col("symbol").is_in(batch_syms)) + date_df_storage = pl.concat([existing, date_df_storage], how="diagonal_relaxed") + date_df_storage = date_df_storage.sort(["symbol"]) + date_df_storage.write_parquet(out) + written += date_df_storage.height + else: + # 全量模式: 缓冲到 date_buffers, 最后一次性写入 + for date_df in enriched.partition_by("date"): + dt = date_df["date"][0] + ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) + date_buffers[ds].append(_select_storage_cols(date_df).sort(["symbol"])) + written += date_df.height + + del raw, enriched, batch_factors, batch_inst + gc.collect() + + logger.info("symbol 批次 %d/%d (%s ~ %s), 已处理 %d 行", + batch_start // SYM_BATCH + 1, + total_batches, + batch_syms[0], batch_syms[-1], written) + + # 通知进度 + if on_batch_done: + on_batch_done(batch_start // SYM_BATCH + 1, total_batches) + + # 全量模式: 按日期分区写入 + if not symbols and date_buffers: + if base.exists(): + import shutil + shutil.rmtree(base) + base.mkdir(parents=True, exist_ok=True) + + for ds, dfs in date_buffers.items(): + out = base / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + merged = pl.concat(dfs, how="diagonal_relaxed").sort(["symbol"]) + merged.write_parquet(out) + + date_buffers.clear() + gc.collect() + + t_done = _t.perf_counter() + adj_label = "含复权" if not factors.is_empty() else "无复权" + logger.info("enriched 完成 [%s]: %.2fs, 共 %d 行, %s", + mode, t_done - t0, written, adj_label) + return written + + +def _load_factors(factor_path: Path) -> pl.DataFrame: + """加载复权因子文件。""" + if not factor_path.exists(): + return pl.DataFrame() + try: + return pl.read_parquet(factor_path) + except Exception as e: # noqa: BLE001 + logger.warning("复权因子读取失败: %s", e) + return pl.DataFrame() + + +def _load_recent_history(enriched_base: Path, symbols: list[str], days: int) -> pl.DataFrame: + """从已有 enriched parquet 加载最近 N 天的历史数据(用于增量模式的指标计算窗口)。 + + 只读基础行情列, 作为指标计算的历史前缀。 + """ + from datetime import date, timedelta + cutoff = date.today() - timedelta(days=days + 30) # 多读 30 天余量 + + try: + lf = ( + pl.scan_parquet(str(enriched_base / "**" / "*.parquet"), cast_options=_cast) + .filter( + (pl.col("symbol").is_in(symbols)) + & (pl.col("date") >= cutoff) + ) + .sort(["symbol", "date"]) + ) + hist_cols = [c for c in ["symbol", "date", "open", "high", "low", "close", + "volume", "amount", "raw_close", "raw_high", "raw_low"] + if c in lf.schema] + return lf.select(hist_cols).collect() + except Exception as e: # noqa: BLE001 + logger.warning("历史数据加载失败: %s", e) + return pl.DataFrame() + + +def compute_enriched_single(daily_for_symbol: pl.DataFrame) -> pl.DataFrame: + """单股版本 — Free 用户用,拉下来单股 K 后即时计算全部指标+信号返回给前端。""" + if daily_for_symbol.is_empty(): + return daily_for_symbol + + # 过滤停牌 + daily_for_symbol = filter_halt_days(daily_for_symbol) + if daily_for_symbol.is_empty(): + return daily_for_symbol + + # 保留 raw_close 用于涨停判断 + daily_for_symbol = daily_for_symbol.with_columns(pl.col("close").alias("raw_close")) + + # 即时计算全套指标 + 信号 (无复权因子, 无 instruments) + return compute_all(daily_for_symbol) + + +# ================================================================ +# 盘中增量计算: 只算今天 5500 行 (不复算历史) +# ================================================================ + +def compute_enriched_today( + live_agg: pl.DataFrame, + prev_enriched: pl.DataFrame, + today_ohlcv: pl.DataFrame, + instruments: pl.DataFrame | None = None, +) -> pl.DataFrame: + """用昨天的递推状态 + 今天的 OHLCV 增量计算今天的 enriched 数据。 + + 只处理 ~5500 行, 耗时 ~10-50ms (替代全量 compute_enriched 的 1.5-2s)。 + + 参数: + live_agg: repo.get_live_agg() — 包含所有递推状态 + 窗口聚合 + prev_enriched: repo.get_enriched_latest() — 昨天的完整 enriched (用于信号交叉判断) + today_ohlcv: 今天的 OHLCV (symbol, date, open, high, low, close, volume, amount) + instruments: 维表 (涨跌停/换手率需要) + + 返回: + 今天的 enriched DataFrame (~5500 行, 64 列) + """ + if today_ohlcv.is_empty() or live_agg.is_empty(): + return pl.DataFrame() + + alpha = _ema_alpha + + # ---- JOIN: 今天的 OHLCV + 昨天的递推状态 ---- + df = today_ohlcv.join(live_agg, on="symbol", how="inner") + + # ---- 前复权: 保存原始价 → 调整 OHLCV ---- + df = df.with_columns([ + pl.col("close").alias("raw_close"), + pl.col("high").alias("raw_high"), + pl.col("low").alias("raw_low"), + ]) + if "_adj_factor" in df.columns: + af = pl.col("_adj_factor").fill_null(1.0) + df = df.with_columns([ + (pl.col("open") * af).alias("open"), + (pl.col("high") * af).alias("high"), + (pl.col("low") * af).alias("low"), + (pl.col("close") * af).alias("close"), + ]) + + # ---- volume 统一 Float64 ---- + df = df.with_columns(pl.col("volume").cast(pl.Float64)) + + # ---- ex_rights: 盘中除权极罕见, 直接 false ---- + df = df.with_columns(pl.lit(False).alias("ex_rights")) + + # ---- 基础涨跌 ---- + # prev_close: 有则直接用 (来自 API quote_extra, raw), 需要乘 adj_factor 对齐复权价 + if "prev_close" not in df.columns: + prev_close = pl.col("close_right") if "close_right" in df.columns else pl.col("close") + df = df.with_columns(prev_close.alias("prev_close")) + elif "_adj_factor" in df.columns: + # 保存 API 原始前收盘价 (用于涨跌停价计算) + df = df.with_columns(pl.col("prev_close").alias("_prev_close_raw")) + # API 返回的 prev_close 是原始价, 乘复权因子对齐复权价 (用于 change_pct) + df = df.with_columns((pl.col("prev_close") * pl.col("_adj_factor").fill_null(1.0)).alias("prev_close")) + + # change_pct / change_amount / amplitude: 有则直接用, 无则计算 + if "change_pct" not in df.columns: + df = df.with_columns((pl.col("close") / pl.col("prev_close") - 1).alias("change_pct")) + if "change_amount" not in df.columns: + df = df.with_columns((pl.col("close") - pl.col("prev_close")).alias("change_amount")) + if "amplitude" not in df.columns: + df = df.with_columns( + pl.when(pl.col("prev_close") > 0) + .then((pl.col("high") - pl.col("low")) / pl.col("prev_close")) + .otherwise(None) + .alias("amplitude"), + ) + + # ---- EMA (递推) ---- + df = df.with_columns([ + (alpha(5) * pl.col("close") + (1 - alpha(5)) * pl.col("ema5")).alias("ema5"), + (alpha(10) * pl.col("close") + (1 - alpha(10)) * pl.col("ema10")).alias("ema10"), + (alpha(20) * pl.col("close") + (1 - alpha(20)) * pl.col("ema20")).alias("ema20"), + (alpha(30) * pl.col("close") + (1 - alpha(30)) * pl.col("ema30")).alias("ema30"), + (alpha(60) * pl.col("close") + (1 - alpha(60)) * pl.col("ema60")).alias("ema60"), + ]) + + # ---- MACD (递推) ---- + ema12 = alpha(12) * pl.col("close") + (1 - alpha(12)) * pl.col("_ema12") + ema26 = alpha(26) * pl.col("close") + (1 - alpha(26)) * pl.col("_ema26") + dif = ema12 - ema26 + dea = alpha(9) * dif + (1 - alpha(9)) * pl.col("macd_dea") + df = df.with_columns([ + dif.alias("macd_dif"), + dea.alias("macd_dea"), + ((dif - dea) * 2).alias("macd_hist"), + ]) + + # ---- MA (用部分和) ---- + df = df.with_columns([ + ((pl.col("_ma5_partial_sum") + pl.col("close")) / 5).alias("ma5"), + ((pl.col("_ma10_partial_sum") + pl.col("close")) / 10).alias("ma10"), + ((pl.col("_ma20_partial_sum") + pl.col("close")) / 20).alias("ma20"), + ((pl.col("_ma30_partial_sum") + pl.col("close")) / 30).alias("ma30"), + ((pl.col("_ma60_partial_sum") + pl.col("close")) / 60).alias("ma60"), + ]) + + # ---- Bollinger ---- + boll_sum = pl.col("_boll_partial_sum") + pl.col("close") + boll_sq_sum = pl.col("_boll_partial_sq_sum") + pl.col("close") ** 2 + boll_ma = boll_sum / 20 + boll_var = boll_sq_sum / 20 - boll_ma ** 2 + boll_std = pl.when(boll_var > 0).then(boll_var.sqrt()).otherwise(0.0) + df = df.with_columns([ + (boll_ma + 2 * boll_std).alias("boll_upper"), + (boll_ma - 2 * boll_std).alias("boll_lower"), + ]) + + # ---- KDJ (递推) ---- + kdj_ln = pl.min_horizontal(pl.col("_kdj_8d_low"), pl.col("low")) + kdj_hn = pl.max_horizontal(pl.col("_kdj_8d_high"), pl.col("high")) + rsv = (pl.col("close") - kdj_ln) / (kdj_hn - kdj_ln).fill_null(1e-12) * 100 + k_today = rsv / 3 + pl.col("kdj_k") * 2 / 3 + d_today = k_today / 3 + pl.col("kdj_d") * 2 / 3 + df = df.with_columns([ + k_today.alias("kdj_k"), + d_today.alias("kdj_d"), + (3 * k_today - 2 * d_today).alias("kdj_j"), + ]) + + # ---- ATR (递推) ---- + tr = pl.max_horizontal( + pl.col("high") - pl.col("low"), + (pl.col("high") - pl.col("prev_close")).abs(), + (pl.col("low") - pl.col("prev_close")).abs(), + ) + df = df.with_columns( + (tr / 14 + pl.col("atr_14") * 13 / 14).alias("atr_14"), + ) + + # ---- RSI (递推, n=6,14,24) ---- + delta = pl.col("close") - pl.col("prev_close") + gain = pl.when(delta > 0).then(delta).otherwise(0.0) + loss = pl.when(delta < 0).then(-delta).otherwise(0.0) + for n in (6, 14, 24): + a = 1.0 / n + avg_gain = (1 - a) * pl.col(f"_rsi_avg_gain_{n}") + a * gain + avg_loss = (1 - a) * pl.col(f"_rsi_avg_loss_{n}") + a * loss + df = df.with_columns([ + avg_gain.alias(f"_rsi_avg_gain_{n}"), + avg_loss.alias(f"_rsi_avg_loss_{n}"), + (100 - 100 / (1 + avg_gain / pl.when(avg_loss == 0).then(1e-12).otherwise(avg_loss))) + .alias(f"rsi_{n}"), + ]) + + # ---- 量比 ---- + vol_ma5 = (pl.col("_vol_ma5_partial_sum") + pl.col("volume")) / 5 + vol_ma10 = (pl.col("_vol_ma10_partial_sum") + pl.col("volume")) / 10 + df = df.with_columns([ + vol_ma5.alias("vol_ma5"), + vol_ma10.alias("vol_ma10"), + (pl.col("volume") / vol_ma5).alias("vol_ratio_5d"), + ]) + + # ---- 极值 60 日 ---- + df = df.with_columns([ + pl.max_horizontal(pl.col("_high_59d"), pl.col("high")).alias("high_60d"), + pl.min_horizontal(pl.col("_low_59d"), pl.col("low")).alias("low_60d"), + ]) + + # ---- 动量 (5d/10d/20d/30d/60d) ---- + df = df.with_columns([ + (pl.col("close") / pl.col("_close_5d_ago") - 1).alias("momentum_5d"), + (pl.col("close") / pl.col("_close_10d_ago") - 1).alias("momentum_10d"), + (pl.col("close") / pl.col("_close_20d_ago") - 1).alias("momentum_20d"), + (pl.col("close") / pl.col("_close_30d_ago") - 1).alias("momentum_30d"), + (pl.col("close") / pl.col("_close_60d_ago") - 1).alias("momentum_60d"), + ]) + + # ---- 年化波动率 20d (递推) ---- + # 用 Welford 简化: sum + sum_sq of 19 historical returns + today's return + today_ret = pl.col("close") / pl.col("prev_close") - 1 + total_sum = pl.col("_vol_19d_pct_sum").fill_null(0.0) + today_ret + total_sq_sum = pl.col("_vol_19d_pct_sq_sum").fill_null(0.0) + today_ret ** 2 + vol_mean = total_sum / 20 + vol_var = total_sq_sum / 20 - vol_mean ** 2 + df = df.with_columns( + pl.when(vol_var > 0) + .then(vol_var.sqrt() * (252 ** 0.5)) + .otherwise(None) + .alias("annual_vol_20d"), + ) + + # ---- 信号 (需要昨天的指标值判断交叉) ---- + if not prev_enriched.is_empty(): + sig_prev = prev_enriched.select( + "symbol", + pl.col("ma5").alias("_prev_ma5"), + pl.col("ma20").alias("_prev_ma20"), + pl.col("ma60").alias("_prev_ma60"), + pl.col("macd_dif").alias("_prev_dif"), + pl.col("macd_dea").alias("_prev_dea"), + pl.col("boll_upper").alias("_prev_boll_upper"), + pl.col("boll_lower").alias("_prev_boll_lower"), + pl.col("close").alias("_prev_close_enriched"), + ) + df = df.join(sig_prev, on="symbol", how="left") + + df = df.with_columns([ + # MA 金叉/死叉 + ((pl.col("ma5") > pl.col("ma20")) & (pl.col("_prev_ma5") <= pl.col("_prev_ma20"))) + .alias("signal_ma_golden_5_20"), + ((pl.col("ma5") < pl.col("ma20")) & (pl.col("_prev_ma5") >= pl.col("_prev_ma20"))) + .alias("signal_ma_dead_5_20"), + ((pl.col("ma20") > pl.col("ma60")) & (pl.col("_prev_ma20") <= pl.col("_prev_ma60"))) + .alias("signal_ma_golden_20_60"), + # MACD 金叉/死叉 + ((pl.col("macd_dif") > pl.col("macd_dea")) & (pl.col("_prev_dif") <= pl.col("_prev_dea"))) + .alias("signal_macd_golden"), + ((pl.col("macd_dif") < pl.col("macd_dea")) & (pl.col("_prev_dif") >= pl.col("_prev_dea"))) + .alias("signal_macd_dead"), + # MA20 突破/跌破 + ((pl.col("close") > pl.col("ma20")) & (pl.col("_prev_close_enriched") <= pl.col("_prev_ma20"))) + .alias("signal_ma20_breakout"), + ((pl.col("close") < pl.col("ma20")) & (pl.col("_prev_close_enriched") >= pl.col("_prev_ma20"))) + .alias("signal_ma20_breakdown"), + # BOLL 突破 + (pl.col("close") >= pl.col("boll_upper")).alias("signal_boll_breakout_upper"), + (pl.col("close") <= pl.col("boll_lower")).alias("signal_boll_breakdown_lower"), + ]) + + df = df.drop([ + c for c in df.columns + if c.startswith("_prev_") and c not in {"_prev_consec_up", "_prev_consec_down"} + ]) + + # N日新高/新低 + 放量 + df = df.with_columns([ + (pl.col("close") >= pl.col("high_60d")).alias("signal_n_day_high"), + (pl.col("close") <= pl.col("low_60d")).alias("signal_n_day_low"), + (pl.col("vol_ratio_5d") >= 2.0).alias("signal_volume_surge"), + ]) + + # ---- 涨跌停 + 换手率 + 炸板 + 连板 ---- + if instruments is not None and not instruments.is_empty(): + df = _compute_limit_signals_today(df, instruments) + + # ---- 清理内部列 ---- + drop_cols = [ + "close_right", "high_right", "low_right", "_prev_close_raw", + "_ma5_partial_sum", "_ma10_partial_sum", "_ma20_partial_sum", + "_ma30_partial_sum", "_ma60_partial_sum", + "_boll_partial_sum", "_boll_partial_sq_sum", + "_high_59d", "_low_59d", + "_close_5d_ago", "_close_10d_ago", "_close_20d_ago", + "_close_30d_ago", "_close_60d_ago", + "_vol_ma5_partial_sum", "_vol_ma10_partial_sum", + "_kdj_8d_low", "_kdj_8d_high", + "_window_len", + "_rsi_avg_gain_6", "_rsi_avg_loss_6", + "_rsi_avg_gain_14", "_rsi_avg_loss_14", + "_rsi_avg_gain_24", "_rsi_avg_loss_24", + "_ema12", "_ema26", + "_adj_factor", + "_vol_19d_pct_sum", "_vol_19d_pct_sq_sum", + "_prev_consec_up", "_prev_consec_down", + ] + df = df.drop([c for c in drop_cols if c in df.columns]) + + # 自定义信号(日级实时路径同样注入) + from app.strategy import custom_signals + df = custom_signals.inject(df, _get_custom_signal_exprs()) + + # 清理 NaN / Inf + float_cols = [c for c in df.columns if df[c].dtype.is_float()] + if float_cols: + df = df.with_columns([ + pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite()) + .then(None) + .otherwise(pl.col(c)) + .alias(c) + for c in float_cols + ]) + + return df + + +def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) -> pl.DataFrame: + """盘中增量版的涨跌停/换手率/炸板/连板计算。""" + inst_cols = ["symbol"] + 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 = ( + instruments + .select("symbol", pl.col("name").str.contains("ST").alias("_is_st")) + .unique(subset=["symbol"]) + ) + inst_subset = inst_subset.join(st_flag, on="symbol", how="left") + + df = df.join(inst_subset, on="symbol", how="left", suffix="_inst") + + # 换手率: API 有则直接用, 无则从 float_shares 计算 + if "turnover_rate" not in df.columns: + if "float_shares" in df.columns and "volume" in df.columns: + df = df.with_columns( + pl.when(pl.col("float_shares") > 0) + .then(pl.col("volume") * 10000.0 / pl.col("float_shares")) + .otherwise(None) + .alias("turnover_rate") + ) + + # 涨跌停 (用 raw_close / raw_high 和前一日原始收盘价) + # 优先用 API 原始前收盘价, 回退到 close_right, 最后回退到 raw_close + if "_prev_close_raw" in df.columns: + if "close_right" in df.columns: + prev_raw = pl.when(pl.col("_prev_close_raw").is_not_null()).then(pl.col("_prev_close_raw")).otherwise(pl.col("close_right")) + else: + prev_raw = pl.col("_prev_close_raw") + elif "close_right" in df.columns: + prev_raw = pl.col("close_right") + else: + prev_raw = pl.col("raw_close") + is_chinext = pl.col("symbol").str.starts_with("300") | pl.col("symbol").str.starts_with("301") + is_star = pl.col("symbol").str.starts_with("688") | pl.col("symbol").str.starts_with("689") + is_bj = pl.col("symbol").str.ends_with(".BJ") + limit_pct = ( + pl.when(is_chinext).then(0.20) + .when(is_star).then(0.20) + .when(is_bj).then(0.30) + .otherwise(0.10) + ) + if "_is_st" in df.columns: + limit_pct = pl.when(pl.col("_is_st").fill_null(False)).then(0.05).otherwise(limit_pct) + limit_pct = limit_pct.alias("_limit_pct") + + 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") >= (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") <= (effective_limit_down + 0.005)) + .otherwise(None).cast(pl.Boolean) + ) + + df = df.with_columns([ + is_limit_up.alias("signal_limit_up"), + is_limit_down.alias("signal_limit_down"), + # 跌停翘板 + pl.when(prev_raw > 0) + .then( + (~is_limit_down.fill_null(True)) + & (pl.col("low") <= effective_limit_down + 0.005) + & (pl.col("close") > pl.col("open")) + ).otherwise(None).cast(pl.Boolean) + .alias("signal_limit_down_recovery"), + # 炸板: 最高价曾触及涨停价 + 最终未封住 + pl.when((prev_raw > 0) & (pl.col("raw_high") > 0)) + .then( + (~is_limit_up.fill_null(True)) + & (pl.col("raw_high") >= effective_limit_up - 0.005) + ).otherwise(None).cast(pl.Boolean) + .alias("signal_broken_limit_up"), + ]) + + # 连板数: 同向 +1, 不同向归零 + # _prev_consec_up / _prev_consec_down 来自 live_agg (昨日 enriched) + if "_prev_consec_up" not in df.columns: + df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("_prev_consec_up")) + if "_prev_consec_down" not in df.columns: + df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("_prev_consec_down")) + prev_up = pl.col("_prev_consec_up").fill_null(0).cast(pl.UInt32) + prev_down = pl.col("_prev_consec_down").fill_null(0).cast(pl.UInt32) + df = df.with_columns([ + pl.when(is_limit_up.fill_null(False)) + .then((prev_up + 1).cast(pl.UInt32)) + .otherwise(pl.lit(0).cast(pl.UInt32)) + .alias("consecutive_limit_ups"), + pl.when(is_limit_down.fill_null(False)) + .then((prev_down + 1).cast(pl.UInt32)) + .otherwise(pl.lit(0).cast(pl.UInt32)) + .alias("consecutive_limit_downs"), + ]) + + # 清理 + cleanup = ["_limit_pct", "_is_st", "limit_up", "limit_down"] + for c in df.columns: + if c.endswith("_inst"): + cleanup.append(c) + for c in ["name", "float_shares"]: + if c in df.columns: + cleanup.append(c) + df = df.drop([c for c in cleanup if c in df.columns]) + + return df diff --git a/serve/backend/app/jobs/__init__.py b/serve/backend/app/jobs/__init__.py new file mode 100644 index 0000000..3daa442 --- /dev/null +++ b/serve/backend/app/jobs/__init__.py @@ -0,0 +1 @@ +"""APScheduler 任务。""" diff --git a/serve/backend/app/jobs/daily_pipeline.py b/serve/backend/app/jobs/daily_pipeline.py new file mode 100644 index 0000000..a8ad488 --- /dev/null +++ b/serve/backend/app/jobs/daily_pipeline.py @@ -0,0 +1,839 @@ +"""盘后管道 + 盘前维表同步。 + +调度: + 09:10 盘前 — 同步个股维表 instruments (全量覆盖) + 15:30 盘后 — 日K同步 + 增量除权因子 + enriched 计算 + 刷新视图 + +盘后同步策略: + 日 K: QuoteService 交易时段已实时落盘 → 有数据时跳过 batch,首次拉 1 年区间 + 除权因子: 从已有数据最新日期的下一天开始增量获取,避免重复拉取和计算 +""" +from __future__ import annotations + +import logging +from collections.abc import Callable +from pathlib import Path + +import polars as pl +from apscheduler.schedulers.asyncio import AsyncIOScheduler +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, 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 + +logger = logging.getLogger(__name__) + +ProgressCb = Callable[..., None] + + +def _noop(stage: str, pct: int, msg: str, **kwargs) -> None: # noqa: ARG001 + pass + + +def _invalidate(table: str | None = None) -> None: + """stage 写完调用,让 /api/data/status 只重算被影响的那张表。""" + from app.api.data import invalidate_data_cache + invalidate_data_cache(table) + + +def _resolve_universe(capset: CapabilitySet) -> list[str]: + """解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。 + + 有 batch 能力 → 直接拉 CN_Equity_A universe + 其他用户 → 用 instruments parquet + watchlist 兜底 + """ + if capset.has(Cap.KLINE_DAILY_BATCH): + try: + all_a = get_pool("CN_Equity_A", refresh=True) + if all_a: + return sorted(all_a) + except Exception as e: # noqa: BLE001 + logger.warning("CN_Equity_A pool unavailable, fallback: %s", e) + + # Free 用户兜底: instruments parquet + watchlist + demo + base: set[str] = set(DEMO_SYMBOLS) + base.update(get_pool("watchlist")) + d = Path(settings.data_dir) + inst_path = d / "instruments" / "instruments.parquet" + if inst_path.exists(): + try: + inst = pl.read_parquet(inst_path, columns=["symbol"]) + base.update(inst["symbol"].to_list()) + except Exception as e: # noqa: BLE001 + logger.warning("instruments supplement failed: %s", e) + return sorted(base) + + +def run_instruments_sync(repo: KlineRepository) -> dict: + """盘前同步个股维表。""" + rows = instrument_sync.sync_instruments(repo.store.data_dir) + _refresh_instruments_view(repo) + _invalidate("instruments") + return {"instruments_rows": rows} + + +def run_now( + repo: KlineRepository, + capset: CapabilitySet, + on_progress: ProgressCb | None = None, +) -> dict: + """立即执行一次盘后管道,支持进度回调。 + + 跳过的 stage **不 emit**,避免前端把"无 capability"的卡片错误标记为 active/done。 + result 里带 skipped_stages 列表供前端展示。 + """ + emit = on_progress or _noop + skipped: list[str] = [] + + # 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} 只标的") + _invalidate("instruments") + + emit("resolve_universe", 9, "解析标的池…") + universe = _resolve_universe(capset) + emit("resolve_universe", 10, f"标的池规模:{len(universe)} 只") + + # Step 1: 日 K 同步 + # 付费档 + 今天有数据 → 实时行情接口拉一次覆写(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 + + # 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 补齐缺口。 + # 也覆盖"今天已有数据但无实时行情权限(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] %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), + f"日K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True) + written_daily = kline_sync.sync_and_persist_daily_batch( + universe, repo, capset, + start_date=_dt.combine(start_date, _dt.min.time()), + end_date=_dt.combine(today, _dt.min.time()), + on_chunk_done=_daily_chunk_progress, + ) + gap_days = (today - start_date).days + new_daily_days = gap_days + emit("sync_daily", 45, f"日K 完成,覆盖 {gap_days} 天") + logger.info("sync_daily: [%s ~ %s] done, %d days", start_date, today, gap_days) + 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) + + def _daily_chunk_progress(cur: int, tot: int) -> None: + emit("sync_daily", 12 + int(33 * cur / tot), + f"日K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True) + written_daily = kline_sync.sync_and_persist_daily_batch( + universe, repo, capset, + start_date=_dt.combine(start_date, _dt.min.time()), + end_date=_dt.combine(today, _dt.min.time()), + on_chunk_done=_daily_chunk_progress, + ) + new_daily_days = 365 + emit("sync_daily", 45, "日K 完成") + logger.info("sync_daily: [%s ~ %s] done", start_date, today) + _invalidate("daily") + + # 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() + if daily_range_start is not None: + adj_start = datetime.combine(daily_range_start, datetime.min.time()) + else: + # 日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}]…") + logger.info("sync_adj: [%s ~ %s] start", adj_start_str, adj_end_str) + + def _adj_chunk_progress(cur: int, tot: int) -> None: + emit("sync_adj", 50 + int(10 * cur / tot), + f"除权因子批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True) + written_adj, affected_symbols = kline_sync.sync_adj_factor( + universe, repo, capset, + start_time=adj_start, end_time=adj_end, + on_chunk_done=_adj_chunk_progress, + ) + if affected_symbols: + _refresh_single_view(repo, "adj_factor") + emit("sync_adj", 60, f"除权因子完成,新增 {len(affected_symbols)} 只个股") + logger.info("sync_adj: [%s ~ %s] done, %d symbols", adj_start_str, adj_end_str, len(affected_symbols)) + else: + emit("sync_adj", 60, "除权因子完成,无新增") + logger.info("sync_adj: [%s ~ %s] no new factors", adj_start_str, adj_end_str) + _invalidate("adj_factor") + else: + skipped.append("sync_adj") + logger.info("sync_adj skipped: no ADJ_FACTOR capability") + + # Step 2: 计算 enriched + # 判断策略: + # - 首次 (enriched 目录不存在) → 全量 + # - 往前扩展历史 (新日期 < enriched 已有最早日期) → 全量 + # 前面的除权因子会改变累积因子链,影响后面所有日期的复权价格 + # - 往后新增日期 (新日期 > enriched 已有最晚日期) + # → 增量补新区块(所有标的) + 受除权影响个股全日期重算 + # - 无新日期 + 有新除权因子 → 增量: 只重算受影响个股的全部日期 + # - 无新日期 + 无变化 → 跳过 + enriched_dir = repo.store.data_dir / "kline_daily_enriched" + enriched_exists = enriched_dir.exists() and any(enriched_dir.glob("date=*")) + daily_dir = repo.store.data_dir / "kline_daily" + daily_days = len(list(daily_dir.glob("date=*"))) if daily_dir.exists() else 0 + prev_enriched_days = len(list(enriched_dir.glob("date=*"))) if enriched_exists else 0 + + # 判断新日期方向: 找 daily 和 enriched 的日期集合做比较 + forward_incremental = False + backward_extension = False + + if daily_days > prev_enriched_days and enriched_exists: + daily_dates = sorted(d.stem.split("=")[1] for d in daily_dir.glob("date=*")) + enriched_dates = sorted(d.stem.split("=")[1] for d in enriched_dir.glob("date=*")) + earliest_enriched = enriched_dates[0] + latest_enriched = enriched_dates[-1] + new_dates = set(daily_dates) - set(enriched_dates) + if new_dates: + # 有新日期早于 enriched 最早日期 → 往前扩展 + if any(d < earliest_enriched for d in new_dates): + backward_extension = True + # 有新日期晚于 enriched 最晚日期 → 往后新增 + if any(d > latest_enriched for d in new_dates): + forward_incremental = True + + def _enriched_batch_progress(cur: int, tot: int) -> None: + emit("compute_enriched", 65 + int(23 * cur / tot), + f"计算指标 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True) + + if not enriched_exists or backward_extension: + # 首次 或 往前扩展 → 全量 + emit("compute_enriched", 65, "全量计算 enriched…") + logger.info("compute_enriched: full rebuild (first=%s, backward=%s, daily=%d, enriched=%d)", + not enriched_exists, backward_extension, daily_days, prev_enriched_days) + written_enriched = run_pipeline(on_batch_done=_enriched_batch_progress) + new_enriched_days = len(list(enriched_dir.glob("date=*"))) + emit("compute_enriched", 88, f"enriched 完成,覆盖 {new_enriched_days} 天") + logger.info("compute_enriched: full rebuild done, %d days", new_enriched_days) + elif forward_incremental: + # 往后新增日期: 增量补新区块 + 受影响个股全日期重算 + symbols_to_recompute = list(set(affected_symbols)) if affected_symbols else [] + emit("compute_enriched", 65, + f"增量计算 enriched (新日期 + {len(symbols_to_recompute)} 只个股重算)…" + if symbols_to_recompute else "增量计算 enriched (新日期)…") + logger.info("compute_enriched: forward incremental, %d symbols to recompute", + len(symbols_to_recompute)) + written_enriched = run_pipeline( + new_dates_only=True, + symbols=symbols_to_recompute or None, + on_batch_done=_enriched_batch_progress, + ) + new_enriched_days = len(list(enriched_dir.glob("date=*"))) + emit("compute_enriched", 88, f"enriched 完成,覆盖 {new_enriched_days} 天") + logger.info("compute_enriched: forward incremental done, %d days", new_enriched_days) + elif affected_symbols: + # 无新日期,仅除权因子变更 → 只重算受影响个股的全部日期 + emit("compute_enriched", 65, f"增量计算 enriched ({len(affected_symbols)} 只个股)…") + logger.info("compute_enriched: adj_factor incremental, %d symbols", len(affected_symbols)) + written_enriched = run_pipeline(symbols=affected_symbols, on_batch_done=_enriched_batch_progress) + emit("compute_enriched", 88, f"enriched 完成,{len(affected_symbols)} 只个股") + else: + written_enriched = 0 + logger.info("compute_enriched: skip (no new daily, no adj_factor changes)") + _refresh_single_view(repo, "kline_enriched") + _invalidate("enriched") + + # Step 2.3: 指数 / ETF 同步 — 物理分开存储;ETF 可复权,指数不复权。 + written_index_daily = 0 + written_etf_daily = 0 + index_count = 0 + 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: + 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() + 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/etf failed: %s", e) + emit("sync_index", 89, f"指数/ETF同步失败:{e}") + else: + skipped.append("sync_index") + + # Step 2.5: 分钟 K 同步(可选) — 未启用或无 capability 时静默跳过(不 emit) + from app.services import preferences + minute_on = preferences.get_minute_sync_enabled() + minute_days = preferences.get_minute_sync_days() + written_minute = 0 + if minute_on and capset.has(Cap.KLINE_MINUTE_BATCH): + minute_start = today - _td(days=minute_days) + emit("sync_minute", 90, f"获取分钟K [{minute_start} ~ {today}]…") + logger.info("sync_minute: [%s ~ %s] start", minute_start, today) + minute_symbols = _resolve_minute_symbols(capset) + def _minute_chunk_progress(cur: int, tot: int) -> None: + emit("sync_minute", 90 + int(3 * cur / tot), + f"分钟K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True) + written_minute = kline_sync.sync_and_persist_minute( + minute_symbols, repo, capset, days=minute_days, + on_chunk_done=_minute_chunk_progress, + ) + minute_dir = repo.store.data_dir / "kline_minute" + minute_cover_days = len(list(minute_dir.glob("date=*"))) if minute_dir.exists() else 0 + emit("sync_minute", 93, f"分钟K完成,覆盖 {minute_cover_days} 天") + logger.info("sync_minute: [%s ~ %s] done, %d days", minute_start, today, minute_cover_days) + _invalidate("minute") + else: + skipped.append("sync_minute") + if minute_on: + logger.info("sync_minute skipped: no KLINE_MINUTE_BATCH capability") + else: + logger.info("sync_minute skipped: user disabled") + + # Step 3: 刷新视图 + emit("refresh_views", 95, "刷新 DuckDB 视图…") + _refresh_views(repo) + + emit("done", 100, "完成") + _invalidate(None) # 兜底:全清 + + return { + "universe_size": len(universe), + "daily_days": new_daily_days, + "adj_factor_symbols": len(affected_symbols), + "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, + } + + +def _refresh_views(repo: KlineRepository) -> None: + """刷新所有 DuckDB 视图。""" + d = repo.store.data_dir.as_posix() + views = { + "kline_daily": f"{d}/kline_daily/**/*.parquet", + "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet", + "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet", + "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet", + "kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet", + "kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet", + "kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet", + "kline_minute": f"{d}/kline_minute/**/*.parquet", + "adj_factor": f"{d}/adj_factor/**/*.parquet", + "adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet", + "instruments": f"{d}/instruments/**/*.parquet", + "instruments_index": f"{d}/instruments_index/**/*.parquet", + "instruments_etf": f"{d}/instruments_etf/**/*.parquet", + } + for name, path in views.items(): + try: + repo.db.execute( + f"CREATE OR REPLACE VIEW {name} AS " + f"SELECT * FROM read_parquet('{path}', union_by_name=true)" + ) + 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: + """刷新单个 DuckDB 视图。""" + d = repo.store.data_dir.as_posix() + paths = { + "kline_daily": f"{d}/kline_daily/**/*.parquet", + "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet", + "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet", + "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet", + "kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet", + "kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet", + "kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet", + "kline_minute": f"{d}/kline_minute/**/*.parquet", + "adj_factor": f"{d}/adj_factor/**/*.parquet", + "adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet", + "instruments": f"{d}/instruments/**/*.parquet", + "instruments_index": f"{d}/instruments_index/**/*.parquet", + "instruments_etf": f"{d}/instruments_etf/**/*.parquet", + } + path = paths.get(name) + if not path: + return + try: + repo.db.execute( + f"CREATE OR REPLACE VIEW {name} AS " + f"SELECT * FROM read_parquet('{path}', union_by_name=true)" + ) + except Exception as e: # noqa: BLE001 + logger.warning("refresh view %s failed: %s", name, e) + + +def _resolve_minute_symbols(capset: CapabilitySet) -> list[str]: + """分钟 K 同步标的 — 与日K共用同一标的池。""" + return _resolve_universe(capset) + + +def _refresh_instruments_view(repo: KlineRepository) -> None: + """单独刷新 instruments 视图。""" + d = repo.store.data_dir.as_posix() + try: + repo.db.execute( + f"CREATE OR REPLACE VIEW instruments AS " + f"SELECT * FROM read_parquet('{d}/instruments/**/*.parquet', union_by_name=true)" + ) + except Exception as e: # noqa: BLE001 + logger.warning("refresh instruments view failed: %s", e) + + +def _run_tracked(fn, job_label: str) -> None: + """调度触发时包装 JobStore 跟踪,确保同步历史有记录。""" + from app.services.pipeline_jobs import job_store + + job_id = job_store.create() + job_store.start(job_id) + + def progress(stage: str, pct: int, msg: str, stage_pct: int | None = None, + skip_log: bool = False) -> None: + job_store.progress(job_id, stage, pct, msg, stage_pct=stage_pct, skip_log=skip_log) + + try: + result = fn(on_progress=progress) + job_store.succeed(job_id, result) + logger.info("scheduled %s completed: job_id=%s", job_label, job_id) + except Exception: + logger.exception("scheduled %s failed: job_id=%s", job_label, job_id) + 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 — 同步个股维表 + 工作日 HH:MM — 盘后管道(时间由用户偏好决定,默认 15:30) + """ + from app.services import preferences + sched = preferences.get_pipeline_schedule() + inst_sched = preferences.get_instruments_schedule() + + scheduler = AsyncIOScheduler(timezone="Asia/Shanghai") + + # 盘前: 同步 instruments(时间由偏好决定) + def _instruments_task(on_progress=None): + emit = on_progress or _noop + emit("sync_instruments", 0, "同步个股维表…") + result = run_instruments_sync(repo) + emit("done", 100, f"个股维表同步完成,{result.get('instruments_rows', 0)} 只标的") + return result + + scheduler.add_job( + lambda: _run_tracked(_instruments_task, "instruments_sync"), + trigger=CronTrigger(day_of_week="mon-fri", + hour=inst_sched["hour"], minute=inst_sched["minute"], + timezone="Asia/Shanghai"), + id="pre_market_instruments", + misfire_grace_time=1800, + replace_existing=True, + ) + + # 盘后: 日 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(_pipeline_then_refresh, "daily_pipeline"), + trigger=CronTrigger(day_of_week="mon-fri", + hour=sched["hour"], minute=sched["minute"], + timezone="Asia/Shanghai"), + id="daily_pipeline", + misfire_grace_time=3600, + replace_existing=True, + ) + + # 盘后: 五档盘口 sealed 定版(时间由偏好决定, 默认15:02, 范围15:01~18:00) + depth_sched = preferences.get_depth_finalize_time() + + def _depth_finalize(): + depth_svc = getattr(_get_app_state(), "depth_service", None) if _get_app_state() else None + if depth_svc: + depth_svc.finalize() + + scheduler.add_job( + _depth_finalize, + trigger=CronTrigger(day_of_week="mon-fri", + hour=depth_sched["hour"], minute=depth_sched["minute"], + timezone="Asia/Shanghai"), + id="depth_finalize", + misfire_grace_time=3600, + replace_existing=True, + ) + + # 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。 + # 默认关闭 —— 仅当用户在复盘页开启时才注册 job。 + # 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。 + # 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"], + depth_sched["hour"], depth_sched["minute"]) + return scheduler + + +# app_state 延迟引用(start_scheduler 在 lifespan 早期调用, app.state 可能还没就绪) +_app_state_ref = None + + +def set_app_state(app_state) -> None: + """lifespan 注册 app.state 引用, 供 scheduled job 访问 depth_service 等单例。""" + global _app_state_ref + _app_state_ref = app_state + + +def _get_app_state(): + return _app_state_ref diff --git a/serve/backend/app/main.py b/serve/backend/app/main.py new file mode 100644 index 0000000..d863c26 --- /dev/null +++ b/serve/backend/app/main.py @@ -0,0 +1,306 @@ +"""FastAPI 入口。""" +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from app import __version__ +from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist +from app.api.routes import router as core_router +from app.config import settings +from app.jobs import daily_pipeline +from app.services.quote_service import QuoteService +from app.tickflow import client as tf_client +from app.tickflow.policy import detect_capabilities +from app.tickflow.repository import DataStore, KlineRepository + +logging.basicConfig( + level=settings.log_level, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info( + "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) + app.state.datastore = store + app.state.repo = repo + + # Polars 缓存预热 + repo.refresh_cache() + + # 能力探测 + capset = detect_capabilities() + app.state.capabilities = capset + logger.info("ready; %d capabilities active", len(capset.all())) + + # 全局行情服务 + qs = QuoteService() + app.state.quote_service = qs + qs.set_repo(repo) + qs.boot_check() + + # QuoteService 需要访问 strategy_monitor 等单例 + # 先创建 strategy_monitor,再注入 app.state + from app.strategy.monitor import StrategyMonitorService + strategy_monitor = StrategyMonitorService() + app.state.strategy_monitor = strategy_monitor + qs.set_app_state(app.state) + + # 五档盘口 sealed 服务(真假涨停/跌停, 独立旁路线) + from app.services.depth_service import DepthService + depth_service = DepthService() + depth_service.set_repo(repo) + depth_service.set_app_state(app.state) + app.state.depth_service = depth_service + + # 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run) + try: + daily_pipeline.set_app_state(app.state) # 供 depth_finalize job 访问 depth_service + scheduler = daily_pipeline.start_scheduler(repo, capset) + app.state.scheduler = scheduler + except Exception as e: # noqa: BLE001 + logger.warning("scheduler not started: %s", e) + app.state.scheduler = None + + # depth sealed: 启动补跑(当天文件不存在) + 盘中轮询(有能力时) + try: + depth_service.boot_check() + depth_service.start_polling() + except Exception as e: # noqa: BLE001 + logger.warning("depth_service init failed: %s", e) + + # 扩展数据定时拉取 + from app.services.ext_pull import pull_scheduler + pull_scheduler.start(store.data_dir) + pull_scheduler.refresh(store.data_dir) + app.state.pull_scheduler = pull_scheduler + + # 内置扩展表 (概念/行业): 只创建 config (含拉取配置), 不自动拉数据 + # 数据获取由用户在概念/行业页点「获取数据」手动触发 (POST /api/ext-data/presets/{id}/fetch) + try: + from app.services.ext_presets import ensure_builtin_presets + await ensure_builtin_presets(store.data_dir) + except Exception as e: # noqa: BLE001 + logger.warning("内置扩展表初始化失败 (不影响启动): %s", e) + + # 财务数据 (需 Expert 套餐): 仅初始化调度器供 /api/financials/sync/* 手动同步, + # 不启动自动调度——用户在「财务分析」页点「同步」手动拉取。 + from app.services.financial_sync import financial_scheduler + financial_scheduler.start(store.data_dir, capset) + app.state.financial_scheduler = financial_scheduler + + # 策略引擎 + from app.strategy.engine import StrategyEngine + from app.strategy.monitor import StrategyMonitorService + from app.services.screener import ScreenerService + + _screener_svc = ScreenerService(repo) + strategy_dirs = [ + Path(__file__).resolve().parent / "strategy" / "builtin", + store.data_dir / "strategies" / "custom", + store.data_dir / "strategies" / "ai", + ] + strategy_engine = StrategyEngine( + enriched_loader=_screener_svc._load_enriched_for_date, + enriched_history_loader=_screener_svc._load_enriched_history, + strategy_dirs=strategy_dirs, + ) + app.state.strategy_engine = strategy_engine + logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies())) + + # 通用监控规则引擎: 启动时 reload 规则到内存态 (修复重启后告警失效) + from app.strategy.monitor import MonitorRuleEngine + from app.strategy import monitor_rules as mr_store + from app.services import preferences + monitor_engine = MonitorRuleEngine() + monitor_engine.set_strategy_engine(strategy_engine) + monitor_engine.set_data_dir(store.data_dir) + # 复用 ScreenerService 的历史窗口加载器 (三级缓存, 启动预计算命中 ~0ms), + # 让声明 filter_history 的策略 (如反包) 也能在实时监控里跑选股 → 盘中触发通知。 + monitor_engine.set_history_loader(_screener_svc._load_enriched_history) + + # 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页) + try: + if preferences.get_strategy_monitor_enabled(): + ids = preferences.get_strategy_monitor_ids() + if ids: + names = {s.id: s.name for s in strategy_engine.list_strategies()} + mr_store.migrate_strategy_monitors(store.data_dir, ids, names) + logger.info("strategy monitor migrated: %d strategies", len(ids)) + except Exception as e: # noqa: BLE001 + logger.warning("strategy monitor migration failed: %s", e) + + try: + rules = mr_store.load_all(store.data_dir) + monitor_engine.set_rules(rules) + logger.info("monitor engine loaded: %d rules", monitor_engine.rule_count) + except Exception as e: # noqa: BLE001 + logger.warning("monitor engine load failed: %s", e) + app.state.monitor_engine = monitor_engine + + yield + + if app.state.scheduler: + app.state.scheduler.shutdown(wait=False) + ps = getattr(app.state, "pull_scheduler", None) + if ps: + ps.stop() + fsc = getattr(app.state, "financial_scheduler", None) + if fsc: + fsc.stop() + qs = getattr(app.state, "quote_service", None) + if qs: + qs.stop() + dsvc = getattr(app.state, "depth_service", None) + if dsvc: + dsvc.stop_polling() + logger.info("shutdown") + + +app = FastAPI( + title="TickFlow Stock Panel", + version=__version__, + description="A 股选股 + 回测面板 — TickFlow 适配", + lifespan=lifespan, +) + +# CORS: 允许局域网访问 (自托管场景, 放开所有来源) +# 注: allow_credentials=True 与 allow_origins=['*'] 不能共存 (浏览器规范), +# 本项目认证走 header (API Key), 不依赖 cookie, 故关闭 credentials 换取通配来源。 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ================================================================ +# 访问认证中间件 +# ================================================================ +# 拦截所有 /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 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_api.router) +app.include_router(kline.router) +app.include_router(watchlist.router) +app.include_router(screener.router) +app.include_router(backtest.router) +app.include_router(intraday.router) +app.include_router(indices.router) +app.include_router(overview.router) +app.include_router(analysis.router) +app.include_router(pipeline.router) +app.include_router(data.router) +app.include_router(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 + + +@app.exception_handler(CapabilityDenied) +async def capability_denied_handler(request: Request, exc: CapabilityDenied) -> JSONResponse: + return JSONResponse( + status_code=403, + content={"detail": str(exc), "suggestion": exc.suggestion}, + ) + +# 生产期静态文件(前端 dist) +_static = Path(settings.static_dir) +if _static.exists(): + if (_static / "assets").exists(): + app.mount("/assets", StaticFiles(directory=_static / "assets"), name="assets") + + @app.get("/{full_path:path}", include_in_schema=False) + def spa_fallback(full_path: str): # noqa: ARG001 + """所有未匹配路径回退到 index.html — React Router 接管。 + + index.html 禁止缓存 (Cache-Control: no-store), 确保浏览器每次拿到 + 最新版本引用的 JS/CSS 文件名 (assets 带 hash, 可长缓存)。 + """ + index = _static / "index.html" + if index.exists(): + return FileResponse( + index, + headers={"Cache-Control": "no-store, must-revalidate"}, + ) + return {"error": "frontend not built"} diff --git a/serve/backend/app/secrets_store.py b/serve/backend/app/secrets_store.py new file mode 100644 index 0000000..28d2c1b --- /dev/null +++ b/serve/backend/app/secrets_store.py @@ -0,0 +1,96 @@ +"""Key / 凭据本地存储(§14)。 + +存储位置:`data/user_data/secrets.json`,权限 0600。 +优先级:secrets.json > .env > 空(Free 模式)。 + +UI 改 Key 时只动这个文件,不动 .env。 +""" +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _path() -> Path: + from app.config import settings + p = settings.data_dir / "user_data" / "secrets.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("secrets.json malformed: %s", e) + return {} + + +def save(updates: dict) -> dict: + """合并写入(不会清掉未提及的字段)。返回新内容。""" + current = load() + current.update({k: v for k, v in updates.items() if v is not None}) + p = _path() + p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8") + try: + os.chmod(p, 0o600) + except OSError: + pass + return current + + +def clear(*keys: str) -> dict: + """清掉指定字段(留空清全部)。""" + p = _path() + if not p.exists(): + return {} + if not keys: + p.unlink() + return {} + current = load() + for k in keys: + current.pop(k, None) + p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8") + return current + + +def get_tickflow_key() -> str: + """取当前 TickFlow Key:secrets.json 优先,否则 .env。""" + val = load().get("tickflow_api_key") + if val: + return val + from app.config import settings + return settings.tickflow_api_key or "" + + +def get_ai_key() -> str: + """取当前 AI Key:secrets.json 优先,否则 .env。""" + val = load().get("ai_api_key") + if val: + return val + from app.config import settings + return settings.ai_api_key or "" + + +def get_ai_config(key: str, default: str = "") -> str: + """取 AI 配置项:secrets.json 优先,否则 config。""" + val = load().get(key) + if val: + return val + from app.config import settings + return getattr(settings, key, default) or default + + +def mask(key: str, prefix: int = 4, suffix: int = 4) -> str: + """脱敏显示。""" + if not key: + return "" + if len(key) <= prefix + suffix: + return "•" * len(key) + return f"{key[:prefix]}{'•' * 6}{key[-suffix:]}" diff --git a/serve/backend/app/services/__init__.py b/serve/backend/app/services/__init__.py new file mode 100644 index 0000000..829d0ed --- /dev/null +++ b/serve/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""业务服务层。""" diff --git a/serve/backend/app/services/ai_provider.py b/serve/backend/app/services/ai_provider.py new file mode 100644 index 0000000..203f711 --- /dev/null +++ b/serve/backend/app/services/ai_provider.py @@ -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") + 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 "" diff --git a/serve/backend/app/services/ai_reports.py b/serve/backend/app/services/ai_reports.py new file mode 100644 index 0000000..caf4115 --- /dev/null +++ b/serve/backend/app/services/ai_reports.py @@ -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") diff --git a/serve/backend/app/services/alert_store.py b/serve/backend/app/services/alert_store.py new file mode 100644 index 0000000..961331a --- /dev/null +++ b/serve/backend/app/services/alert_store.py @@ -0,0 +1,209 @@ +"""告警触发记录存储 — JSONL 追加写 + 滚动清理。 + +职责: + - 把每次触发的 AlertEvent 追加写入 data/user_data/alerts.jsonl + - 提供查询 (按来源/类型过滤、时间倒序、限量) + - 滚动清理: 保留近 N 天 + 上限 M 条 (取交集) + +设计: + - JSONL 每行一个 JSON 对象,便于增量追加和流式读取 + - 清理策略: 追加后按需 prune (按 ts 删旧),避免文件无限膨胀 + - 读时全量加载到内存过滤 (记录量受上限约束, 5000 条量级无压力) +""" +from __future__ import annotations + +import json +import logging +import threading +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 保留策略 +MAX_DAYS = 7 +MAX_RECORDS = 5000 +# 每隔多少次写入触发一次清理 (避免每次写都 prune) +PRUNE_EVERY = 20 + +_lock = threading.Lock() +_write_count = 0 + + +def _path(data_dir: Path) -> Path: + p = data_dir / "user_data" / "alerts.jsonl" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def append(data_dir: Path, event: dict) -> None: + """追加一条触发记录。event 应含 ts(毫秒)、rule_id、source 等字段。""" + line = json.dumps(event, ensure_ascii=False) + with _lock: + p = _path(data_dir) + with p.open("a", encoding="utf-8") as f: + f.write(line + "\n") + global _write_count + _write_count += 1 + if _write_count >= PRUNE_EVERY: + _write_count = 0 + _prune_locked(p) + + +def append_many(data_dir: Path, events: list[dict]) -> None: + """批量追加。""" + if not events: + return + with _lock: + p = _path(data_dir) + with p.open("a", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev, ensure_ascii=False) + "\n") + global _write_count + _write_count += len(events) + if _write_count >= PRUNE_EVERY: + _write_count = 0 + _prune_locked(p) + + +def list_recent( + data_dir: Path, + days: int = MAX_DAYS, + limit: int = MAX_RECORDS, + source: str | None = None, + type: str | None = None, +) -> list[dict]: + """读取近 N 天记录,按时间倒序,支持按 source/type 过滤。""" + import time + cutoff = (time.time() - days * 86400) * 1000 # 毫秒 + out: list[dict] = [] + p = _path(data_dir) + if not p.exists(): + return [] + try: + with p.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + except Exception: + continue + if ev.get("ts", 0) < cutoff: + continue + if source and ev.get("source") != source: + continue + if type and ev.get("type") != type: + continue + out.append(ev) + except Exception as e: + logger.warning("alert_store read failed: %s", e) + return [] + # 时间倒序 + 截断 + out.sort(key=lambda x: x.get("ts", 0), reverse=True) + return out[:limit] + + +def clear(data_dir: Path) -> int: + """清空全部记录,返回清除的条数。""" + with _lock: + p = _path(data_dir) + if not p.exists(): + return 0 + count = 0 + try: + with p.open("r", encoding="utf-8") as f: + count = sum(1 for line in f if line.strip()) + except Exception: + pass + p.write_text("", encoding="utf-8") + return count + + +def delete_one(data_dir: Path, ts: int) -> bool: + """删除指定 ts 的单条记录,返回是否删除成功。 + + JSONL 无主键, 用 ts(毫秒时间戳) 作为标识。 + 若存在多条同 ts, 只删第一条。 + """ + with _lock: + p = _path(data_dir) + if not p.exists(): + return False + kept: list[dict] = [] + deleted = False + try: + with p.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + except Exception: + continue + if not deleted and ev.get("ts") == ts: + deleted = True + continue + kept.append(ev) + except Exception as e: + logger.warning("alert_store delete_one read failed: %s", e) + return False + if not deleted: + return False + try: + with p.open("w", encoding="utf-8") as f: + for ev in kept: + f.write(json.dumps(ev, ensure_ascii=False) + "\n") + except Exception as e: + logger.warning("alert_store delete_one write failed: %s", e) + return False + return True + return count + + +def count(data_dir: Path) -> int: + """返回当前记录总数。""" + p = _path(data_dir) + if not p.exists(): + return 0 + try: + with p.open("r", encoding="utf-8") as f: + return sum(1 for line in f if line.strip()) + except Exception: + return 0 + + +def _prune_locked(p: Path) -> None: + """(调用方需持锁) 保留近 MAX_DAYS 天 + 上限 MAX_RECORDS 条。""" + import time + cutoff = (time.time() - MAX_DAYS * 86400) * 1000 + kept: list[dict] = [] + try: + with p.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + except Exception: + continue + if ev.get("ts", 0) >= cutoff: + kept.append(ev) + except FileNotFoundError: + return + except Exception as e: + logger.warning("alert_store prune read failed: %s", e) + return + # 上限截断 (保留最新的) + if len(kept) > MAX_RECORDS: + kept.sort(key=lambda x: x.get("ts", 0)) + kept = kept[-MAX_RECORDS:] + # 重写文件 + try: + with p.open("w", encoding="utf-8") as f: + for ev in kept: + f.write(json.dumps(ev, ensure_ascii=False) + "\n") + except Exception as e: + logger.warning("alert_store prune write failed: %s", e) diff --git a/serve/backend/app/services/auth.py b/serve/backend/app/services/auth.py new file mode 100644 index 0000000..77c7b35 --- /dev/null +++ b/serve/backend/app/services/auth.py @@ -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) diff --git a/serve/backend/app/services/backtest.py b/serve/backend/app/services/backtest.py new file mode 100644 index 0000000..806541c --- /dev/null +++ b/serve/backend/app/services/backtest.py @@ -0,0 +1,392 @@ +"""回测服务(§6.7)。 + +包 vectorbt — 全项目唯一一处出现 pandas。 +""" +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field +from datetime import date +from typing import Literal + +import numpy as np +import pandas as pd +import polars as pl + +from app.config import settings +from app.tickflow.repository import KlineRepository + +logger = logging.getLogger(__name__) + +# vectorbt 是 optional extras(见 pyproject.toml).未装时只有 backtest 不可用,其他功能正常. +_vbt = None +_vbt_unavailable_reason: str | None = None + + +class VectorbtUnavailable(RuntimeError): + """vectorbt 未安装 — 提示用户 `uv sync --extra backtest`.""" + + +def _get_vbt(): + global _vbt, _vbt_unavailable_reason + if _vbt is not None: + return _vbt + if _vbt_unavailable_reason is not None: + raise VectorbtUnavailable(_vbt_unavailable_reason) + try: + import vectorbt as vbt + _vbt = vbt + return _vbt + except ImportError as e: + _vbt_unavailable_reason = ( + "vectorbt 未安装 — 它是回测的可选依赖.macOS Intel 用户先 `brew install cmake` " + "然后 `uv sync --extra backtest`" + ) + logger.warning("vectorbt unavailable: %s", e) + raise VectorbtUnavailable(_vbt_unavailable_reason) from e + + +def is_available() -> bool: + """供 API 层快速检测.""" + try: + _get_vbt() + return True + except VectorbtUnavailable: + return False + + +SignalKind = Literal[ + "macd_golden", "macd_dead", + "ma_golden_5_20", "ma_dead_5_20", + "ma_golden_20_60", + "ma20_breakout", "ma20_breakdown", + "n_day_high", "n_day_low", + "boll_breakout_upper", "boll_breakdown_lower", + "volume_surge", + "rsi_oversold", "rsi_overbought", + "stop_loss", "trailing_stop", "max_hold", +] + + +@dataclass +class BacktestConfig: + symbols: list[str] + start: date + end: date + # 买入信号(任一触发即买) + entries: list[str] = field(default_factory=list) + # 卖出信号(任一触发即卖) + exits: list[str] = field(default_factory=list) + # 其他参数 + stop_loss_pct: float | None = None # 例 -0.05 = -5% + max_hold_days: int | None = None + fees_pct: float = 0.0002 # 万二佣金 + slippage_bps: float = 5 # 5 bps + # 撮合 + matching: Literal["close_t", "open_t+1"] = "close_t" + rsi_oversold_threshold: float = 30 + rsi_overbought_threshold: float = 70 + + +@dataclass +class BacktestResult: + run_id: str + config: dict + stats: dict + equity_curve: list[dict] # [{date, value}] + trades: list[dict] # [{symbol, entry_date, exit_date, pnl_pct, ...}] + per_symbol_stats: list[dict] # 每只股票的统计 + + +# enriched 表里的信号列名映射 +_SIGNAL_COLS: dict[SignalKind, str] = { + "macd_golden": "signal_macd_golden", + "macd_dead": "signal_macd_dead", + "ma_golden_5_20": "signal_ma_golden_5_20", + "ma_dead_5_20": "signal_ma_dead_5_20", + "ma_golden_20_60": "signal_ma_golden_20_60", + "ma20_breakout": "signal_ma20_breakout", + "ma20_breakdown": "signal_ma20_breakdown", + "n_day_high": "signal_n_day_high", + "n_day_low": "signal_n_day_low", + "boll_breakout_upper": "signal_boll_breakout_upper", + "boll_breakdown_lower": "signal_boll_breakdown_lower", + "volume_surge": "signal_volume_surge", +} + + +class BacktestService: + def __init__(self, repo: KlineRepository) -> None: + self.repo = repo + + def _load_panel( + self, + symbols: list[str], + start: date, + end: date, + ) -> pd.DataFrame: + """加载 [date × symbol] 价格面板 — Polars scan_parquet + 即时计算指标。 + + **全项目唯一从 Polars 转 pandas 的边界**(§7.4 / ADR-19)。 + """ + try: + enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet") + df = ( + pl.scan_parquet(enriched_glob) + .filter( + (pl.col("symbol").is_in(symbols)) + & (pl.col("date") >= start) + & (pl.col("date") <= end) + ) + .sort(["date", "symbol"]) + .collect() + ) + except Exception as e: # noqa: BLE001 + logger.warning("backtest load failed: %s", e) + return pd.DataFrame() + + if df.is_empty(): + return pd.DataFrame() + + # 即时计算指标 + 信号 + from app.indicators.pipeline import compute_all + df = compute_all(df) + + # 选择需要的列 + needed_cols = [ + "date", "symbol", "open", "high", "low", "close", "volume", + "rsi_14", "signal_macd_golden", "signal_macd_dead", + "signal_ma_golden_5_20", "signal_ma_dead_5_20", + "signal_ma_golden_20_60", + "signal_ma20_breakout", "signal_ma20_breakdown", + "signal_n_day_high", "signal_n_day_low", + "signal_boll_breakout_upper", "signal_boll_breakdown_lower", + "signal_volume_surge", + ] + existing = [c for c in needed_cols if c in df.columns] + df = df.select(existing) + + # to_pandas 边界 + return df.to_pandas(use_pyarrow_extension_array=False) + + def _build_signal_matrix( + self, + panel: pd.DataFrame, + kinds: list[str], + config: BacktestConfig, + ) -> pd.DataFrame: + """从面板构造 [date × symbol] 的布尔信号矩阵。""" + if not kinds or panel.empty: + return pd.DataFrame() + + # pivot 成 [date × symbol] 形式 + result = None + for kind in kinds: + mat = None + if kind in _SIGNAL_COLS: + col = _SIGNAL_COLS[kind] + mat = panel.pivot(index="date", columns="symbol", values=col).fillna(False).astype(bool) + elif kind == "rsi_oversold": + mat = (panel.pivot(index="date", columns="symbol", values="rsi_14") + < config.rsi_oversold_threshold) + elif kind == "rsi_overbought": + mat = (panel.pivot(index="date", columns="symbol", values="rsi_14") + > config.rsi_overbought_threshold) + # stop_loss / trailing / max_hold 通过 vectorbt 参数处理,不参与信号矩阵 + + if mat is not None: + result = mat if result is None else (result | mat) + return result if result is not None else pd.DataFrame() + + def run(self, config: BacktestConfig) -> BacktestResult: + vbt = _get_vbt() + run_id = uuid.uuid4().hex[:10] + + panel = self._load_panel(config.symbols, config.start, config.end) + if panel.empty: + return BacktestResult( + run_id=run_id, + config=_config_to_dict(config), + stats={"error": "no data"}, + equity_curve=[], + trades=[], + per_symbol_stats=[], + ) + + # 价格面板 + close = panel.pivot(index="date", columns="symbol", values="close") + + # 信号矩阵 + entries = self._build_signal_matrix(panel, config.entries, config) + exits = self._build_signal_matrix(panel, config.exits, config) + + # 对齐 index/columns + if not entries.empty: + entries = entries.reindex_like(close).fillna(False).astype(bool) + else: + entries = pd.DataFrame(False, index=close.index, columns=close.columns) + if not exits.empty: + exits = exits.reindex_like(close).fillna(False).astype(bool) + else: + exits = pd.DataFrame(False, index=close.index, columns=close.columns) + + if not entries.any().any(): + return BacktestResult( + run_id=run_id, + config=_config_to_dict(config), + stats={"error": "no buy signals"}, + equity_curve=[], + trades=[], + per_symbol_stats=[], + ) + + # T+1 适配:vectorbt 默认信号当根 K 撮合 + # close_t 撮合:维持默认 + # open_t+1 撮合:shift 信号 1 根 + 用 open 作为价 + if config.matching == "open_t+1": + entries = entries.shift(1).fillna(False).astype(bool) + exits = exits.shift(1).fillna(False).astype(bool) + price = panel.pivot(index="date", columns="symbol", values="open") + else: + price = close + + # 跑回测 + try: + pf_kwargs = dict( + close=close, + entries=entries, + exits=exits, + price=price, + fees=config.fees_pct, + slippage=config.slippage_bps / 10000.0, + freq="1D", + ) + if config.stop_loss_pct is not None: + pf_kwargs["sl_stop"] = abs(config.stop_loss_pct) + if config.max_hold_days is not None: + # vectorbt 没有内置 max-hold;用时间退出近似: + # 在 max_hold_days 后强制 exit + exits_idx = entries.copy() + for col in entries.columns: + entry_rows = np.where(entries[col].values)[0] + for i in entry_rows: + end_i = min(i + config.max_hold_days, len(entries) - 1) + if end_i > i: + exits_idx.iloc[end_i][col] = True + pf_kwargs["exits"] = (exits | exits_idx).astype(bool) + + pf = vbt.Portfolio.from_signals(**pf_kwargs) + except Exception as e: # noqa: BLE001 + logger.exception("vectorbt backtest failed") + return BacktestResult( + run_id=run_id, + config=_config_to_dict(config), + stats={"error": str(e)}, + equity_curve=[], + trades=[], + per_symbol_stats=[], + ) + + # 提取结果 + try: + stats_series = pf.stats(silence_warnings=True) + if isinstance(stats_series, pd.DataFrame): + # 多列时取 agg + stats_dict = stats_series.mean(numeric_only=True).to_dict() + else: + stats_dict = stats_series.to_dict() + except Exception: # noqa: BLE001 + stats_dict = {} + + # 净值曲线(组合平均) + equity = pf.value().mean(axis=1) if isinstance(pf.value(), pd.DataFrame) else pf.value() + equity_curve = [ + {"date": str(idx.date() if hasattr(idx, "date") else idx), "value": float(v)} + for idx, v in equity.items() if pd.notna(v) + ] + + # 交易记录 + try: + trades_df = pf.trades.records_readable + trades = trades_df.to_dict(orient="records") if not trades_df.empty else [] + # 字段名美化 + trades = [ + { + "symbol": t.get("Column", t.get("Symbol", "")), + "entry_date": str(t.get("Entry Timestamp", t.get("Entry Date", ""))), + "exit_date": str(t.get("Exit Timestamp", t.get("Exit Date", ""))), + "entry_price": float(t.get("Avg Entry Price", t.get("Avg. Entry Price", 0))), + "exit_price": float(t.get("Avg Exit Price", t.get("Avg. Exit Price", 0))), + "pnl_pct": float(t.get("Return", t.get("PnL %", 0))), + "duration": str(t.get("Duration", "")), + } + for t in trades + ] + except Exception: # noqa: BLE001 + trades = [] + + # 每标的统计 + per_symbol = [] + try: + total_ret = pf.total_return() + if isinstance(total_ret, pd.Series): + for sym, ret in total_ret.items(): + if pd.notna(ret): + per_symbol.append({"symbol": sym, "total_return": float(ret)}) + except Exception: # noqa: BLE001 + pass + + result = BacktestResult( + run_id=run_id, + config=_config_to_dict(config), + stats={k: _json_safe(v) for k, v in stats_dict.items()}, + equity_curve=equity_curve, + trades=trades, + per_symbol_stats=per_symbol, + ) + + # 落盘 + self._persist(result) + return result + + def _persist(self, result: BacktestResult) -> None: + out_dir = settings.data_dir / "backtest_results" + out_dir.mkdir(parents=True, exist_ok=True) + # 用 polars 写一份汇总 + summary = pl.DataFrame({ + "run_id": [result.run_id], + "stats_json": [str(result.stats)], + "n_trades": [len(result.trades)], + }) + summary.write_parquet(out_dir / f"run_id={result.run_id}.parquet") + + def get_result(self, run_id: str) -> BacktestResult | None: + # Phase 1:只保留近似落盘,完整结果保存在内存的近期 cache 中 + # 简化:重新 run 比缓存复杂结果代价小,暂不实现 get_result + return None + + +def _config_to_dict(c: BacktestConfig) -> dict: + return { + "symbols": c.symbols, + "start": str(c.start), + "end": str(c.end), + "entries": c.entries, + "exits": c.exits, + "stop_loss_pct": c.stop_loss_pct, + "max_hold_days": c.max_hold_days, + "fees_pct": c.fees_pct, + "slippage_bps": c.slippage_bps, + "matching": c.matching, + } + + +def _json_safe(v): + if isinstance(v, (int, float, str, bool)) or v is None: + return v + if isinstance(v, (np.floating, np.integer)): + return float(v) if not np.isnan(float(v)) else None + if hasattr(v, "isoformat"): + return v.isoformat() + return str(v) diff --git a/serve/backend/app/services/concept_rotation_analyzer.py b/serve/backend/app/services/concept_rotation_analyzer.py new file mode 100644 index 0000000..e6721a0 --- /dev/null +++ b/serve/backend/app/services/concept_rotation_analyzer.py @@ -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) diff --git a/serve/backend/app/services/depth_service.py b/serve/backend/app/services/depth_service.py new file mode 100644 index 0000000..1e5c423 --- /dev/null +++ b/serve/backend/app/services/depth_service.py @@ -0,0 +1,586 @@ +"""五档盘口 sealed(真假涨停/跌停) 服务 — 独立旁路线。 + +架构(完全解耦): + - 只读 enriched(拿涨跌停名单), 不写回 enriched(14列不动) + - sealed 存独立 parquet(data/depth5/date=xxx/part.parquet) + - limit_ladder API 查询时 LEFT JOIN(同 ext_columns 机制) + - signal_limit_up 永远是"价格涨停", sealed 是叠加的真假判定层 + +数据流: + 盘中轮询线程(交易时段, 独立 sleep, 不绑行情轮询): + 读 enriched 内存缓存(线程安全) → 涨跌停名单 → tf.depth.batch + → 算 sealed → 更新内存缓存(不落盘) → sealed_ready=True + 盘后定版 job(可配置时间, 默认15:02): + 最后拉一次 → 落盘 depth5 parquet(定版) + +三层防护节流("设过大设上限, 设过小设最小值"): + ① 套餐范围 clamp: Pro 10~120s, Expert 3~300s + ② 限速安全 clamp: safe = 60/((rpm*0.8)/batches), 涨跌停多就自动放慢 + ③ 系统接管通知: 用户设置会超限时, 推 toast 告知已自动调整 +""" +from __future__ import annotations + +import logging +import math +import threading +import time +from datetime import date, datetime, time as dt_time +from pathlib import Path + +import polars as pl + +logger = logging.getLogger(__name__) + + +# 套餐 → (轮询间隔下限s, 上限s) +TIER_INTERVAL_RANGE: dict[str, tuple[float, float]] = { + "pro": (10.0, 120.0), + "expert": (3.0, 300.0), +} +# 兜底: 其他有 DEPTH5_BATCH 的套餐按 pro 范围 +DEFAULT_RANGE = (10.0, 120.0) + +# 限速余量: 只用 rpm 的 80%, 给系统其他 depth 调用留空间 +RPM_MARGIN = 0.8 +# 间隔硬下限/上限(任何套餐) +INTERVAL_HARD_MIN = 10.0 +INTERVAL_HARD_MAX = 300.0 + + +class DepthService: + """五档盘口 sealed 服务 — 单例。""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._running = False + self._thread: threading.Thread | None = None + self._repo = None # 延迟注入(KlineRepository) + self._app_state = None # 延迟注入(FastAPI app.state) + + # 内存缓存: {symbol: SealedEntry} + # SealedEntry = {sealed_up, sealed_down, ask1_vol, bid1_vol, status, fetched_ts} + self._sealed_cache: dict[str, dict] = {} + self._sealed_ready = False + self._sealed_date: date | None = None # sealed 数据对应的交易日(可能是昨天,如休市) + self._sealed_fetched_ts: float = 0.0 # 上次拉取的 perf_counter + self._sealed_fetched_at: float = 0.0 # 上次拉取的 wall-clock 时间戳 + self._persisted_date: date | None = None # 已落盘的日期 + + # 系统接管状态(防通知刷屏) + self._last_taken_over: bool | None = None + self._last_user_interval: float | None = None + + # ================================================================ + # 注入 + # ================================================================ + + def set_repo(self, repo) -> None: + self._repo = repo + + def set_app_state(self, app_state) -> None: + self._app_state = app_state + + # ================================================================ + # 生命周期 + # ================================================================ + + def boot_check(self) -> None: + """启动补跑: 当天 depth5 文件不存在则 finalize 一次; 已存在则恢复内存缓存。""" + if not self._has_capability(): + logger.info("depth sealed: 无 DEPTH5_BATCH 能力, 跳过启动补跑") + return + today = date.today() + if self._persisted_for_date(today): + # parquet 已存在: 恢复内存缓存(避免重启后每次查询都读 parquet) + self._restore_from_parquet(today) + return + logger.info("depth sealed: 启动补跑今天定版") + try: + self.finalize() + except Exception as e: # noqa: BLE001 + logger.warning("depth sealed 启动补跑失败: %s", e) + + def _restore_from_parquet(self, d: date) -> None: + """从 parquet 恢复内存缓存(服务重启后)。""" + if not self._repo: + return + out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet" + if not out.exists(): + return + try: + df = pl.read_parquet(out) + cache: dict[str, dict] = {} + for row in df.to_dicts(): + sym = row.get("symbol") + if not sym: + continue + cache[sym] = { + "sealed_up": row.get("sealed_up"), + "sealed_down": row.get("sealed_down"), + "ask1_vol": row.get("ask1_vol"), + "bid1_vol": row.get("bid1_vol"), + "status": row.get("status"), + "fetched_ts": row.get("fetched_at"), + } + with self._lock: + self._sealed_cache = cache + self._sealed_ready = True + self._sealed_date = d + self._persisted_date = d + logger.info("depth sealed: 从 parquet 恢复 %d 只 (日期=%s)", len(cache), d) + except Exception as e: # noqa: BLE001 + logger.warning("depth sealed 从 parquet 恢复失败: %s", e) + + def start_polling(self) -> None: + """启动盘中轮询线程(连板梯队监控开启 + 有能力 + 交易时段)。""" + if self._running: + return + if not self._has_capability(): + return + from app.services import preferences + if not preferences.get_limit_ladder_monitor_enabled(): + return + self._running = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + logger.info("depth sealed 盘中轮询已启动") + + def stop_polling(self) -> None: + """停止盘中轮询线程。""" + self._running = False + if self._thread: + self._thread.join(timeout=10) + self._thread = None + logger.info("depth sealed 盘中轮询已停止") + + def apply_monitor_toggle(self, enabled: bool) -> None: + """连板梯队监控开关切换时调用: 开启→启动轮询, 关闭→停止轮询。""" + if enabled: + self.start_polling() + else: + self.stop_polling() + + def run_once(self) -> dict: + """手动触发一次修正(立即拉取 depth + 更新内存缓存)。 + + 不受监控开关限制 — 用户可随时手动修正一次。 + 返回 {"ok": bool, "count": int, "msg": str} + """ + if not self._has_capability(): + return {"ok": False, "count": 0, "msg": "无五档盘口能力(需 Pro+)"} + try: + self._fetch_and_seal(persist=True) # 落盘, 刷新页面不丢 + with self._lock: + count = len(self._sealed_cache) + return {"ok": True, "count": count, "msg": f"已修正 {count} 只"} + except Exception as e: # noqa: BLE001 + logger.warning("depth run_once 失败: %s", e) + return {"ok": False, "count": 0, "msg": f"修正失败: {e}"} + + # ================================================================ + # 核心拉取 + # ================================================================ + + def _fetch_and_seal(self, persist: bool = False) -> None: + """拉一次 depth.batch, 算 sealed, 更新内存缓存(可选落盘)。 + + persist=True: 盘后定版, 写 depth5 parquet + persist=False: 盘中轮询, 只更新内存缓存 + """ + if not self._repo: + return + + # 只读 enriched 内存缓存(线程安全, 避免和 quote_service 写盘竞态) + enriched, enriched_date = self._repo.get_enriched_latest() + if enriched.is_empty(): + return + + # 筛涨跌停名单(用 fill_null 防止列缺失) + syms_up: list[str] = [] + syms_down: list[str] = [] + if "signal_limit_up" in enriched.columns: + syms_up = enriched.filter( + pl.col("signal_limit_up").fill_null(False) + )["symbol"].to_list() + if "signal_limit_down" in enriched.columns: + syms_down = enriched.filter( + pl.col("signal_limit_down").fill_null(False) + )["symbol"].to_list() + + all_syms = list(dict.fromkeys(syms_up + syms_down)) # 去重保序 + if not all_syms: + logger.debug("depth sealed: 当日无涨跌停股, 跳过") + return + + # 拉 depth(涨跌停一次拉, 按 capset batch 切片) + depth_data = self._call_depth_batch(all_syms) + if not depth_data: + logger.warning("depth sealed: depth.batch 返回空") + return + + up_set = set(syms_up) + down_set = set(syms_down) + now_perf = time.perf_counter() + now_wall = time.time() + + new_cache: dict[str, dict] = {} + for sym, d in depth_data.items(): + ask_vols = d.get("ask_volumes") or [] + bid_vols = d.get("bid_volumes") or [] + ask1 = ask_vols[0] if ask_vols else None + bid1 = bid_vols[0] if bid_vols else None + # depth 返回的 timestamp(毫秒 epoch), 回退到当前 wall-clock + depth_ts = d.get("timestamp") + fetched = (depth_ts / 1000.0) if isinstance(depth_ts, (int, float)) and depth_ts else now_wall + entry = { + # 涨停真封: 涨停价上卖一(主动卖压)为 0 + "sealed_up": (ask1 == 0) if sym in up_set and ask1 is not None else None, + # 跌停真封: 跌停价上买一为 0 + "sealed_down": (bid1 == 0) if sym in down_set and bid1 is not None else None, + "ask1_vol": ask1, + "bid1_vol": bid1, + "status": "limit_down" if sym in down_set and sym not in up_set else "limit_up", + "fetched_ts": fetched, + } + new_cache[sym] = entry + + with self._lock: + self._sealed_cache = new_cache + self._sealed_ready = True + self._sealed_date = enriched_date # 记录数据对应的交易日(可能是昨天,如休市) + self._sealed_fetched_ts = now_perf + self._sealed_fetched_at = now_wall + + logger.info("depth sealed: 拉取 %d 只 (涨停%d/跌停%d) 日期=%s%s", + len(new_cache), len(syms_up), len(syms_down), + enriched_date, " → 落盘" if persist else "") + + # 缓存已更新: 通知 SSE 推 depth_updated, 触发连板梯队刷新封单数据。 + self._notify_depth_updated(len(new_cache)) + + if persist and enriched_date: + self._persist(enriched_date) + + def _call_depth_batch(self, symbols: list[str]) -> dict: + """调 tf.depth.batch, 按 capset 的 batch 切片 + 节流。返回 {symbol: MarketDepth}。""" + from app.tickflow.client import get_client + tf = get_client() + + capset = self._get_capset() + lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH) + batch_size = (lim.batch if lim and lim.batch else 100) + rpm = (lim.rpm if lim and lim.rpm else 30) + # 批间隔 = 60/rpm(匀速) + inter_batch = 60.0 / rpm if rpm > 0 else 2.0 + + result: dict = {} + chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] + for i, chunk in enumerate(chunks): + if i > 0: + time.sleep(inter_batch) + try: + # SDK 的 batch 内部已按 batch_size 切, 这里再切一层防单请求过大 + data = tf.depth.batch(chunk) + if isinstance(data, dict): + result.update(data) + except Exception as e: # noqa: BLE001 + logger.warning("depth.batch 第 %d 批失败(%d 只): %s", i + 1, len(chunk), e) + # 单批失败不影响其他批 + return result + + def finalize(self) -> None: + """盘后定版: 拉一次 + 落盘。""" + if not self._has_capability(): + return + self._fetch_and_seal(persist=True) + + # ================================================================ + # 落盘 + # ================================================================ + + def _persist(self, today: date) -> None: + """把内存缓存写 depth5/date=今天/part.parquet。""" + with self._lock: + cache = dict(self._sealed_cache) + if not cache: + return + + rows = [] + for sym, e in cache.items(): + rows.append({ + "symbol": sym, + "sealed_up": e.get("sealed_up"), + "sealed_down": e.get("sealed_down"), + "ask1_vol": e.get("ask1_vol"), + "bid1_vol": e.get("bid1_vol"), + "status": e.get("status"), + "fetched_at": e.get("fetched_ts"), + }) + # 显式 schema: sealed_up/sealed_down 是 bool 与 None 混合, 不指定 schema + # polars 会按首行推断类型, 后续遇到不一致 (bool vs null) 报 + # "could not append value: false of type: bool to the builder"。 + df = pl.DataFrame(rows, schema={ + "symbol": pl.Utf8, + "sealed_up": pl.Boolean, + "sealed_down": pl.Boolean, + "ask1_vol": pl.Int64, + "bid1_vol": pl.Int64, + "status": pl.Utf8, + "fetched_at": pl.Float64, + }) + ds = today.isoformat() + out = self._repo.store.data_dir / "depth5" / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + df.write_parquet(out) + self._persisted_date = today + logger.info("depth sealed 落盘: %d 行 → %s", df.height, out) + + def _persisted_for_date(self, d: date) -> bool: + """检查某日 depth5 文件是否已存在。""" + if not self._repo: + return False + out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet" + return out.exists() + + # ================================================================ + # 查询(供 limit_ladder API 用) + # ================================================================ + + def get_sealed_map(self, target_date: date, is_down: bool) -> dict: + """返回 {symbol: {sealed, vol, ready, age}} 供 JOIN。 + + 优先内存缓存(盘中), 回退 parquet(历史/盘后)。 + sealed: bool | None (None=待确认或降级) + vol: 封单量(int) | None + ready: sealed 数据是否就绪(False→降级标识) + age: 距上次拉取秒数(盘后定版为 None) + """ + # 内存缓存(sealed 数据对应的交易日 = target_date 时才用) + if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_cache: + return self._read_from_memory(is_down) + # parquet(历史或盘后定版) + return self._read_from_parquet(target_date, is_down) + + def _read_from_memory(self, is_down: bool) -> dict: + sealed_key = "sealed_down" if is_down else "sealed_up" + # 封单量: 涨停=买一量(涨停价买单堆积), 跌停=卖一量(跌停价卖单堆积) + vol_key = "ask1_vol" if is_down else "bid1_vol" + now = time.perf_counter() + with self._lock: + cache = dict(self._sealed_cache) + fetched_ts = self._sealed_fetched_ts + age = (now - fetched_ts) if fetched_ts else 0.0 + result = {} + for sym, e in cache.items(): + result[sym] = { + "sealed": e.get(sealed_key), + "vol": e.get(vol_key), + "ready": True, + "age": age, + } + return result + + def _read_from_parquet(self, target_date: date, is_down: bool) -> dict: + if not self._repo: + return {} + out = self._repo.store.data_dir / "depth5" / f"date={target_date.isoformat()}" / "part.parquet" + if not out.exists(): + return {} + try: + df = pl.read_parquet(out) + except Exception as e: # noqa: BLE001 + logger.warning("depth5 parquet 读取失败: %s", e) + return {} + sealed_key = "sealed_down" if is_down else "sealed_up" + # 封单量: 涨停=买一量, 跌停=卖一量 + vol_key = "ask1_vol" if is_down else "bid1_vol" + result = {} + for row in df.to_dicts(): + sym = row.get("symbol") + if not sym: + continue + result[sym] = { + "sealed": row.get(sealed_key), + "vol": row.get(vol_key), + "ready": True, + "age": None, # 盘后定版, 无 age + } + return result + + def is_sealed_ready(self, target_date: date) -> bool: + """sealed 数据是否就绪(供前端降级判定)。""" + # 内存缓存对应的数据日 == 查询日 → 看内存就绪状态 + if self._sealed_date and target_date == self._sealed_date: + return self._sealed_ready + # 其他日期: 有 parquet 就 ready + return self._persisted_for_date(target_date) + + def get_sealed_age(self, target_date: date) -> float | None: + """返回 sealed 数据 age(秒), 盘后定版为 None。""" + if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_fetched_ts: + return time.perf_counter() - self._sealed_fetched_ts + return None + + # ================================================================ + # 盘中轮询线程 + # ================================================================ + + def _poll_loop(self) -> None: + """盘中轮询: 按 capset 自适应间隔拉 depth, 更新内存缓存。""" + while self._running: + try: + if self._is_trading_hours(): + self._poll_once() + else: + logger.debug("depth sealed: 非交易时段, 跳过") + except Exception as e: # noqa: BLE001 + logger.warning("depth sealed 轮询异常: %s", e) + + # 等待下一轮(用 _running 检查保证能及时退出) + interval = self._current_sleep_interval() + waited = 0.0 + while self._running and waited < interval: + time.sleep(0.5) + waited += 0.5 + + def _poll_once(self) -> None: + """单次轮询: 算间隔(三层防护) → 拉取 → 检测系统接管通知。""" + # 数当前涨跌停股 + n = self._count_limit_stocks() + if n == 0: + return + + interval, taken_over, user_interval = self._compute_interval(n) + + # 系统接管通知(状态切换时才推, 防刷屏) + if taken_over and (self._last_taken_over is False or self._last_user_interval != user_interval): + self._notify_takeover(n, user_interval, interval) + self._last_taken_over = taken_over + self._last_user_interval = user_interval + + self._fetch_and_seal(persist=False) + + def _current_sleep_interval(self) -> float: + """计算当前 sleep 间隔(供 _poll_loop 等待用)。""" + n = self._count_limit_stocks() + if n == 0: + return 30.0 # 无涨跌停, 慢轮询 + interval, _, _ = self._compute_interval(n) + return interval + + # ================================================================ + # 三层防护节流 + # ================================================================ + + def _compute_interval(self, n_symbols: int) -> tuple[float, bool, float]: + """三层防护计算实际轮询间隔。 + + 返回 (actual_interval, taken_over, user_interval) + - actual_interval: 实际使用的间隔(秒) + - taken_over: 是否被系统接管(用户设置会超限) + - user_interval: 用户设置(经套餐 clamp 后)的间隔 + """ + from app.services import preferences + from app.tickflow.policy import tier_label + + capset = self._get_capset() + lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH) + batch_size = (lim.batch if lim and lim.batch else 100) + rpm = (lim.rpm if lim and lim.rpm else 30) + + # ① 套餐范围 clamp + tier = tier_label().split()[0].split("+")[0].strip().lower() + lo, hi = TIER_INTERVAL_RANGE.get(tier, DEFAULT_RANGE) + raw_user = preferences.get_depth_polling_interval() + user_interval = max(lo, min(hi, raw_user)) + + # ② 限速安全 clamp + batches = max(1, math.ceil(n_symbols / batch_size)) + usable_rpm = rpm * RPM_MARGIN + calls_per_min = usable_rpm / batches if batches > 0 else usable_rpm + safe_interval = 60.0 / calls_per_min if calls_per_min > 0 else INTERVAL_HARD_MAX + + # 实际: 取用户设置和安全的较大值 + actual = max(user_interval, safe_interval) + # 硬上下限 + actual = max(INTERVAL_HARD_MIN, min(actual, INTERVAL_HARD_MAX)) + taken_over = safe_interval > user_interval + + return actual, taken_over, user_interval + + def _count_limit_stocks(self) -> int: + """数当前涨跌停股总数(供节流计算)。""" + if not self._repo: + return 0 + enriched, _ = self._repo.get_enriched_latest() + if enriched.is_empty(): + return 0 + n = 0 + if "signal_limit_up" in enriched.columns: + n += enriched.filter(pl.col("signal_limit_up").fill_null(False)).height + if "signal_limit_down" in enriched.columns: + n += enriched.filter(pl.col("signal_limit_down").fill_null(False)).height + return n + + # ================================================================ + # 通知 + # ================================================================ + + def _notify_takeover(self, n_stocks: int, user_interval: float, actual_interval: float) -> None: + """系统接管通知: 复用 quote_service 的 _pending_alerts 通道。""" + if not self._app_state: + return + qs = getattr(self._app_state, "quote_service", None) + if not qs: + return + msg = (f"五档轮询: 当前涨跌停 {n_stocks} 只, 您设置的 {user_interval:.0f} 秒间隔会超限, " + f"系统已自动调整为 {actual_interval:.0f} 秒") + alert = { + "source": "depth", + "type": "takeover", + "message": msg, + } + try: + with qs._lock: + qs._pending_alerts.append(alert) + qs._alert_event.set() + except Exception as e: # noqa: BLE001 + logger.debug("depth 接管通知推送失败: %s", e) + + def _notify_depth_updated(self, count: int) -> None: + """修正完成通知: set quote_service._depth_update_event, SSE 推 depth_updated 刷新连板梯队。""" + if not self._app_state: + return + qs = getattr(self._app_state, "quote_service", None) + if not qs: + return + try: + qs.notify_depth_updated() + except Exception as e: # noqa: BLE001 + logger.debug("depth 更新通知推送失败: %s", e) + + # ================================================================ + # 工具 + # ================================================================ + + def _has_capability(self) -> bool: + capset = self._get_capset() + from app.tickflow.capabilities import Cap + return capset.has(Cap.DEPTH5_BATCH) + + def _get_capset(self): + """获取当前 capset(优先 app.state, 回退 detect)。""" + if self._app_state: + cs = getattr(self._app_state, "capabilities", None) + if cs: + return cs + from app.tickflow.policy import detect_capabilities + return detect_capabilities() + + @staticmethod + def _is_trading_hours() -> bool: + now = datetime.now() + t = now.time() + morning = dt_time(9, 25) <= t <= dt_time(11, 35) + afternoon = dt_time(12, 55) <= t <= dt_time(15, 5) + return now.weekday() < 5 and (morning or afternoon) diff --git a/serve/backend/app/services/ext_data.py b/serve/backend/app/services/ext_data.py new file mode 100644 index 0000000..7e7695d --- /dev/null +++ b/serve/backend/app/services/ext_data.py @@ -0,0 +1,521 @@ +"""扩展数据服务 — 配置管理 + 文件解析 + Parquet 存储。""" +from __future__ import annotations + +import json +import logging +from datetime import date, datetime +from pathlib import Path +from typing import Literal + +import polars as pl + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# 配置模型 +# --------------------------------------------------------------------------- + +class ExtField: + """扩展字段定义。""" + __slots__ = ("name", "dtype", "label") + + def __init__(self, name: str, dtype: str = "string", label: str = "") -> None: + self.name = name + self.dtype = dtype # string | int | float | bool + self.label = label or name + + def to_dict(self) -> dict: + return {"name": self.name, "dtype": self.dtype, "label": self.label} + + @classmethod + def from_dict(cls, d: dict) -> ExtField: + return cls(d["name"], d.get("dtype", "string"), d.get("label", "")) + + +class PullConfig: + """定时拉取配置。""" + __slots__ = ( + "url", "method", "headers", "body", "response_path", + "field_map", "schedule_minutes", "enabled", + "last_run", "last_status", "last_message", "last_rows", + "next_run", + ) + + def __init__( + self, + url: str = "", + method: str = "GET", + headers: dict[str, str] | None = None, + body: str | None = None, + response_path: str = "", + field_map: dict[str, str] | None = None, + schedule_minutes: int = 1440, + enabled: bool = False, + last_run: str | None = None, + 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 + self.headers = headers or {} + self.body = body # JSON string (POST body template) + self.response_path = response_path # dot-path to rows array, e.g. "data.list" + self.field_map = field_map or {} # external_name → config_field_name + self.schedule_minutes = schedule_minutes + self.enabled = enabled + self.last_run = last_run + 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 { + "url": self.url, + "method": self.method, + "headers": self.headers, + "body": self.body, + "response_path": self.response_path, + "field_map": self.field_map, + "schedule_minutes": self.schedule_minutes, + "enabled": self.enabled, + "last_run": self.last_run, + "last_status": self.last_status, + "last_message": self.last_message, + "last_rows": self.last_rows, + "next_run": self.next_run, + } + + @classmethod + def from_dict(cls, d: dict) -> PullConfig: + if not d: + return cls() + return cls( + url=d.get("url", ""), + method=d.get("method", "GET"), + headers=d.get("headers"), + body=d.get("body"), + response_path=d.get("response_path", ""), + field_map=d.get("field_map"), + schedule_minutes=d.get("schedule_minutes", 1440), + enabled=d.get("enabled", False), + last_run=d.get("last_run"), + last_status=d.get("last_status"), + last_message=d.get("last_message"), + last_rows=d.get("last_rows"), + next_run=d.get("next_run"), + ) + + +class ExtConfig: + """一个扩展数据源的完整配置。""" + __slots__ = ( + "id", "label", "mode", "fields", "description", + "symbol_map", "code_map", + "created_at", "updated_at", "pull", + ) + + def __init__( + self, + id: str, + label: str, + mode: Literal["snapshot", "timeseries"], + fields: list[ExtField], + description: str = "", + symbol_map: dict | None = None, + code_map: dict | None = None, + created_at: str | None = None, + updated_at: str | None = None, + pull: PullConfig | None = None, + ) -> None: + self.id = id + self.label = label + self.mode = mode + self.fields = fields + self.description = description + # 映射关系: {"type": "mapped", "col": "原始列名"} 或 {"type": "computed", "from": "symbol|code", "method": "strip_exchange|append_exchange"} + self.symbol_map = symbol_map or {} + self.code_map = code_map or {} + self.created_at = created_at or datetime.now().isoformat() + self.updated_at = updated_at or datetime.now().isoformat() + self.pull = pull + + def to_dict(self) -> dict: + d = { + "id": self.id, + "label": self.label, + "mode": self.mode, + "fields": [f.to_dict() for f in self.fields], + "description": self.description, + "symbol_map": self.symbol_map, + "code_map": self.code_map, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + if self.pull: + d["pull"] = self.pull.to_dict() + return d + + @classmethod + def from_dict(cls, d: dict) -> ExtConfig: + return cls( + id=d["id"], + label=d["label"], + mode=d["mode"], + fields=[ExtField.from_dict(f) for f in d.get("fields", [])], + description=d.get("description", ""), + symbol_map=d.get("symbol_map"), + code_map=d.get("code_map"), + created_at=d.get("created_at"), + updated_at=d.get("updated_at"), + pull=PullConfig.from_dict(d["pull"]) if d.get("pull") else None, + ) + + +# --------------------------------------------------------------------------- +# 配置持久化 +# --------------------------------------------------------------------------- + +class ExtConfigStore: + """扩展数据配置文件读写 — 每个表独立目录 data/ext/{config_id}/config.json。""" + + def __init__(self, data_dir: Path) -> None: + self._base = data_dir / "ext_data" + + def _config_path(self, config_id: str) -> Path: + return self._base / config_id / "config.json" + + def load_all(self) -> list[ExtConfig]: + # 兼容旧版: 如果目录为空且旧配置文件存在则迁移 + if not self._base.exists() or not any(self._base.iterdir()): + old = self._base.parent / "ext_configs.json" + if not old.exists(): + old = self._base.parent / "ext_configs.json.bak" + if old.exists(): + self._migrate_legacy(old) + if not self._base.exists(): + return [] + configs = [] + for d in sorted(self._base.iterdir()): + cp = d / "config.json" + if d.is_dir() and cp.exists(): + try: + raw = json.loads(cp.read_text(encoding="utf-8")) + configs.append(ExtConfig.from_dict(raw)) + except Exception as e: + logger.warning("扩展表配置解析失败 %s: %s", cp, e) + return configs + + def get(self, config_id: str) -> ExtConfig | None: + cp = self._config_path(config_id) + if not cp.exists(): + return None + try: + raw = json.loads(cp.read_text(encoding="utf-8")) + return ExtConfig.from_dict(raw) + except Exception: + return None + + def upsert(self, config: ExtConfig) -> None: + config.updated_at = datetime.now().isoformat() + cp = self._config_path(config.id) + cp.parent.mkdir(parents=True, exist_ok=True) + cp.write_text( + json.dumps(config.to_dict(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def delete(self, config_id: str) -> bool: + import shutil + cp = self._config_path(config_id) + if not cp.exists(): + return False + shutil.rmtree(cp.parent, ignore_errors=True) + return True + + def _migrate_legacy(self, old_path: Path) -> None: + """一次性迁移旧版 ext_configs.json 到独立目录结构。""" + try: + raw = json.loads(old_path.read_text(encoding="utf-8")) + configs = [ExtConfig.from_dict(d) for d in raw] + for c in configs: + cp = self._config_path(c.id) + cp.parent.mkdir(parents=True, exist_ok=True) + cp.write_text( + json.dumps(c.to_dict(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + # 迁移完成后重命名旧文件作为备份 + backup = old_path.with_suffix(".json.bak") + old_path.rename(backup) + logger.info("ext_configs.json 已迁移至 ext/ (备份: %s)", backup.name) + except Exception as e: + logger.warning("ext_configs 迁移失败: %s", e) + + +# --------------------------------------------------------------------------- +# CSV / Excel 解析 → Parquet 写入 +# --------------------------------------------------------------------------- + +_POLARS_DTYPE_MAP = { + "string": pl.Utf8, + "int": pl.Int64, + "float": pl.Float64, + "bool": pl.Boolean, +} + + +def build_code_lookup(data_dir: Path) -> dict[str, str]: + """从 instruments 维表构建 code → symbol 映射。""" + path = data_dir / "instruments" / "instruments.parquet" + if not path.exists(): + return {} + try: + df = pl.read_parquet(path, columns=["code", "symbol"]) + return dict(zip(df["code"].to_list(), df["symbol"].to_list())) + except Exception: + return {} + + +def normalize_symbol(series: pl.Series, lookup: dict[str, str] | None = None) -> pl.Series: + """将 symbol 列标准化为 代码.交易所 格式。 + + 优先使用 instruments 维表查找 code → symbol,确保 100% 准确。 + 查不到时按规则兜底:6开头 → .SH,其余 → .SZ。 + """ + _lookup = lookup or {} + + def _fix_one(val: str) -> str: + if not val: + return val + val = val.strip() + # 已经是标准格式(含 .),直接返回 + if "." in val: + return val + # 纯6位数字代码 → 优先查维表 + if len(val) == 6 and val.isdigit(): + mapped = _lookup.get(val) + if mapped: + return mapped + # 兜底规则 + if val.startswith(("6",)): + return f"{val}.SH" + else: + return f"{val}.SZ" + return val + + return series.map_elements(_fix_one, return_dtype=pl.Utf8) + + +def ensure_utf8_csv(file_path: Path) -> Path: + """确保 CSV 文件以 UTF-8 编码可读,非 UTF-8(如 GBK/GB18030)则转换。 + + 国内行情软件(同花顺/东财/通达信)和 Windows 中文 Excel 导出的 CSV 多为 + GBK 系编码,Polars 的 read_csv 默认按 UTF-8 解析会抛 "invalid utf-8 sequence"。 + 这里在交给 Polars 前做一次编码规范化。 + + 返回值:若已是 UTF-8 则返回原路径;否则在同目录写一个 *.utf8 文件并返回它 + (调用方用临时目录,随目录一起清理)。 + """ + raw = file_path.read_bytes() + # BOM 处理:UTF-8-SIG 等带 BOM 文件直接交给 Polars(它认识 BOM) + try: + raw.decode("utf-8") + return file_path # 已是合法 UTF-8 + except UnicodeDecodeError: + pass + # 依次尝试常见中文编码,第一个能完整解码的即为命中 + for enc in ("gb18030", "gbk", "gb2312", "big5"): + try: + text = raw.decode(enc) + except UnicodeDecodeError: + continue + out_path = file_path.with_suffix(file_path.suffix + ".utf8") + out_path.write_text(text, encoding="utf-8") + logger.info("CSV 编码转换 %s → %s (%s)", file_path.name, out_path.name, enc) + return out_path + # 都无法解码:返回原路径,让 Polars 抛出更精确的原始错误 + return file_path + + +def parse_upload_file(file_path: Path, symbol_col: str = "symbol", data_dir: Path | None = None) -> pl.DataFrame: + """解析上传的 CSV / Excel 文件为 Polars DataFrame。""" + suffix = file_path.suffix.lower() + if suffix == ".csv": + df = pl.read_csv(ensure_utf8_csv(file_path), infer_schema_length=10000) + elif suffix in (".xlsx", ".xls"): + df = pl.read_excel(file_path) + else: + raise ValueError(f"不支持的文件格式: {suffix}") + + if symbol_col not in df.columns: + # 尝试模糊匹配 + candidates = [c for c in df.columns if c.lower() in ("symbol", "code", "代码", "标的")] + if candidates: + df = df.rename({candidates[0]: symbol_col}) + else: + raise ValueError(f"未找到标的代码列 (symbol),可选列: {df.columns}") + + # 确保 symbol 列为字符串并标准化 + lookup = build_code_lookup(data_dir) if data_dir else None + df = df.with_columns(normalize_symbol(df[symbol_col].cast(pl.Utf8), lookup)) + return df + + +def cast_df_to_schema(df: pl.DataFrame, fields: list[ExtField]) -> pl.DataFrame: + """按配置的字段类型转换 DataFrame 列类型。""" + for f in fields: + if f.name in df.columns: + target = _POLARS_DTYPE_MAP.get(f.dtype, pl.Utf8) + df = df.with_columns(pl.col(f.name).cast(target)) + return df + + +def _config_dir(config_id: str, data_dir: Path) -> Path: + """返回扩展配置的根目录 data/ext_data/{config_id}/。""" + return data_dir / "ext_data" / config_id + + +def write_ext_parquet( + df: pl.DataFrame, + config: ExtConfig, + data_dir: Path, + snapshot_date: date | None = None, +) -> int: + """将 DataFrame 写入扩展数据 Parquet。 + + 目录结构: + - snapshot: data/ext_data/{id}/part.parquet(与 config.json 同级,覆盖写) + - timeseries: data/ext_data/{id}/timeseries/date=xxx/part.parquet(按日分区) + + Returns: + 写入行数。 + """ + snap = snapshot_date or date.today() + cfg_dir = _config_dir(config.id, data_dir) + + # 标准化 symbol 列: 用维表查找 → 准确匹配交易所 + if "symbol" in df.columns: + lookup = build_code_lookup(data_dir) + df = df.with_columns(normalize_symbol(df["symbol"], lookup)) + + if config.mode == "snapshot": + # 快照: 与 config.json 同级,直接覆盖 + cfg_dir.mkdir(parents=True, exist_ok=True) + out_path = cfg_dir / "part.parquet" + + # 如果已有文件,合并去重后覆盖 + if out_path.exists(): + try: + 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 as e: + # schema 不一致 (列不同) 时 concat 失败 → 直接用新 df 覆盖。 + # 记日志而非静默吞掉, 便于排查"数据结构错乱"类问题。 + logger.warning("扩展表 %s 合并去重失败, 将覆盖写入: %s", config.id, e) + else: + # 时序: timeseries/ 下按日期分区 + out_dir = cfg_dir / "timeseries" / f"date={snap}" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / "part.parquet" + + # 如果已有文件,合并去重 + if out_path.exists(): + try: + 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 as e: + logger.warning("扩展表 %s 合并去重失败, 将覆盖写入: %s", config.id, e) + + df = cast_df_to_schema(df, config.fields) + df.write_parquet(out_path) + logger.info("扩展表写入: %s → %s (%d 行)", config.id, out_path, len(df)) + return len(df) + + +def delete_ext_parquet(config_id: str, data_dir: Path) -> None: + """删除扩展数据源关联的所有 Parquet 数据(保留 config.json)。 + + - snapshot: 删除 ext_data/{id}/part.parquet + - timeseries: 删除 ext_data/{id}/timeseries/ 目录 + """ + cfg_dir = _config_dir(config_id, data_dir) + # 删除快照文件 + snap = cfg_dir / "part.parquet" + if snap.exists(): + snap.unlink() + # 删除时序目录 + ts_dir = cfg_dir / "timeseries" + if ts_dir.exists(): + import shutil + shutil.rmtree(ts_dir, ignore_errors=True) + + +def fix_symbol_format(config: ExtConfig, data_dir: Path) -> int: + """扫描该扩展配置的所有 Parquet 文件,将 symbol 列标准化为 代码.交易所 格式。 + + - snapshot: 扫描 ext_data/{id}/part.parquet + - timeseries: 扫描 ext_data/{id}/timeseries/date=xxx/part.parquet + + Returns: + 修复的文件数。 + """ + cfg_dir = _config_dir(config.id, data_dir) + if not cfg_dir.exists(): + return 0 + + # 收集需要扫描的 parquet 文件列表 + parquet_files: list[Path] = [] + if config.mode == "snapshot": + p = cfg_dir / "part.parquet" + if p.exists(): + parquet_files.append(p) + else: + ts_dir = cfg_dir / "timeseries" + if ts_dir.exists(): + for part_dir in sorted(ts_dir.iterdir()): + if not part_dir.is_dir() or not part_dir.name.startswith("date="): + continue + p = part_dir / "part.parquet" + if p.exists(): + parquet_files.append(p) + + fixed = 0 + lookup = build_code_lookup(data_dir) + for parquet_path in parquet_files: + try: + df = pl.read_parquet(parquet_path) + if "symbol" not in df.columns: + continue + old = df["symbol"].to_list() + df = df.with_columns(normalize_symbol(df["symbol"], lookup)) + new = df["symbol"].to_list() + if old != new: + df.write_parquet(parquet_path) + fixed += 1 + logger.info("代码格式修复: %s/%s (%d 行)", config.id, parquet_path.parent.name, len(df)) + except Exception as e: + logger.warning("代码格式修复跳过 %s: %s", parquet_path, e) + + return fixed + + +def rows_to_parquet( + rows: list[dict], + config: ExtConfig, + data_dir: Path, + snapshot_date: date | None = None, +) -> int: + """将 JSON 行列表转为 DataFrame 写入 Parquet,复用 write_ext_parquet 的存储逻辑。 + + Returns: + 写入行数。 + """ + df = pl.DataFrame(rows) + if "symbol" in df.columns: + df = df.with_columns(pl.col("symbol").cast(pl.Utf8)) + return write_ext_parquet(df, config, data_dir, snapshot_date=snapshot_date) diff --git a/serve/backend/app/services/ext_presets.py b/serve/backend/app/services/ext_presets.py new file mode 100644 index 0000000..6ec0e6a --- /dev/null +++ b/serve/backend/app/services/ext_presets.py @@ -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 diff --git a/serve/backend/app/services/ext_pull.py b/serve/backend/app/services/ext_pull.py new file mode 100644 index 0000000..5f04ea7 --- /dev/null +++ b/serve/backend/app/services/ext_pull.py @@ -0,0 +1,339 @@ +"""扩展数据定时拉取引擎 — 从外部 API 拉取数据写入 Parquet。""" +from __future__ import annotations + +import asyncio +import json +import logging +import threading +from datetime import date, datetime, timezone +from functools import reduce +from typing import Any + +import httpx + +from app.services.ext_data import ( + ExtConfig, + ExtConfigStore, + PullConfig, + rows_to_parquet, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# 响应解析 +# --------------------------------------------------------------------------- + +def _extract_rows(data: Any, path: str) -> list[dict]: + """按 dot-path 从 JSON 响应中提取行数组。 + + 例: path="data.list" → response["data"]["list"] + 如果 path 为空,直接将 data 视为数组。 + """ + if not path: + if isinstance(data, list): + return data + raise ValueError("response_path 为空但响应不是数组") + + keys = path.split(".") + current = data + for key in keys: + if isinstance(current, dict): + if key not in current: + raise ValueError(f"响应中不存在路径 '{path}',缺失键 '{key}'") + current = current[key] + elif isinstance(current, list): + try: + current = current[int(key)] + except (ValueError, IndexError) as e: + raise ValueError(f"响应路径 '{path}' 解析失败: {e}") from e + else: + raise ValueError(f"响应路径 '{path}' 中间值不是 dict/list: {type(current)}") + + if not isinstance(current, list): + raise ValueError(f"路径 '{path}' 指向的不是数组,而是 {type(current)}") + + return current + + +def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]: + """将外部字段名映射为内部配置字段名。field_map: {外部名: 内部名}。""" + if not field_map: + return rows + mapped = [] + for row in rows: + new_row: dict = {} + for k, v in row.items(): + mapped_key = field_map.get(k, k) + new_row[mapped_key] = v + mapped.append(new_row) + return mapped + + +# --------------------------------------------------------------------------- +# 拉取执行 +# --------------------------------------------------------------------------- + +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, +) -> tuple[int, str]: + """执行一次拉取: 请求外部 API → 解析响应 → 写入 Parquet。 + + Returns: + (rows_written, date_str) + """ + pull = config.pull + if not pull or not pull.url: + raise ValueError("拉取未配置或 URL 为空") + + async with httpx.AsyncClient(timeout=30) as client: + headers = pull.headers or {} + kwargs: dict[str, Any] = {"headers": headers} + + if pull.method.upper() == "POST" and pull.body: + kwargs["content"] = pull.body + if "content-type" not in {k.lower() for k in headers}: + kwargs["headers"]["Content-Type"] = "application/json" + + resp = await client.request(pull.method.upper(), pull.url, **kwargs) + resp.raise_for_status() + + # 解析 JSON + try: + data = resp.json() + except Exception as e: + raise ValueError(f"响应不是有效 JSON: {e}") from e + + # 提取行 + rows = _extract_rows(data, pull.response_path) + 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) + + # 校验 symbol 列 + if rows and "symbol" not in rows[0]: + raise ValueError("数据行中缺少 symbol 字段,请配置 field_map 映射") + + # 写入 + snap = date.today() + n = rows_to_parquet(rows, config, data_dir, snapshot_date=snap) + return n, snap.isoformat() + + +# --------------------------------------------------------------------------- +# 调度器 +# --------------------------------------------------------------------------- + +class PullScheduler: + """后台调度器:为每个启用了 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._loop: asyncio.AbstractEventLoop | None = None + + def start(self, data_dir) -> None: + """启动调度(在 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() + self._tasks.clear() + 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: + new_configs.append(config) + + # 需要移除的 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: + # 每轮重读最新配置 — 用户可能修改了 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 + + 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 + + +# 全局单例 +pull_scheduler = PullScheduler() diff --git a/serve/backend/app/services/extend_history.py b/serve/backend/app/services/extend_history.py new file mode 100644 index 0000000..1339a52 --- /dev/null +++ b/serve/backend/app/services/extend_history.py @@ -0,0 +1,224 @@ +"""向前扩展历史数据 — 完全独立于 daily_pipeline 的盘后管道。 + +用户从日 K 卡片手动触发,指定往前补的时长 (x 天/月/年)。 +流程: + 1. 获取当前最早日期 + 2. 向前拉日 K batch (start = 最早日期 - offset, end = 最早日期) + 3. 向前拉除权因子 (同范围) + 4. 全量重算 enriched + 5. 刷新视图 + 缓存 + +⚠️ 本模块不导入 daily_pipeline 的任何函数,只复用基础设施: + - kline_sync.sync_and_persist_daily_batch / sync_adj_factor + - indicators.pipeline.run_pipeline + - pipeline_jobs.JobStore + - tickflow.repository.KlineRepository +""" +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import date, datetime, timedelta + +from app.services import kline_sync +from app.services.pipeline_jobs import job_store +from app.tickflow.capabilities import Cap, CapabilitySet +from app.tickflow.repository import KlineRepository + +logger = logging.getLogger(__name__) + + +def _noop(stage: str, pct: int, msg: str, **kwargs) -> None: # noqa: ARG001 + pass + + +def _invalidate(table: str | None = None) -> None: + from app.api.data import invalidate_data_cache + invalidate_data_cache(table) + + +def _resolve_universe(capset: CapabilitySet) -> list[str]: + """解析标的池 — 与 daily_pipeline 独立的副本。""" + if capset.has(Cap.KLINE_DAILY_BATCH): + try: + from app.tickflow.pools import get_pool + all_a = get_pool("CN_Equity_A", refresh=True) + if all_a: + return sorted(all_a) + except Exception as e: + logger.warning("CN_Equity_A pool unavailable: %s", e) + + from app.tickflow.pools import DEMO_SYMBOLS, get_pool as _get_pool + from app.config import settings + from pathlib import Path + import polars as pl + base: set[str] = set(DEMO_SYMBOLS) + base.update(_get_pool("watchlist")) + d = Path(settings.data_dir) + inst_path = d / "instruments" / "instruments.parquet" + if inst_path.exists(): + try: + inst = pl.read_parquet(inst_path, columns=["symbol"]) + base.update(inst["symbol"].to_list()) + except Exception as e: + logger.warning("instruments supplement failed: %s", e) + return sorted(base) + + +def _refresh_single_view(repo: KlineRepository, name: str) -> None: + """刷新单个 DuckDB 视图。""" + d = repo.store.data_dir.as_posix() + paths = { + "kline_daily": f"{d}/kline_daily/**/*.parquet", + "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet", + "kline_minute": f"{d}/kline_minute/**/*.parquet", + "adj_factor": f"{d}/adj_factor/**/*.parquet", + "instruments": f"{d}/instruments/**/*.parquet", + } + path = paths.get(name) + if not path: + return + try: + repo.db.execute( + f"CREATE OR REPLACE VIEW {name} AS " + f"SELECT * FROM read_parquet('{path}', union_by_name=true)" + ) + except Exception as e: + logger.warning("refresh view %s failed: %s", name, e) + + +def compute_offset(value: int, unit: str) -> timedelta: + """将用户输入的 value + unit 转成 timedelta。""" + if unit == "day": + return timedelta(days=value) + elif unit == "month": + return timedelta(days=value * 30) + elif unit == "year": + return timedelta(days=value * 365) + else: + raise ValueError(f"不支持的单位: {unit}") + + +def run_extend_history( + repo: KlineRepository, + capset: CapabilitySet, + value: int, + unit: str, + on_progress: Callable | None = None, +) -> dict: + """向前扩展历史数据的主函数。 + + 完全独立于 daily_pipeline.run_now(),不调用其任何逻辑。 + 返回结果 dict 供 job_store 记录。 + """ + emit = on_progress or _noop + + # 0. 计算时间偏移 + offset = compute_offset(value, unit) + today = date.today() + + # 1. 获取当前最早日期 + emit("extend_history", 2, "检查当前数据范围…") + earliest = repo.earliest_daily_date() + + if not earliest: + return {"error": "本地无日K数据,请先执行一次完整同步"} + + new_start = earliest - offset + # 不能超过今天 + if new_start >= earliest: + return {"error": "扩展范围无效,请增大时间跨度"} + + # 2. 解析标的池 + emit("extend_history", 5, "解析标的池…") + universe = _resolve_universe(capset) + if not universe: + return {"error": "标的池为空"} + emit("extend_history", 8, f"标的池: {len(universe)} 只") + + start_str = new_start.strftime("%Y-%m-%d") + end_str = earliest.strftime("%Y-%m-%d") + + # 3. 拉日 K + emit("extend_history", 10, f"获取日K [{start_str} ~ {end_str}]…") + logger.info("extend_history: daily K [%s ~ %s], %d symbols", start_str, end_str, len(universe)) + + def _daily_chunk(cur: int, tot: int) -> None: + emit("extend_history", 10 + int(35 * cur / tot), + f"日K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True) + + written_daily = kline_sync.sync_and_persist_daily_batch( + universe, repo, capset, + start_date=datetime.combine(new_start, datetime.min.time()), + end_date=datetime.combine(earliest, datetime.min.time()), + on_chunk_done=_daily_chunk, + ) + emit("extend_history", 45, f"日K 完成,写入 {written_daily} 行") + logger.info("extend_history: daily K done, %d rows", written_daily) + _refresh_single_view(repo, "kline_daily") + _invalidate("daily") + + # 4. 拉除权因子 (新范围) + written_adj = 0 + adj_start = datetime.combine(new_start, datetime.min.time()) + adj_end = datetime.combine(today, datetime.min.time()) + adj_start_str = new_start.strftime("%Y-%m-%d") + adj_end_str = today.strftime("%Y-%m-%d") + + if capset.has(Cap.ADJ_FACTOR): + emit("extend_history", 48, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…") + logger.info("extend_history: adj_factor [%s ~ %s]", adj_start_str, adj_end_str) + + def _adj_chunk(cur: int, tot: int) -> None: + emit("extend_history", 48 + int(10 * cur / tot), + f"除权因子批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True) + + written_adj, _affected = kline_sync.sync_adj_factor( + universe, repo, capset, + start_time=adj_start, end_time=adj_end, + on_chunk_done=_adj_chunk, + ) + emit("extend_history", 60, f"除权因子完成,{written_adj} 行") + logger.info("extend_history: adj_factor done, %d rows", written_adj) + _refresh_single_view(repo, "adj_factor") + _invalidate("adj_factor") + else: + emit("extend_history", 60, "除权因子跳过(无权限)") + logger.info("extend_history: adj_factor skipped, no ADJ_FACTOR capability") + + # 5. 全量重算 enriched + emit("extend_history", 65, "全量计算 enriched…") + logger.info("extend_history: full enriched rebuild start") + + from app.indicators.pipeline import run_pipeline + written_enriched = run_pipeline() + + enriched_dir = repo.store.data_dir / "kline_daily_enriched" + enriched_days = len(list(enriched_dir.glob("date=*"))) if enriched_dir.exists() else 0 + emit("extend_history", 92, f"enriched 完成,覆盖 {enriched_days} 天") + logger.info("extend_history: enriched done, %d days", enriched_days) + _refresh_single_view(repo, "kline_enriched") + _invalidate("enriched") + + # 6. 刷新视图 + emit("extend_history", 95, "刷新视图…") + _refresh_single_view(repo, "kline_daily") + _refresh_single_view(repo, "kline_enriched") + _refresh_single_view(repo, "adj_factor") + _invalidate(None) + + # 7. 统计结果 + daily_dir = repo.store.data_dir / "kline_daily" + daily_days = len(list(daily_dir.glob("date=*"))) if daily_dir.exists() else 0 + + emit("extend_history", 100, f"完成,已扩展至 {new_start}") + + return { + "earliest_before": earliest.isoformat(), + "earliest_after": new_start.isoformat(), + "daily_rows": written_daily, + "daily_days": daily_days, + "adj_factor_rows": written_adj, + "enriched_days": enriched_days, + "universe_size": len(universe), + } diff --git a/serve/backend/app/services/financial_analyzer.py b/serve/backend/app/services/financial_analyzer.py new file mode 100644 index 0000000..8dcd57a --- /dev/null +++ b/serve/backend/app/services/financial_analyzer.py @@ -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) diff --git a/serve/backend/app/services/financial_sync.py b/serve/backend/app/services/financial_sync.py new file mode 100644 index 0000000..39f4102 --- /dev/null +++ b/serve/backend/app/services/financial_sync.py @@ -0,0 +1,410 @@ +"""财务数据独立同步服务。 + +解耦于 K-line 管道, 自有调度 + 自有存储。 +能力门控: Cap.FINANCIAL (Expert 套餐) +""" +from __future__ import annotations + +import asyncio +import logging +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import polars as pl + +from app.tickflow.capabilities import Cap, CapabilitySet + +logger = logging.getLogger(__name__) + +# 每个 API 请求最多 100 个标的 +_BATCH_SIZE = 100 + +# 4 张财务表 +FINANCIAL_TABLES = ("metrics", "income", "balance_sheet", "cash_flow") + + +# ================================================================ +# 同步函数 +# ================================================================ + +def _get_symbols(data_dir: Path) -> list[str]: + """从 instruments 表获取标的列表。""" + inst_path = data_dir / "instruments" / "instruments.parquet" + if not inst_path.exists(): + return [] + try: + df = pl.read_parquet(inst_path, columns=["symbol"]) + return df["symbol"].to_list() + except Exception as e: + logger.warning("读取 instruments 失败: %s", e) + return [] + + +def _sync_table( + table: str, + symbols: list[str], + data_dir: Path, + capset: CapabilitySet, + latest_only: bool = True, +) -> int: + """同步单张财务表。返回写入的行数。""" + if not capset.has(Cap.FINANCIAL): + logger.info("sync_%s skipped: no FINANCIAL capability", table) + return 0 + if not symbols: + logger.warning("sync_%s skipped: no symbols", table) + return 0 + + from app.tickflow.client import get_client + tf = get_client() + + # 分批拉取 + api_method = { + "metrics": tf.financials.metrics, + "income": tf.financials.income, + "balance_sheet": tf.financials.balance_sheet, + "cash_flow": tf.financials.cash_flow, + }[table] + + all_records: list[dict] = [] + total_batches = (len(symbols) + _BATCH_SIZE - 1) // _BATCH_SIZE + + for i in range(0, len(symbols), _BATCH_SIZE): + chunk = symbols[i : i + _BATCH_SIZE] + batch_num = i // _BATCH_SIZE + 1 + try: + data = api_method(chunk, latest=latest_only) + # data 格式: { "600519.SH": [record, ...], ... } + if isinstance(data, dict): + for sym, records in data.items(): + if isinstance(records, list): + for rec in records: + if isinstance(rec, dict): + rec["symbol"] = sym + all_records.append(rec) + logger.debug("sync_%s batch %d/%d: %d records", table, batch_num, total_batches, len(data) if isinstance(data, dict) else 0) + except Exception as e: + logger.warning("sync_%s batch %d/%d failed: %s", table, batch_num, total_batches, e) + + if not all_records: + return 0 + + df = pl.DataFrame(all_records) + if df.is_empty(): + return 0 + + # 确保 symbol 列存在 + if "symbol" not in df.columns: + return 0 + + # 写入 Parquet (全量覆盖) + out_dir = data_dir / "financials" / table + out_dir.mkdir(parents=True, exist_ok=True) + out_file = out_dir / "part.parquet" + df.write_parquet(out_file) + + logger.info("sync_%s done: %d records written", table, len(df)) + return len(df) + + +def sync_metrics(data_dir: Path, capset: CapabilitySet) -> int: + """同步核心财务指标 (metrics)。""" + symbols = _get_symbols(data_dir) + return _sync_table("metrics", symbols, data_dir, capset, latest_only=True) + + +def sync_income(data_dir: Path, capset: CapabilitySet) -> int: + """同步利润表。""" + symbols = _get_symbols(data_dir) + return _sync_table("income", symbols, data_dir, capset, latest_only=True) + + +def sync_balance_sheet(data_dir: Path, capset: CapabilitySet) -> int: + """同步资产负债表。""" + symbols = _get_symbols(data_dir) + return _sync_table("balance_sheet", symbols, data_dir, capset, latest_only=True) + + +def sync_cash_flow(data_dir: Path, capset: CapabilitySet) -> int: + """同步现金流量表。""" + symbols = _get_symbols(data_dir) + return _sync_table("cash_flow", symbols, data_dir, capset, latest_only=True) + + +def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]: + """同步所有财务表。返回 {table: rows}。""" + if not capset.has(Cap.FINANCIAL): + logger.info("sync_all financials skipped: no FINANCIAL capability") + return {} + + symbols = _get_symbols(data_dir) + results: dict[str, int] = {} + for table in FINANCIAL_TABLES: + results[table] = _sync_table(table, symbols, data_dir, capset, latest_only=True) + + # 同步完成后注册 DuckDB 视图 + _refresh_financials_views(data_dir) + + return results + + +# ================================================================ +# DuckDB 视图 +# ================================================================ + +def _refresh_financials_views(data_dir: Path) -> None: + """刷新财务表 DuckDB 视图 (在 DataStore.db 上注册)。""" + d = data_dir.as_posix() + views = { + "financials_metrics": f"{d}/financials/metrics/*.parquet", + "financials_income": f"{d}/financials/income/*.parquet", + "financials_balance_sheet": f"{d}/financials/balance_sheet/*.parquet", + "financials_cash_flow": f"{d}/financials/cash_flow/*.parquet", + } + for name, path in views.items(): + out = data_dir / "financials" / name.replace("financials_", "") / "part.parquet" + if not out.exists(): + continue + # 视图注册需要由 DataStore 完成,这里只做日志 + logger.debug("financial parquet ready: %s (%d rows)", name, out.stat().st_size) + + +def get_financial_df(data_dir: Path, table: str) -> pl.DataFrame: + """读取本地财务 Parquet。""" + path = data_dir / "financials" / table / "part.parquet" + if not path.exists(): + return pl.DataFrame() + try: + return pl.read_parquet(path) + except Exception as e: + logger.warning("读取 financials/%s 失败: %s", table, e) + return pl.DataFrame() + + +# ================================================================ +# 调度器 +# ================================================================ + +class FinancialScheduler: + """独立调度器: 每周同步 metrics, 每季度同步三张报表。""" + + def __init__(self) -> None: + self._task: asyncio.Task | None = None + self._running = False + self._data_dir: Path | None = None + self._capset: CapabilitySet | None = None + self._lock = threading.Lock() + self._last_sync: dict[str, str] = {} # {table: iso_timestamp} + # 手动同步(run_now)是否正在进行。前端据此显示"同步中"并防重复点击。 + self._is_syncing = False + + 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 + # 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步" + 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 (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 + if self._task: + self._task.cancel() + self._task = None + logger.info("FinancialScheduler stopped") + + async def _run_loop(self) -> None: + """每周执行一次 metrics 同步。""" + try: + while self._running: + # 首次启动等 60s, 之后每 7 天执行一次 + await asyncio.sleep(60) + if not self._running: + break + + # 每周: 只同步 metrics + try: + rows = sync_metrics(self._data_dir, self._capset) + self._record_sync("metrics") + logger.info("FinancialScheduler: metrics synced, %d rows", rows) + except Exception as e: + logger.warning("FinancialScheduler: metrics sync failed: %s", e) + + # 等待下一次 (7天) + for _ in range(7 * 24 * 60): # 每分钟检查一次 _running + if not self._running: + break + await asyncio.sleep(60) + + 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]: + """同步执行一次同步(阻塞调用线程)。 + + ⚠ 全量同步需数分钟,务必在后台线程调用,不要直接在 HTTP 请求线程里阻塞, + 否则请求会长时间 pending 直至被浏览器/代理超时掐断(表现为"点击无反应")。 + HTTP 接口应调用 trigger() 立即返回,再让前端轮询 /status.syncing 看进度。 + + 用 _is_syncing 标志防并发:若已有同步在进行,本次直接跳过, + 避免重复请求拖慢服务端 / 触发上游限流。 + """ + if not self._capset or not self._capset.has(Cap.FINANCIAL): + return {} + with self._lock: + if self._is_syncing: + logger.info("financial sync skipped: already running") + return {"_skipped": 1} + self._is_syncing = True + try: + 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 返回,前端据此显示"同步中")。""" + with self._lock: + return self._is_syncing + + @property + def last_sync(self) -> dict[str, str]: + return dict(self._last_sync) + + +# 全局单例 +financial_scheduler = FinancialScheduler() diff --git a/serve/backend/app/services/index_sync.py b/serve/backend/app/services/index_sync.py new file mode 100644 index 0000000..42f3b1b --- /dev/null +++ b/serve/backend/app/services/index_sync.py @@ -0,0 +1,360 @@ +"""指数 / 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 + +from app.indicators.pipeline import compute_enriched +from app.services import kline_sync, preferences +from app.tickflow.capabilities import Cap, CapabilitySet +from app.tickflow.client import get_client +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: + """将 TickFlow quotes 响应(get_by_universes)规范为指数 instruments。 + + 付费档(Starter+)的补充来源,免费档用不到。 + """ + if resp is None: + return pl.DataFrame() + + if isinstance(resp, pl.DataFrame): + df = resp + elif hasattr(resp, "columns"): + df = pl.from_pandas(resp.reset_index() if hasattr(resp, "reset_index") else resp) + else: + rows: list[dict] = [] + for q in resp or []: + item = q if isinstance(q, dict) else {} + ext = item.get("ext") or {} + symbol = item.get("symbol") + if not symbol: + continue + rows.append({ + "symbol": str(symbol), + "name": ext.get("name") or item.get("name") or str(symbol), + }) + df = pl.DataFrame(rows) + + if df.is_empty() or "symbol" not in df.columns: + return pl.DataFrame() + + rename = {"ts_code": "symbol"} + df = df.rename({k: v for k, v in rename.items() if k in df.columns}) + + if "name" not in df.columns: + if "ext" in df.columns: + df = df.with_columns(pl.col("symbol").cast(pl.Utf8).alias("name")) + else: + df = df.with_columns(pl.col("symbol").cast(pl.Utf8).alias("name")) + + result = df.select([ + pl.col("symbol").cast(pl.Utf8), + pl.col("name").cast(pl.Utf8), + ]).with_columns([ + pl.col("symbol").str.split(".").list.first().alias("code"), + pl.lit("index").alias("asset_type"), + ]) + return result.unique(subset=["symbol"], keep="last").sort("symbol") + + +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() + rows: list[dict] = [] + for ex in _EXCHANGES: + try: + 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 + logger.warning("get_instruments(%s, type=%s) failed: %s", ex, instrument_type, e) + + if not rows: + return pl.DataFrame() + + 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.refresh_index_views() + 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( + 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到独立 parquet,并计算 enriched。 + + symbols_override 非空时,只拉这些代码(跳过 instruments 表),用于自定义范围。 + 否则取 index_instruments 表全量(指数+ETF 合并存储)。 + 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)) + if not symbols: + return 0 + else: + instruments = repo.get_index_instruments() + 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: + 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)] + 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_index_daily(raw) + enriched = compute_enriched(raw, factors=None, instruments=None) + repo.append_index_enriched(enriched) + total_rows += 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() + return total_rows diff --git a/serve/backend/app/services/instrument_sync.py b/serve/backend/app/services/instrument_sync.py new file mode 100644 index 0000000..2abae49 --- /dev/null +++ b/serve/backend/app/services/instrument_sync.py @@ -0,0 +1,122 @@ +"""标的维表同步服务。 + +盘前 9:10 调用 tf.exchanges.get_instruments("SH"/"SZ"/"BJ", type="stock") +获取全量标的元数据,flatten ext 字段,写入 instruments.parquet。 + +Starter+ 盘后可用 quotes.get(universes) 顺便补充 name。 +""" +from __future__ import annotations + +import logging +from datetime import date +from pathlib import Path + +import polars as pl + +from app.tickflow.client import get_client + +logger = logging.getLogger(__name__) + +_EXCHANGES = ["SH", "SZ", "BJ"] + + +def _flatten_instruments(items: list[dict]) -> list[dict]: + """把 SDK 返回的 Instrument 列表 flatten 成扁平行。""" + rows = [] + for item in items: + row = { + "symbol": item.get("symbol"), + "name": item.get("name"), + "code": item.get("code"), + "exchange": item.get("exchange"), + "region": item.get("region"), + "type": item.get("type"), + } + ext = item.get("ext") or {} + row["listing_date"] = ext.get("listing_date") + row["total_shares"] = ext.get("total_shares") + row["float_shares"] = ext.get("float_shares") + row["tick_size"] = ext.get("tick_size") + row["limit_up"] = ext.get("limit_up") + row["limit_down"] = ext.get("limit_down") + rows.append(row) + return rows + + +def sync_instruments(data_dir: Path) -> int: + """全量同步标的维表 → data/instruments/instruments.parquet。 + + 返回写入的行数。 + """ + tf = get_client() + all_rows: list[dict] = [] + + for ex in _EXCHANGES: + try: + items = tf.exchanges.get_instruments(ex, instrument_type="stock") + if items: + all_rows.extend(_flatten_instruments(items)) + logger.info("instruments %s: %d stocks", ex, len(items)) + except Exception as e: + logger.warning("get_instruments(%s) failed: %s", ex, e) + + if not all_rows: + return 0 + + df = pl.DataFrame(all_rows) + df = df.with_columns(pl.lit(date.today()).alias("as_of")) + + out = data_dir / "instruments" / "instruments.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + df.write_parquet(out) + + logger.info("instruments synced: %d rows → %s", df.height, out) + return df.height + + +def enrich_names_from_quotes( + data_dir: Path, + quotes_data: list[dict], +) -> int: + """从 quotes 响应中提取 name,更新 instruments 维表(兜底补充)。 + + 盘后 quotes.get(universes) 返回的数据中包含 ext.name, + 用来补充 instruments 中可能缺失的 name。 + """ + if not quotes_data: + return 0 + + # 构建 symbol → name 映射 + name_map: dict[str, str] = {} + for q in quotes_data: + symbol = q.get("symbol", "") + ext = q.get("ext") or {} + name = ext.get("name") or q.get("name", "") + if symbol and name: + name_map[symbol] = name + + if not name_map: + return 0 + + inst_path = data_dir / "instruments" / "instruments.parquet" + if not inst_path.exists(): + return 0 + + df = pl.read_parquet(inst_path) + + # 只更新空 name 的行 + updates = pl.DataFrame({ + "symbol": list(name_map.keys()), + "_new_name": list(name_map.values()), + }) + df = df.join(updates, on="symbol", how="left") + df = df.with_columns( + pl.when(pl.col("name").is_null() | (pl.col("name") == "")) + .then(pl.col("_new_name")) + .otherwise(pl.col("name")) + .alias("name"), + ).drop("_new_name") + + df.write_parquet(inst_path) + logger.info("instruments name enriched from quotes: %d names", len(name_map)) + return len(name_map) diff --git a/serve/backend/app/services/kline_sync.py b/serve/backend/app/services/kline_sync.py new file mode 100644 index 0000000..7a4d5f5 --- /dev/null +++ b/serve/backend/app/services/kline_sync.py @@ -0,0 +1,674 @@ +"""日 K 同步服务(§7.7 Step 1)。 + +调度器在 capability 允许下,把符号集合的日 K 批量同步到本地 Parquet。 +策略: + - 日 K 仅使用 `kline.daily.batch` + - 除权因子仅使用 `adj_factor` +""" +from __future__ import annotations + +import logging +import time +from collections.abc import Callable +from datetime import datetime, timedelta + +import polars as pl + +from app.indicators.pipeline import filter_halt_days +from app.tickflow.capabilities import Cap, CapabilitySet +from app.tickflow.client import get_client +from app.tickflow.repository import KlineRepository + +logger = logging.getLogger(__name__) + + +# 标准列(无论 SDK 返回什么形状,我们把它规范成这套) +CANONICAL_DAILY_COLS = [ + "symbol", "date", "open", "high", "low", "close", "volume", "amount", +] + + +def _normalize_daily(df_in, default_symbol: str | None = None) -> pl.DataFrame: + """把 SDK 返回的 pandas/任意 DataFrame 规范成 canonical 列。""" + if df_in is None or len(df_in) == 0: + return pl.DataFrame() + + if not isinstance(df_in, pl.DataFrame): + df = pl.from_pandas(df_in.reset_index() if hasattr(df_in, "reset_index") else df_in) + else: + df = df_in + + # 兼容字段名差异 + rename_map = { + "ts_code": "symbol", + "trade_date": "date", + "vol": "volume", + "amt": "amount", + "datetime": "date", + } + 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 is not None: + 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"): + if col in df.columns: + df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False)) + for col in ("volume", "amount"): + if col in df.columns: + df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False)) + + # 过滤停牌日 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断) + df = filter_halt_days(df) + + # 只保留 canonical 列 + keep = [c for c in CANONICAL_DAILY_COLS if c in df.columns] + return df.select(keep) + + +def sync_daily_batch(symbols: list[str], + count: int | None = None, + batch_size: int | None = None, + rpm: int | None = None, + start_time: datetime | None = None, + end_time: datetime | None = None, + on_chunk_done: Callable[[int, int], None] | None = None) -> pl.DataFrame: + """批量拉取多股日 K。 + + 优先使用 start_time / end_time 区间 + count=10000,确保覆盖完整时间段。 + 仅传 count 时按条数回溯。 + """ + tf = get_client() + out: list[pl.DataFrame] = [] + interval = (60.0 / rpm) if rpm else 0 + + if batch_size is None: + chunks = [symbols] + else: + chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] + + for i, chunk in enumerate(chunks): + if i > 0 and interval > 0 and len(chunks) > rpm: + time.sleep(interval) + try: + if start_time and end_time: + raw = tf.klines.batch( + chunk, period="1d", adjust="none", + start_time=_datetime_to_ms(start_time), + end_time=_datetime_to_ms(end_time), + count=10000, + as_dataframe=True, show_progress=False, + ) + else: + raw = tf.klines.batch(chunk, period="1d", count=count or 250, adjust="none", + as_dataframe=True, show_progress=False) + except Exception as e: # noqa: BLE001 + logger.warning("batch fetch failed for %d symbols: %s", len(chunk), e) + continue + + # 兼容两种形态:dict[sym → df] 和扁平 df + if isinstance(raw, dict): + for sym, sub in raw.items(): + if sub is None or len(sub) == 0: + continue + out.append(_normalize_daily(sub, default_symbol=sym)) + elif raw is not None and len(raw) > 0: + out.append(_normalize_daily(raw)) + + if on_chunk_done: + on_chunk_done(i + 1, len(chunks)) + + if not out: + return pl.DataFrame() + return pl.concat(out, how="diagonal_relaxed") + + +def sync_and_persist_daily_batch( + symbols: list[str], + repo: KlineRepository, + capset: CapabilitySet, + count: int | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + on_chunk_done: Callable[[int, int], None] | None = None, +) -> int: + """批量同步日 K 并落到 Parquet。返回写入的行数。 + + start_date/end_date: 外部传入的时间范围(由 pipeline 根据已有数据计算)。 + 未传入时默认拉最近 1 年。 + """ + if not symbols or not capset.has(Cap.KLINE_DAILY_BATCH): + return 0 + + lim = capset.limits(Cap.KLINE_DAILY_BATCH) + batch_size = lim.batch if lim and lim.batch else 100 + rpm = lim.rpm if lim else None + + end_time = end_date or datetime.now() + start_time = start_date or (end_time - timedelta(days=365)) + + df = sync_daily_batch( + symbols, count=count, batch_size=batch_size, rpm=rpm, + start_time=start_time, end_time=end_time, + on_chunk_done=on_chunk_done, + ) + + if df.is_empty(): + return 0 + + repo.append_daily(df) + + try: + d = repo.store.data_dir.as_posix() + repo.db.execute( + f"""CREATE OR REPLACE VIEW kline_daily AS + SELECT * FROM read_parquet('{d}/kline_daily/**/*.parquet', union_by_name=true)""" + ) + except Exception as e: # noqa: BLE001 + logger.warning("refresh view failed: %s", e) + + return df.height + + +def sync_daily_by_quotes(repo: KlineRepository) -> int: + """用实时行情接口拉全市场当日数据,覆写 kline_daily 今天分区。 + + 一个请求覆盖 ~5500 只股票,比 batch K-line 快几个数量级。 + 返回写入的行数。 + """ + from datetime import date as _date + + from app.tickflow.client import get_client + + tf = get_client() + try: + resp = tf.quotes.get_by_universes(universes=["CN_Equity_A"]) + except Exception as e: + logger.warning("get_by_universes failed: %s", e) + return 0 + + if not resp: + logger.warning("get_by_universes returned empty") + return 0 + + records = [] + for q in resp: + ext = q.get("ext") or {} + records.append({ + "symbol": q.get("symbol"), + "open": q.get("open"), + "high": q.get("high"), + "low": q.get("low"), + "close": q.get("last_price"), + "volume": q.get("volume"), + "amount": q.get("amount"), + }) + + df = pl.DataFrame(records) + if df.is_empty(): + return 0 + + today = _date.today() + daily_df = df.with_columns(pl.lit(today).cast(pl.Date).alias("date")) + + # 过滤停牌 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断) + daily_df = filter_halt_days(daily_df) + + repo.flush_live_daily(daily_df) + logger.info("sync_daily_by_quotes: %d symbols flushed for %s", daily_df.height, today) + 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, + asset_type: str = "stock") -> tuple[int, list[str]]: + """同步除权因子(Starter+)。SDK 接口:`tf.klines.ex_factors(symbols=...)`。 + + 支持增量: 传 start_time/end_time 只拉取该时间范围内的新除权事件。 + 返回 (写入行数, 受影响的 symbol 列表) — 供 enriched 局部重算使用。 + """ + if not capset.has(Cap.ADJ_FACTOR) or not symbols: + return 0, [] + + tf = get_client() + lim = capset.limits(Cap.ADJ_FACTOR) + batch_size = lim.batch if lim and lim.batch else 50 + rpm = lim.rpm if lim else 30 + interval = 60.0 / rpm if rpm else 0 + + # 构建 SDK 参数 + sdk_kwargs: dict = {"as_dataframe": True, "batch_size": batch_size, "show_progress": False} + if start_time: + sdk_kwargs["start_time"] = _datetime_to_ms(start_time) + if end_time: + sdk_kwargs["end_time"] = _datetime_to_ms(end_time) + + chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] + all_dfs: list[pl.DataFrame] = [] + + for i, chunk in enumerate(chunks): + if i > 0 and interval > 0 and len(chunks) > rpm: + time.sleep(interval) + try: + raw = tf.klines.ex_factors(chunk, **sdk_kwargs) + 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) + + if on_chunk_done: + on_chunk_done(i + 1, len(chunks)) + + if not all_dfs: + return 0, [] + + new_data = pl.concat(all_dfs, how="diagonal_relaxed") if len(all_dfs) > 1 else all_dfs[0] + + # 提取受影响的 symbol 列表(合并前) + affected = new_data["symbol"].unique().to_list() + + 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(): + existing = pl.read_parquet(out) + before = existing.height + merged = pl.concat([existing, new_data]).unique( + subset=["symbol", "trade_date"], keep="last", + ).sort(["symbol", "trade_date"]) + merged.write_parquet(out) + added = merged.height - before + logger.info("adj_factor merged: %d total (+%d new), %d/%d symbols", + merged.height, added, new_data.height, len(symbols)) + return added, affected + else: + new_data.sort(["symbol", "trade_date"]).write_parquet(out) + logger.info("adj_factor synced: %d rows (%d symbols)", new_data.height, len(symbols)) + return new_data.height, affected + + +# ===== 分钟 K 同步 ===== + +CANONICAL_MINUTE_COLS = [ + "symbol", "datetime", "open", "high", "low", "close", "volume", "amount", +] + + +def _normalize_minute(df_in, default_symbol: str | None = None) -> pl.DataFrame: + """把 SDK 返回的分钟 K 数据规范成 canonical 列。""" + if df_in is None or len(df_in) == 0: + return pl.DataFrame() + + if not isinstance(df_in, pl.DataFrame): + df = pl.from_pandas(df_in.reset_index() if hasattr(df_in, "reset_index") else df_in) + else: + df = df_in + + rename_map = { + "ts_code": "symbol", + "vol": "volume", + "amt": "amount", + } + df = df.rename({k: v for k, v in rename_map.items() if k in df.columns}) + + # datetime 列:优先用 timestamp(毫秒精度),其次 trade_time + if "timestamp" in df.columns: + df = df.with_columns( + pl.from_epoch("timestamp", time_unit="ms").alias("datetime"), + ).drop("timestamp") + for drop_col in ("trade_time", "trade_date"): + if drop_col in df.columns: + df = df.drop(drop_col) + elif "trade_time" in df.columns: + df = df.rename({"trade_time": "datetime"}) + if "trade_date" in df.columns: + df = df.drop("trade_date") + elif "trade_date" in df.columns: + df = df.rename({"trade_date": "datetime"}) + + if "symbol" not in df.columns and default_symbol is not None: + df = df.with_columns(pl.lit(default_symbol).alias("symbol")) + + # 类型规范:统一转 Datetime('us') + if "datetime" in df.columns: + dt_type = df.schema["datetime"] + if not isinstance(dt_type, pl.Datetime) or dt_type.time_unit != "us": + df = df.with_columns(pl.col("datetime").cast(pl.Datetime("us"), strict=False)) + + for col in ("open", "high", "low", "close"): + if col in df.columns: + df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False)) + for col in ("volume", "amount"): + if col in df.columns: + df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False)) + + keep = [c for c in CANONICAL_MINUTE_COLS if c in df.columns] + return df.select(keep) + + +def _datetime_to_ms(dt: datetime) -> int: + """datetime → 毫秒时间戳 (供 SDK start_time / end_time 使用)。""" + return int(dt.timestamp() * 1000) + + +def sync_minute_batch( + symbols: list[str], + start_time: datetime | None = None, + end_time: datetime | None = None, + count: int | None = None, + batch_size: int | None = None, + rpm: int | None = None, + on_chunk_done: Callable[[int, int], None] | None = None, +) -> pl.DataFrame: + """批量拉取多股分钟 K。 + + 优先使用 start_time / end_time 区间, 确保所有标的覆盖同一时间段。 + count 仅作为 fallback 保留。 + on_chunk_done(current, total) 每个 chunk 完成后回调。 + """ + tf = get_client() + out: list[pl.DataFrame] = [] + interval = (60.0 / rpm) if rpm else 0 + + if batch_size is None: + chunks = [symbols] + else: + chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] + + for i, chunk in enumerate(chunks): + if i > 0 and interval > 0 and len(chunks) > rpm: + time.sleep(interval) + try: + if start_time and end_time: + raw = tf.klines.batch( + chunk, period="1m", + start_time=_datetime_to_ms(start_time), + end_time=_datetime_to_ms(end_time), + count=10000, + as_dataframe=True, show_progress=False, + ) + else: + raw = tf.klines.batch(chunk, period="1m", count=count or 1200, + as_dataframe=True, show_progress=False) + except Exception as e: # noqa: BLE001 + logger.warning("minute batch fetch failed for %d symbols: %s", len(chunk), e) + continue + + if isinstance(raw, dict): + for sym, sub in raw.items(): + if sub is None or len(sub) == 0: + continue + out.append(_normalize_minute(sub, default_symbol=sym)) + elif raw is not None and len(raw) > 0: + out.append(_normalize_minute(raw)) + + if on_chunk_done: + on_chunk_done(i + 1, len(chunks)) + + if not out: + return pl.DataFrame() + return pl.concat(out, how="diagonal_relaxed") + + +def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame: + """从 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) + tf = get_client() + try: + raw = tf.klines.batch( + [symbol], period="1m", + start_time=_datetime_to_ms(start_time), + end_time=_datetime_to_ms(end_time), + count=10000, + as_dataframe=True, show_progress=False, + ) + except Exception as e: + logger.warning("fetch_minute_single(%s, %s) failed: %s", symbol, trade_date, e) + return pl.DataFrame() + + if isinstance(raw, dict): + sub = raw.get(symbol) + return _normalize_minute(sub) if sub is not None and len(sub) > 0 else pl.DataFrame() + if raw is not None and len(raw) > 0: + return _normalize_minute(raw) + 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: + res = repo.execute_one("SELECT max(datetime) FROM kline_minute") + if res and res[0]: + d = res[0] + if isinstance(d, datetime): + return d + return datetime.fromisoformat(str(d)) + except Exception: # noqa: BLE001 + pass + return None + + +def _cleanup_null_datetime_minute(repo: KlineRepository) -> None: + """检测并清除 datetime 全为 null 的旧版分钟 K 数据(迁移用)。""" + minute_dir = repo.store.data_dir / "kline_minute" + if not minute_dir.exists(): + return + try: + row = repo.execute_one( + "SELECT count(*) AS total, count(datetime) AS non_null FROM kline_minute" + ) + if row and row[0] > 0 and (row[1] is None or row[1] == 0): + # 全部 datetime 为 null — 清除所有分钟 K parquet + n = 0 + for f in minute_dir.rglob("*.parquet"): + f.unlink() + n += 1 + logger.info("cleaned %d corrupted minute-K parquet files (null datetime)", n) + except Exception as e: # noqa: BLE001 + logger.debug("minute cleanup check failed: %s", e) + + +def _migrate_symbol_to_date_partition(repo: KlineRepository) -> None: + """将旧版 symbol= 分区迁移为 date= 分区。迁移完成后删除旧目录。""" + minute_dir = repo.store.data_dir / "kline_minute" + if not minute_dir.exists(): + return + + old_dirs = [d for d in minute_dir.iterdir() if d.is_dir() and d.name.startswith("symbol=")] + if not old_dirs: + return + + logger.info("migrating %d symbol-partitioned minute-K dirs to date partition…", len(old_dirs)) + + all_frames: list[pl.DataFrame] = [] + for sym_dir in old_dirs: + for pq in sym_dir.glob("*.parquet"): + try: + df = pl.read_parquet(pq) + if "datetime" in df.columns: + df = df.filter(pl.col("datetime").is_not_null()) + if not df.is_empty(): + all_frames.append(df) + except Exception: # noqa: BLE001 + pass + + if not all_frames: + # 数据全部不可用,直接删旧目录 + for d in old_dirs: + d.mkdir(parents=True, exist_ok=True) + for f in d.rglob("*"): + if f.is_file(): + f.unlink() + d.rmdir() + return + + combined = pl.concat(all_frames, how="diagonal_relaxed") + combined = combined.unique(subset=["symbol", "datetime"], keep="last") + + # 按日期写新分区 + combined = combined.with_columns(pl.col("datetime").dt.date().alias("_trade_date")) + for day_df in combined.partition_by("_trade_date"): + trade_date = day_df["_trade_date"][0] + out = minute_dir / f"date={trade_date}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + day_df = day_df.drop("_trade_date").sort("symbol", "datetime") + day_df.write_parquet(out) + + # 删旧目录 + for d in old_dirs: + for f in d.rglob("*"): + if f.is_file(): + f.unlink() + # 移除空目录 + try: + d.rmdir() + except OSError: + pass + + logger.info("minute-K migration done: %d rows migrated", combined.height) + + +def sync_and_persist_minute( + symbols: list[str], + repo: KlineRepository, + capset: CapabilitySet, + days: int = 5, + on_chunk_done: Callable[[int, int], None] | None = None, +) -> int: + """同步分钟 K 并存到 Parquet(仅 raw,不前复权)。返回写入行数。 + + 使用 start_time / end_time 区间拉取, 确保所有标的覆盖同一时间段。 + on_chunk_done(current, total) 每个 chunk 完成后回调。 + """ + if not symbols or not capset.has(Cap.KLINE_MINUTE_BATCH): + return 0 + + # 迁移:旧版 _normalize_minute 未转换 timestamp→datetime,导致全部 datetime 为 null + # 检测到后直接清除(这些数据无法使用) + _cleanup_null_datetime_minute(repo) + + # 迁移:旧版按 symbol= 分区转为 date= 分区 + _migrate_symbol_to_date_partition(repo) + + now = datetime.now() + + # 计算时间区间: 首次拉取回溯 N 天, 增量从最后数据时间开始 + last_dt = _latest_minute_datetime(repo) + if last_dt: + start_time = last_dt + else: + start_time = now - timedelta(days=days) + end_time = now + + lim = capset.limits(Cap.KLINE_MINUTE_BATCH) + batch_size = lim.batch if lim and lim.batch else 100 + rpm = lim.rpm if lim else 30 + + df = sync_minute_batch(symbols, start_time=start_time, end_time=end_time, + batch_size=batch_size, rpm=rpm, + on_chunk_done=on_chunk_done) + if df.is_empty(): + return 0 + + # 按日期分区写: data/kline_minute/date={YYYY-MM-DD}/part.parquet + df = df.with_columns( + pl.col("datetime").dt.date().alias("_trade_date") + ) + written = 0 + for day_df in df.partition_by("_trade_date"): + trade_date = day_df["_trade_date"][0] + out = repo.store.data_dir / "kline_minute" / f"date={trade_date}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + if out.exists(): + existing = pl.read_parquet(out) + if "datetime" in existing.columns: + existing = existing.filter(pl.col("datetime").is_not_null()) + day_df = pl.concat([existing, day_df.drop("_trade_date")]).unique( + subset=["symbol", "datetime"], keep="last", + ) + else: + day_df = day_df.drop("_trade_date") + day_df = day_df.sort("symbol", "datetime") + day_df.write_parquet(out) + written += day_df.height + + # 刷新视图 + try: + d = repo.store.data_dir.as_posix() + repo.db.execute( + f"""CREATE OR REPLACE VIEW kline_minute AS + SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)""" + ) + except Exception as e: # noqa: BLE001 + logger.warning("refresh kline_minute view failed: %s", e) + + logger.info("minute K synced: %d rows (%d symbols)", written, len(symbols)) + return written diff --git a/serve/backend/app/services/market_overview_builder.py b/serve/backend/app/services/market_overview_builder.py new file mode 100644 index 0000000..8a1adfd --- /dev/null +++ b/serve/backend/app/services/market_overview_builder.py @@ -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, + }) diff --git a/serve/backend/app/services/market_recap.py b/serve/backend/app/services/market_recap.py new file mode 100644 index 0000000..2fe0d7d --- /dev/null +++ b/serve/backend/app/services/market_recap.py @@ -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 diff --git a/serve/backend/app/services/market_recap_reports.py b/serve/backend/app/services/market_recap_reports.py new file mode 100644 index 0000000..06be304 --- /dev/null +++ b/serve/backend/app/services/market_recap_reports.py @@ -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") diff --git a/serve/backend/app/services/notify_adapter.py b/serve/backend/app/services/notify_adapter.py new file mode 100644 index 0000000..1d95c17 --- /dev/null +++ b/serve/backend/app/services/notify_adapter.py @@ -0,0 +1,136 @@ +"""系统通知适配器 — 三平台原生通知中心。 + +职责: 把后端产生的告警事件推送到操作系统通知中心。 + 窗口最小化 / 被遮挡 / 后台运行时都能弹通知 (不依赖前端 WebView)。 + +平台实现: + - Windows: winotify (进现代操作中心, 支持图标) + - macOS: osascript (系统已内置, 无需额外依赖) + - Linux: notify-send (系统已内置) / plyer 兜底 + +设计: 失败静默降级, 绝不因通知失败阻断告警主流程 (落盘 / SSE 推送)。 + 通知去重不在本层做, 复用 MonitorRuleEngine 的 cooldown 逻辑。 +""" +from __future__ import annotations + +import logging +import subprocess +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 单次通知最长字符 (避免某些平台截断报错) +_MAX_LEN = 200 + +# 避免重复探测平台能力, 缓存一次 +_backend_cache: str | None = None + + +def _detect_backend() -> str | None: + """探测当前平台可用的通知后端。""" + global _backend_cache + if _backend_cache is not None: + return _backend_cache if _backend_cache != "none" else None + + backend = None + 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" + + _backend_cache = backend or "none" + return backend + + +def _truncate(text: str) -> str: + """截断超长文本, 避免平台通知上限报错。""" + text = (text or "").strip() + return text[:_MAX_LEN] + ("…" if len(text) > _MAX_LEN else "") + + +def notify(title: str, message: str, icon: Path | None = None) -> bool: + """推送一条系统通知。 + + Args: + title: 通知标题 + message: 通知正文 + icon: 可选图标路径 (部分平台支持) + + Returns: + True=成功送达, False=失败或无可用后端。 + 失败静默, 不抛异常 (通知是辅助通道, 不能阻断告警主流程)。 + """ + title = _truncate(title) + message = _truncate(message) + if not title: + return False + + backend = _detect_backend() + if backend is None: + return False + + try: + if backend == "winotify": + return _notify_winotify(title, message) + if backend == "osascript": + return _notify_osascript(title, message) + if backend == "notify-send": + return _notify_notify_send(title, message, icon) + except Exception as e: # noqa: BLE001 + logger.debug("系统通知失败 (%s): %s", backend, e) + return False + + 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 注入 + safe_title = title.replace('"', '\\"') + safe_msg = message.replace('"', '\\"') + script = ( + f'display notification "{safe_msg}" with title "{safe_title}"' + ) + result = subprocess.run( # noqa: S603, S607 + ["osascript", "-e", script], + capture_output=True, + timeout=5, + ) + return result.returncode == 0 + + +def _notify_notify_send(title: str, message: str, icon: Path | None) -> bool: + """Linux 通知 (notify-send) — freedesktop.org 标准。""" + args = ["notify-send", title] + if icon and Path(icon).exists(): + args.extend(["--icon", str(icon)]) + args.append(message) + result = subprocess.run( # noqa: S603 + args, + capture_output=True, + timeout=5, + ) + return result.returncode == 0 diff --git a/serve/backend/app/services/pipeline_jobs.py b/serve/backend/app/services/pipeline_jobs.py new file mode 100644 index 0000000..979eddb --- /dev/null +++ b/serve/backend/app/services/pipeline_jobs.py @@ -0,0 +1,250 @@ +"""异步盘后管道任务注册表 — 每个 job 独立 JSON 文件。 + +设计: + - job_store/ 文件夹,每个 job 一个 {id}.json,最多保留 max_jobs 个文件 + - running/pending 状态的 job 仅存内存(高频读写) + - succeeded/failed 后写入独立文件并从内存释放 + - 列表查询 = 内存中的活跃 job + 磁盘文件扫描,按时间排序 + - 单个查询 = 内存优先,没有则读磁盘 + - 创建新 job 前检查文件数量,>= max_jobs 时删除最老的文件 +""" +from __future__ import annotations + +import json +import logging +import os +import threading +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Literal + +logger = logging.getLogger(__name__) + +JobStatus = Literal["pending", "running", "succeeded", "failed"] + + +def _default_store_dir() -> Path: + from app.config import settings + return settings.data_dir / "job_store" + + +_STORE_DIR = _default_store_dir() + + +class JobStore: + def __init__(self, max_jobs: int = 50, store_dir: Path = _STORE_DIR) -> None: + self._max_jobs = max_jobs + self._store_dir = store_dir + self._active_jobs: dict[str, dict[str, Any]] = {} # running/pending + self._active_id: str | None = None + self._lock = threading.Lock() + self._store_dir.mkdir(parents=True, exist_ok=True) + + # ===== persistence ===== + + def _write_file(self, job: dict[str, Any]) -> None: + """将终态 job 写入独立 JSON 文件。""" + path = self._store_dir / f"{job['id']}.json" + try: + path.write_text( + json.dumps(job, ensure_ascii=False, indent=None), + encoding="utf-8", + ) + except Exception: + logger.warning("failed to write job file %s", path) + + def _read_file(self, job_id: str) -> dict[str, Any] | None: + """从磁盘读取单个 job 文件。""" + path = self._store_dir / f"{job_id}.json" + if not path.exists(): + return None + try: + return json.loads(path.read_text("utf-8")) + except Exception: + logger.warning("failed to read job file %s", path) + return None + + def _delete_oldest(self) -> None: + """删除最老的 job 文件,保持文件数量 < max_jobs。""" + try: + files = sorted(self._store_dir.glob("*.json"), key=lambda f: f.stat().st_mtime) + except Exception: + return + while len(files) >= self._max_jobs: + oldest = files.pop(0) + try: + oldest.unlink() + except Exception: + logger.warning("failed to delete old job file %s", oldest) + + def _job_files_sorted(self) -> list[dict[str, Any]]: + """扫描磁盘上所有 job 文件,按 started_at 从新到旧排序。""" + jobs: list[dict[str, Any]] = [] + for f in self._store_dir.glob("*.json"): + try: + jobs.append(json.loads(f.read_text("utf-8"))) + except Exception: + continue + jobs.sort(key=lambda j: j.get("started_at") or "", reverse=True) + return jobs + + # ===== lifecycle ===== + + def create(self) -> str: + with self._lock: + if self._active_id and self._active_jobs.get(self._active_id, {}).get("status") == "running": + return self._active_id + + job_id = uuid.uuid4().hex[:10] + self._active_jobs[job_id] = { + "id": job_id, + "status": "pending", + "stage": "init", + "progress": 0, + "stage_pct": 0, + "log": [], + "started_at": None, + "finished_at": None, + "duration_s": None, + "result": None, + "error": None, + } + self._active_id = job_id + return job_id + + def start(self, job_id: str) -> None: + with self._lock: + j = self._active_jobs.get(job_id) + if not j: + return + j["status"] = "running" + j["started_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z" + + def succeed(self, job_id: str, result: Any) -> None: + with self._lock: + j = self._active_jobs.pop(job_id, None) + if not j: + return + j["status"] = "succeeded" + j["finished_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z" + j["progress"] = 100 + j["result"] = result + j["duration_s"] = _duration_s(j) + if self._active_id == job_id: + self._active_id = None + self._delete_oldest() + self._write_file(j) + + def fail(self, job_id: str, error: str) -> None: + with self._lock: + j = self._active_jobs.pop(job_id, None) + if not j: + return + j["status"] = "failed" + j["finished_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z" + j["error"] = error + j["duration_s"] = _duration_s(j) + if self._active_id == job_id: + self._active_id = None + self._delete_oldest() + self._write_file(j) + + # ===== progress ===== + + def progress(self, job_id: str, stage: str, pct: int, msg: str, + stage_pct: int | None = None, skip_log: bool = False) -> None: + with self._lock: + j = self._active_jobs.get(job_id) + if not j: + return + j["stage"] = stage + j["progress"] = max(0, min(100, int(pct))) + if stage_pct is not None: + j["stage_pct"] = max(0, min(100, int(stage_pct))) + elif j["stage"] != stage: + j["stage_pct"] = 0 + entry = { + "ts": datetime.utcnow().isoformat(timespec="seconds") + "Z", + "stage": stage, + "msg": msg, + } + if skip_log: + entry["_skip"] = True + if skip_log and j["log"] and j["log"][-1].get("stage") == stage and j["log"][-1].get("_skip"): + j["log"][-1] = entry + else: + j["log"].append(entry) + if len(j["log"]) > 200: + j["log"] = j["log"][-200:] + + # ===== query ===== + + def get(self, job_id: str) -> dict[str, Any] | None: + # 内存中的活跃 job 优先 + j = self._active_jobs.get(job_id) + if j: + return j + # 否则从磁盘读 + return self._read_file(job_id) + + def list_recent(self, limit: int = 20) -> list[dict[str, Any]]: + # 合并: 内存中的活跃 job + 磁盘文件 + all_jobs: list[dict[str, Any]] = list(self._active_jobs.values()) + all_jobs.extend(self._job_files_sorted()) + # 按 started_at 从新到旧排序,去重(理论上不会有重复) + seen: set[str] = set() + result: list[dict[str, Any]] = [] + for j in sorted(all_jobs, key=lambda x: x.get("started_at") or "", reverse=True): + jid = j["id"] + if jid in seen: + continue + seen.add(jid) + result.append(_summary(j)) + if len(result) >= limit: + break + return result + + def active_id(self) -> str | None: + return self._active_id + + def clear(self) -> None: + """清空所有任务(内存 + 磁盘文件)。""" + with self._lock: + self._active_jobs.clear() + self._active_id = None + for f in self._store_dir.glob("*.json"): + try: + f.unlink() + except Exception: + pass + + +def _summary(j: dict[str, Any]) -> dict[str, Any]: + return { + "id": j["id"], + "status": j["status"], + "stage": j["stage"], + "progress": j["progress"], + "stage_pct": j.get("stage_pct", 0), + "started_at": j["started_at"], + "finished_at": j["finished_at"], + "duration_s": j["duration_s"], + "result": j["result"], + "error": j["error"], + } + + +def _duration_s(j: dict[str, Any]) -> float | None: + if not j.get("started_at") or not j.get("finished_at"): + return None + try: + s = datetime.fromisoformat(j["started_at"]) + e = datetime.fromisoformat(j["finished_at"]) + return round((e - s).total_seconds(), 2) + except Exception: # noqa: BLE001 + return None + + +# 进程内单例 +job_store = JobStore() diff --git a/serve/backend/app/services/preferences.py b/serve/backend/app/services/preferences.py new file mode 100644 index 0000000..f4e31db --- /dev/null +++ b/serve/backend/app/services/preferences.py @@ -0,0 +1,585 @@ +"""用户偏好设置持久化。 + +存储位置: data/user_data/preferences.json +沿用 secrets_store 的 merge-write 模式,但不做 chmod 0600 (非敏感数据)。 +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _path() -> Path: + from app.config import settings + p = settings.data_dir / "user_data" / "preferences.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("preferences.json malformed: %s", e) + return {} + + +def save(updates: dict) -> dict: + """合并写入。返回新内容。""" + current = load() + current.update(updates) + _path().write_text( + json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8", + ) + return current + + +def get_realtime_quotes_enabled() -> bool: + return load().get("realtime_quotes_enabled", False) + + +def get_indices_nav_pinned() -> bool: + """侧栏指数报价卡片是否固定显示。默认 True(常驻)。 + 关闭后,卡片跟随实时行情开关(仅实时开时显示)。""" + return load().get("indices_nav_pinned", True) + + +def get_realtime_quote_interval() -> float: + return load().get("realtime_quote_interval", 10.0) + + +def get_realtime_watchlist_symbols() -> list[str]: + """Free 档自选实时监控标的:直接取自选页前 5 个。""" + try: + from app.services import watchlist + rows = watchlist.list_symbols() + except Exception as e: # noqa: BLE001 + logger.warning("load watchlist for realtime failed: %s", e) + return [] + out: list[str] = [] + for row in rows: + symbol = str((row or {}).get("symbol") or "").strip().upper() + if symbol and symbol not in out: + out.append(symbol) + if len(out) >= 5: + break + return out + + +def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001 + """兼容旧接口: Free 实时标的现在由自选页前 5 个决定。""" + return get_realtime_watchlist_symbols() + + +def set_realtime_quote_interval(interval: float) -> float: + """保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。""" + current = load() + current["realtime_quote_interval"] = interval + _path().write_text( + json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8", + ) + return interval + + +def get_minute_sync_enabled() -> bool: + return load().get("minute_sync_enabled", False) + + +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}) + return {"hour": d.get("hour", 15), "minute": d.get("minute", 30)} + + +def set_pipeline_schedule(hour: int, minute: int) -> dict: + h = max(0, min(23, hour)) + m = max(0, min(59, minute)) + # 盘后不早于 15:00 + if h * 60 + m < 15 * 60: + h, m = 15, 0 + save({"pipeline_schedule": {"hour": h, "minute": m}}) + return {"hour": h, "minute": m} + + +def get_instruments_schedule() -> dict: + """返回盘前标的维表调度时间 {"hour": 9, "minute": 10}。""" + d = load().get("instruments_schedule", {"hour": 9, "minute": 10}) + return {"hour": d.get("hour", 9), "minute": d.get("minute", 10)} + + +def set_instruments_schedule(hour: int, minute: int) -> dict: + h = max(0, min(23, hour)) + m = max(0, min(59, minute)) + # 盘前不晚于 09:15 + if h * 60 + m > 9 * 60 + 15: + h, m = 9, 15 + save({"instruments_schedule": {"hour": h, "minute": m}}) + return {"hour": h, "minute": m} + + +def get_enriched_batch_size() -> int: + """返回 enriched 全量计算每批 symbol 数量。""" + return max(1, min(10000, load().get("enriched_batch_size", 1000))) + + +def set_enriched_batch_size(size: int) -> int: + """保存 enriched 全量计算批次大小。""" + size = max(10, min(6000, size)) + save({"enriched_batch_size": size}) + return size + + +def get_index_daily_batch_size() -> int: + """返回指数日 K 同步每批 symbol 数量。""" + return max(1, min(10000, load().get("index_daily_batch_size", 100))) + + +def set_index_daily_batch_size(size: int) -> int: + """保存指数日 K 同步批次大小。""" + size = max(1, min(10000, size)) + save({"index_daily_batch_size": size}) + return size + + +# ── 五档盘口 sealed(真假涨停) 配置 ────────────────────── + +def get_limit_ladder_monitor_enabled() -> bool: + """连板梯队 5 档监控开关。关闭时 depth 不轮询(连板梯队降级显示)。""" + return load().get("limit_ladder_monitor_enabled", False) + + +def get_depth_polling_interval() -> float: + """depth 盘中轮询间隔(秒)。默认 20(Pro/Expert 都适用)。""" + return float(load().get("depth_polling_interval", 20.0)) + + +def set_depth_polling_interval(interval: float) -> float: + """保存 depth 轮询间隔。套餐范围 clamp 由 depth_service 按档位做。""" + interval = max(1.0, min(600.0, float(interval))) + save({"depth_polling_interval": interval}) + return interval + + +def get_depth_finalize_time() -> dict: + """盘后 sealed 定版时间 {"hour": 15, "minute": 2}。范围 15:01~18:00。""" + d = load().get("depth_finalize_time", {"hour": 15, "minute": 2}) + return {"hour": d.get("hour", 15), "minute": d.get("minute", 2)} + + +def set_depth_finalize_time(hour: int, minute: int) -> dict: + """保存盘后 sealed 定版时间,强制范围 15:01~18:00。""" + h = max(0, min(23, hour)) + m = max(0, min(59, minute)) + # 下限 15:01, 上限 18:00 + if h * 60 + m < 15 * 60 + 1: + h, m = 15, 1 + if h * 60 + m > 18 * 60: + h, m = 18, 0 + save({"depth_finalize_time": {"hour": h, "minute": m}}) + return {"hour": h, "minute": m} + + +# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位) +# 多选: 不推送 = 空数组, 而非 'none' +REVIEW_PUSH_CHANNELS = {"feishu"} + + +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 + + + +# ===== 实时监控 ===== + +# 页面 SSE 刷新配置: { "watchlist": true, "monitor": true, ... } +# 可刷新的页面列表及其默认值 +SSE_REFRESH_PAGES_DEFAULT = { + "watchlist": True, + "limit-ladder": False, +} + +SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"] + + +# ===== 盘中实时行情范围 (独立于盘后管道范围) ===== + + +def get_realtime_pull_stock() -> bool: + return load().get("realtime_pull_stock", True) + + +def get_realtime_pull_etf() -> bool: + # 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。 + return load().get("realtime_pull_etf", False) + + +def get_realtime_pull_index() -> bool: + return load().get("realtime_pull_index", True) + + +def get_realtime_index_mode() -> str: + mode = str(load().get("realtime_index_mode", "core") or "core").lower() + return mode if mode in {"core", "all"} else "core" + + +def get_realtime_index_symbols() -> list[str]: + stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT) + if isinstance(stored, str): + import re + stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()] + return [str(s) for s in stored if str(s).strip()] + + +def set_realtime_quote_scope(cfg: dict) -> dict: + updates = {} + for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"): + if key in cfg and cfg[key] is not None: + updates[key] = bool(cfg[key]) + if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}: + updates["realtime_index_mode"] = cfg["realtime_index_mode"] + if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None: + updates["realtime_index_symbols"] = cfg["realtime_index_symbols"] + if updates: + save(updates) + return get_realtime_quote_scope() + + +def get_realtime_quote_scope() -> dict: + return { + "realtime_pull_stock": get_realtime_pull_stock(), + "realtime_pull_etf": get_realtime_pull_etf(), + "realtime_pull_index": get_realtime_pull_index(), + "realtime_index_mode": get_realtime_index_mode(), + "realtime_index_symbols": get_realtime_index_symbols(), + } + + +def get_sse_refresh_pages() -> dict[str, bool]: + """返回每个页面的 SSE 刷新开关。""" + stored = load().get("sse_refresh_pages", {}) + # 合并默认值 (新增页面自动出现) + result = dict(SSE_REFRESH_PAGES_DEFAULT) + result.update(stored) + return result + + +def set_sse_refresh_pages(pages: dict[str, bool]) -> dict[str, bool]: + """保存页面 SSE 刷新配置。""" + save({"sse_refresh_pages": pages}) + return get_sse_refresh_pages() + + +def get_sidebar_index_symbols() -> list[str]: + """返回左侧菜单显示的指数代码。""" + stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT) + allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT) + return [s for s in stored if s in allowed] + + +def get_strategy_monitor_enabled() -> bool: + """策略告警评估总开关。""" + return load().get("strategy_monitor_enabled", False) + + +def get_system_notify_enabled() -> bool: + """系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。""" + return load().get("system_notify_enabled", False) + + +def set_system_notify_enabled(enabled: bool) -> bool: + """保存系统通知开关。""" + save({"system_notify_enabled": bool(enabled)}) + return bool(enabled) + + +def get_feishu_webhook_url() -> str: + """飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。""" + return load().get("feishu_webhook_url", "") + + +def get_feishu_webhook_secret() -> str: + """飞书自定义机器人签名密钥 — 机器人启用「签名校验」时必填, 留空表示不验签。""" + return load().get("feishu_webhook_secret", "") + + +def set_feishu_webhook_url(url: str) -> str: + """保存飞书 Webhook 地址。传入空串表示清空配置。""" + save({"feishu_webhook_url": str(url or "").strip()}) + return get_feishu_webhook_url() + + +def set_feishu_webhook_secret(secret: str) -> str: + """保存飞书签名密钥。传入空串表示不验签。""" + save({"feishu_webhook_secret": str(secret or "").strip()}) + return get_feishu_webhook_secret() + + +def get_webhook_enabled_default() -> bool: + """新建监控规则时是否默认勾选「飞书推送」。 + + 数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定。 + 此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。 + """ + return load().get("webhook_enabled_default", False) + + +def set_webhook_enabled_default(enabled: bool) -> bool: + """保存飞书推送默认勾选态。""" + save({"webhook_enabled_default": bool(enabled)}) + return get_webhook_enabled_default() + + +def get_screener_auto_run() -> bool: + """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。""" + return load().get("screener_auto_run", True) + + +def get_strategy_monitor_ids() -> list[str]: + """返回监控池中的策略 ID。""" + return load().get("strategy_monitor_ids", []) + + +def set_realtime_monitor_config(cfg: dict) -> dict: + """批量更新实时监控配置。""" + updates = {} + if "sse_refresh_pages" in cfg: + updates["sse_refresh_pages"] = cfg["sse_refresh_pages"] + if "strategy_monitor_enabled" in cfg: + updates["strategy_monitor_enabled"] = cfg["strategy_monitor_enabled"] + if "strategy_monitor_ids" in cfg: + updates["strategy_monitor_ids"] = cfg["strategy_monitor_ids"] + if "sidebar_index_symbols" in cfg: + allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT) + updates["sidebar_index_symbols"] = [s for s in cfg["sidebar_index_symbols"] if s in allowed] + if "screener_auto_run" in cfg: + updates["screener_auto_run"] = bool(cfg["screener_auto_run"]) + if updates: + save(updates) + return get_realtime_monitor_config() + + +def get_realtime_monitor_config() -> dict: + """返回完整的实时监控配置。""" + return { + "sse_refresh_pages": get_sse_refresh_pages(), + "strategy_monitor_enabled": get_strategy_monitor_enabled(), + "strategy_monitor_ids": get_strategy_monitor_ids(), + "sidebar_index_symbols": get_sidebar_index_symbols(), + "screener_auto_run": get_screener_auto_run(), + } + + +def get_nav_order() -> list[str]: + """返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。""" + return load().get("nav_order", []) + + +def set_nav_order(order: list[str]) -> list[str]: + """保存左侧菜单排序。""" + save({"nav_order": order}) + return get_nav_order() + + +def get_nav_hidden() -> list[str]: + """返回左侧菜单中隐藏的项 id 列表。""" + return load().get("nav_hidden", []) + + +def set_nav_hidden(hidden: list[str]) -> list[str]: + """保存左侧菜单隐藏项。""" + save({"nav_hidden": hidden}) + return get_nav_hidden() + + +def get_watchlist_columns() -> list[dict] | None: + """返回自选列表列配置。""" + return load().get("watchlist_columns") + + +def set_watchlist_columns(columns: list[dict]) -> list[dict]: + """保存自选列表列配置。""" + save({"watchlist_columns": columns}) + return columns + + +def get_screener_result_columns() -> list[dict] | None: + """返回策略结果列表列配置。""" + return load().get("screener_result_columns") + + +def set_screener_result_columns(columns: list[dict]) -> list[dict]: + """保存策略结果列表列配置。""" + save({"screener_result_columns": columns}) + return columns + + +# ===== 首次使用引导 ===== + +def get_onboarding_completed() -> bool: + """是否已完成首次使用向导。默认 False(新用户)。""" + return bool(load().get("onboarding_completed", False)) + + +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}) diff --git a/serve/backend/app/services/quote_service.py b/serve/backend/app/services/quote_service.py new file mode 100644 index 0000000..2948f9b --- /dev/null +++ b/serve/backend/app/services/quote_service.py @@ -0,0 +1,971 @@ +"""全局实时行情服务。 + +集中管理全市场行情拉取 + enriched 缓存,供盘中选股、自选股等所有模块复用。 + +架构: + - 后台线程轮询 TickFlow get_by_universes(["CN_Equity_A", "CN_Index"]) + - 拉取行情 → 写 kline_daily (不复权) + 增量计算 enriched → 写盘 + 更新缓存 + - _enriched_cache 是唯一的盘中数据源 (OHLCV + 全套技术指标) + - _live_agg_cache 是递推状态 (只加载一次, 盘中不变) + +数据流 (每轮 ~15s): + 1. API 拉取 → raw_records (临时变量) + 2. raw_records → 写 kline_daily (不复权原始价格) + 3. raw_records → 更新 _enriched_cache 的 OHLCV + 4. 增量计算 enriched 指标 (~50ms) + 5. 写 kline_daily_enriched + 替换 _enriched_cache + 6. 通知 SSE + +生命周期: + - 服务启动时读取 preferences,若 enabled 则自动启动线程 + - 运行中可通过 API 切换开关 + - 关闭时停止线程 +""" +from __future__ import annotations + +import logging +import threading +import time +from datetime import date, datetime, time as dt_time + +import polars as pl + +logger = logging.getLogger(__name__) + + +class QuoteService: + """全局实时行情服务 — 单例。""" + + CORE_INDEX_SYMBOLS = ("000001.SH", "399001.SZ", "399006.SZ", "000680.SH") + + # 档位 → 最小轮询间隔 (秒) + TIER_MIN_INTERVAL = { + "expert": 1.0, + "pro": 2.0, + "starter": 3.0, + "free": 6.0, + } + DEFAULT_INTERVAL = 10.0 + MAX_INTERVAL = 60.0 + + def __init__(self) -> None: + self._lock = threading.Lock() + self._running = False + self._enabled = False # 全局开关 (持久化到 preferences) + self._interval = self.DEFAULT_INTERVAL + self._thread: threading.Thread | None = None + self._repo = None # 延迟注入, 避免循环导入 + self._update_event = threading.Event() # SSE 通知: 行情更新后 set + self._alert_event = threading.Event() # SSE 通知: 有告警时 set + self._depth_update_event = threading.Event() # SSE 通知: depth 五档修正后 set (刷新连板梯队) + self._pending_alerts: list[dict] = [] # 待推送的告警 + self._max_pending_alerts: int = 1000 # 背压上限: 超出丢弃最旧 + # 复盘进度 SSE 通道: 定时复盘流式生成时, 把 meta/delta/done 事件推给开着页面的前端 + self._review_event = threading.Event() # SSE 通知: 有复盘进度事件时 set + self._pending_review: list[str] = [] # 待推送的复盘事件(JSON 字符串) + self._max_pending_review: int = 200 # 背压上限: 超出丢弃最旧 + self._strategy_monitor = None # 延迟注入 + self._app_state = None # 延迟注入 (FastAPI app.state) + + # 拉取元信息 (给 SSE / status 用) + self._fetch_time: float = 0.0 # perf_counter (用于计算 quote_age_ms) + self._fetch_ms: float = 0.0 # 拉取耗时 (毫秒) + self._fetched_at: float = 0.0 # 拉取完成的 Unix 时间戳 (毫秒) + self._symbol_count: int = 0 + self._index_symbol_count: int = 0 + self._etf_symbol_count: int = 0 + self._index_quotes_cache: pl.DataFrame | None = None + + # ================================================================ + # 生命周期 + # ================================================================ + + def start(self, interval: float = 0.0) -> None: + """启动后台行情轮询线程。""" + if self._running: + return + if interval <= 0: + from app.services import preferences + interval = preferences.get_realtime_quote_interval() + self._interval = self._clamp_interval(interval) + self._running = True + self._enabled = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + self._save_enabled(True) + logger.info("行情服务已启动, 轮询间隔 %.1fs", self._interval) + + def stop(self) -> None: + """停止后台行情轮询线程。""" + self._running = False + self._enabled = False + if self._thread: + self._thread.join(timeout=10) + self._thread = None + self._save_enabled(False) + logger.info("行情服务已停止") + + def enable(self) -> bool: + """开启自动行情 (不立即启动线程,等下一个交易时段)。 + + none 档无实时行情权限,拒绝开启并返回 False; + free 档开启自选股实时,starter+ 开启全市场实时。返回值表示是否真正开启。 + """ + if not self.is_realtime_allowed(): + logger.warning("实时行情开启被拒:当前档位(none)无实时行情权限") + return False + self._enabled = True + self._save_enabled(True) + if not self._running: + from app.services import preferences + self._interval = self._clamp_interval(preferences.get_realtime_quote_interval()) + self._running = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + logger.info("行情服务已启用, 轮询间隔 %.1fs", self._interval) + + def disable(self) -> None: + """关闭自动行情。""" + self.stop() + logger.info("行情服务已关闭") + + def boot_check(self) -> None: + """启动时检查 preferences,若 enabled 则自动启动。 + + none 档无实时行情权限:即使 preferences 标记为 enabled, + 也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)。 + """ + from app.services import preferences + if not self.is_realtime_allowed(): + if preferences.get_realtime_quotes_enabled(): + self._save_enabled(False) + logger.info("实时行情未启动:当前档位(none)无实时行情权限") + return + if preferences.get_realtime_quotes_enabled(): + self.start() + + def set_repo(self, repo) -> None: + """注入 KlineRepository, 用于实时落盘。""" + self._repo = repo + + def set_app_state(self, app_state) -> None: + """注入 FastAPI app.state, 用于获取 strategy_monitor 等单例。""" + self._app_state = app_state + + def set_interval(self, interval: float) -> float: + """运行时更新轮询间隔(立即生效)。""" + clamped = self._clamp_interval(interval) + self._interval = clamped + from app.services import preferences + preferences.set_realtime_quote_interval(clamped) + logger.info("轮询间隔已更新为 %.1fs", clamped) + return clamped + + def get_min_interval(self) -> float: + """返回当前档位允许的最小间隔。""" + return self._tier_min_interval() + + def wait_for_update(self, timeout: float = 30.0) -> bool: + """阻塞等待下一次行情更新 (供 SSE 线程使用)。""" + self._update_event.clear() + return self._update_event.wait(timeout=timeout) + + def wait_for_alert(self, timeout: float = 30.0) -> bool: + """阻塞等待告警 (供 SSE 线程使用)。""" + self._alert_event.clear() + return self._alert_event.wait(timeout=timeout) + + def notify_depth_updated(self) -> None: + """五档盘口修正完成后调用: 通知 SSE 推送 depth_updated, 触发连板梯队刷新。 + + 与行情/告警通道独立 — 只刷新连板梯队, 不连带刷新 watchlist 等。 + """ + self._depth_update_event.set() + + def wait_for_depth_update(self, timeout: float = 30.0) -> bool: + """阻塞等待 depth 修正 (供 SSE 线程使用)。""" + self._depth_update_event.clear() + return self._depth_update_event.wait(timeout=timeout) + + def pop_alerts(self) -> list[dict]: + """取走所有待推送的告警 (线程安全)。""" + with self._lock: + alerts = self._pending_alerts + self._pending_alerts = [] + return alerts + + # ================================================================ + # 复盘进度 SSE 通道 — 定时复盘流式生成时, 把事件实时推给前端 + # ================================================================ + def push_review_event(self, event_json: str) -> None: + """追加一条复盘进度事件(JSON 字符串), 并唤醒 SSE generator。 + + 事件格式与 recap_market_stream 的产出一致(meta/delta/error/done), + 前端 reviewStore 直接消费。背压: 超过上限丢弃最旧(复盘流几百条 delta, 200 够用)。 + """ + with self._lock: + self._pending_review.append(event_json) + if len(self._pending_review) > self._max_pending_review: + overflow = len(self._pending_review) - self._max_pending_review + self._pending_review = self._pending_review[overflow:] + self._review_event.set() + + def wait_for_review(self, timeout: float = 30.0) -> bool: + """阻塞等待复盘进度事件 (供 SSE 线程使用)。""" + self._review_event.clear() + return self._review_event.wait(timeout=timeout) + + def pop_review_events(self) -> list[str]: + """取走所有待推送的复盘事件 (线程安全)。""" + with self._lock: + events = self._pending_review + self._pending_review = [] + return events + + # ================================================================ + # 档位感知间隔限制 + # ================================================================ + + @staticmethod + def _current_tier() -> str: + """获取当前档位名(小写)。""" + from app.tickflow.policy import tier_label + return tier_label().split()[0].split("+")[0].strip().lower() + + @classmethod + def realtime_mode(cls) -> str: + """当前实时行情模式: none / watchlist / full_market。""" + tier = cls._current_tier() + if tier == "none": + return "none" + if tier == "free": + return "watchlist" + return "full_market" + + @classmethod + def is_realtime_allowed(cls) -> bool: + """当前档位是否允许使用实时行情。""" + return cls.realtime_mode() != "none" + + @classmethod + def _tier_min_interval(cls) -> float: + tier = cls._current_tier() + return cls.TIER_MIN_INTERVAL.get(tier, cls.DEFAULT_INTERVAL) + + def _clamp_interval(self, interval: float) -> float: + return max(self._tier_min_interval(), min(self.MAX_INTERVAL, interval)) + + # ================================================================ + # 行情数据访问 + # ================================================================ + + def get_enriched_today(self) -> tuple[pl.DataFrame, date | None]: + """返回今天 enriched 数据 + 日期 (线程安全)。 + + 所有页面统一通过此方法获取实时行情 + 技术指标。 + """ + if not self._repo: + return pl.DataFrame(), None + return self._repo.get_enriched_latest() + + def get_quotes_compat(self) -> pl.DataFrame: + """兼容接口: 返回行情 DataFrame (用于盘中选股等需要 last_price/prev_close 的场景)。 + + 从 _enriched_cache 取 today 的数据, 只选行情基础列, 补上 last_price 别名。 + 不返回指标列, 避免 JOIN live_agg 时列名冲突。 + """ + df, _ = self.get_enriched_today() + if df.is_empty(): + return df + + # 只取盘中选股需要的行情基础列 + keep = [c for c in [ + "symbol", "close", "open", "high", "low", "volume", "amount", + "prev_close", "change_pct", "change_amount", "amplitude", "turnover_rate", + ] if c in df.columns] + df = df.select(keep) + + # enriched 的 close 等价于 last_price + if "close" in df.columns and "last_price" not in df.columns: + df = df.with_columns(pl.col("close").alias("last_price")) + return df + + def get_index_quotes(self, symbols: list[str] | None = None) -> pl.DataFrame: + """返回实时指数行情缓存。不会触发 TickFlow 请求。""" + with self._lock: + df = self._index_quotes_cache.clone() if self._index_quotes_cache is not None else pl.DataFrame() + if df.is_empty(): + return df + if symbols: + return df.filter(pl.col("symbol").is_in(symbols)) + return df + + def status(self) -> dict: + """返回行情服务状态。""" + from app.services import preferences + age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1 + mode = self.realtime_mode() + return { + "enabled": self._enabled, + "running": self._running, + "mode": mode, + "realtime_allowed": mode != "none", + "watchlist_symbol_count": len(preferences.get_realtime_watchlist_symbols()), + "interval_s": self._interval, + "symbol_count": self._symbol_count, + "index_symbol_count": self._index_symbol_count, + "etf_symbol_count": self._etf_symbol_count, + "quote_age_ms": round(age, 0) if age >= 0 else None, + "is_trading_hours": self._is_trading_hours(), + "last_fetch_ms": round(self._fetched_at, 0) if self._fetched_at else None, + } + + def refresh(self) -> dict: + """手动触发一次行情拉取。""" + self._fetch_quotes() + return self.status() + + # ================================================================ + # 后台轮询 + # ================================================================ + + def _poll_loop(self) -> None: + while self._running and self._enabled: + try: + if self._is_trading_hours(): + self._fetch_quotes() + else: + logger.debug("非交易时段, 跳过行情轮询") + except Exception as e: # noqa: BLE001 + logger.warning("行情轮询异常: %s", e) + + waited = 0.0 + while self._running and self._enabled and waited < self._interval: + time.sleep(0.5) + waited += 0.5 + + def _fetch_quotes(self) -> None: + """按当前档位拉取行情。""" + if self.realtime_mode() == "watchlist": + self._fetch_watchlist_quotes() + return + self._fetch_full_market_quotes() + + def _fetch_full_market_quotes(self) -> None: + """拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。""" + from app.tickflow.client import get_paid_realtime_client + + tf = get_paid_realtime_client() + if tf is None: + logger.warning("实时行情拉取失败:未配置付费服务器 API Key") + return + t0 = time.perf_counter() + now_ts = time.perf_counter() + + try: + from app.services import preferences + all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set() + core_index_symbols = set(preferences.get_realtime_index_symbols() or self.CORE_INDEX_SYMBOLS) + all_index_symbols.update(core_index_symbols) + all_etf_symbols = set() + if self._repo: + etf_inst = self._repo.get_etf_instruments() + if not etf_inst.is_empty() and "symbol" in etf_inst.columns: + all_etf_symbols = set(etf_inst["symbol"].cast(pl.Utf8).to_list()) + + universes: list[str] = [] + if preferences.get_realtime_pull_stock(): + universes.append("CN_Equity_A") + if preferences.get_realtime_pull_etf() and all_etf_symbols: + universes.append("CN_ETF") + if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "all": + universes.append("CN_Index") + + resp = [] + if universes: + resp.extend(tf.quotes.get_by_universes(universes=universes) or []) + if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core": + resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or []) + except Exception as e: # noqa: BLE001 + logger.warning("行情拉取失败: %s", e) + return + + if not resp: + logger.warning("行情数据为空") + return + + # ---- 解析 API 响应 (临时变量, 用完丢弃) ---- + records = [] + for q in resp: + ext = q.get("ext") or {} + last_price = q.get("last_price") + prev_close = q.get("prev_close") + change_amount = ext.get("change_amount") + change_pct = ext.get("change_pct") + if change_amount is None and last_price is not None and prev_close is not None: + change_amount = float(last_price) - float(prev_close) + if change_pct is None and change_amount is not None and prev_close not in (None, 0): + change_pct = float(change_amount) / float(prev_close) * 100 + records.append({ + "symbol": q.get("symbol"), + "name": q.get("name") or ext.get("name"), + "last_price": last_price, + "prev_close": prev_close, + "open": q.get("open"), + "high": q.get("high"), + "low": q.get("low"), + "volume": q.get("volume"), + "amount": q.get("amount"), + "change_pct": change_pct, + "change_amount": change_amount, + "amplitude": ext.get("amplitude"), + "turnover_rate": ext.get("turnover_rate"), + "timestamp": q.get("timestamp"), + "session": q.get("session"), + }) + + index_records = [r for r in records if r.get("symbol") in all_index_symbols] + etf_records = [r for r in records if r.get("symbol") in all_etf_symbols] + stock_records = [ + r for r in records + if r.get("symbol") not in all_index_symbols and r.get("symbol") not in all_etf_symbols + ] + + fetch_ms = (time.perf_counter() - t0) * 1000 + fetched_at = time.time() * 1000 + + # ---- 更新元信息 ---- + with self._lock: + self._fetch_time = now_ts + self._fetch_ms = fetch_ms + self._fetched_at = fetched_at + self._symbol_count = len(stock_records) + self._index_symbol_count = len(index_records) + self._etf_symbol_count = len(etf_records) + self._index_quotes_cache = self._build_index_quotes(index_records) + + logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms) + + # ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ---- + daily_df = self._build_daily(stock_records) + if not daily_df.is_empty() and self._repo: + try: + self._repo.flush_live_daily(daily_df) + except Exception as e: # noqa: BLE001 + logger.warning("日K写盘失败: %s", e) + + etf_daily_df = self._build_daily(etf_records) + if not etf_daily_df.is_empty() and self._repo: + try: + self._repo.flush_live_daily_asset("etf", etf_daily_df) + except Exception as e: # noqa: BLE001 + logger.warning("ETF 日K写盘失败: %s", e) + + # ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ---- + quote_extra = self._build_quote_extra(stock_records) + etf_quote_extra = self._build_quote_extra(etf_records) + + # ---- 增量计算 enriched + 写盘 + 更新缓存 ---- + if not daily_df.is_empty() and self._repo: + self._flush_live_enriched(daily_df, quote_extra, asset_type="stock") + if not etf_daily_df.is_empty() and self._repo: + self._flush_live_enriched(etf_daily_df, etf_quote_extra, asset_type="etf") + + # ---- 通知 SSE ---- + self._update_event.set() + + # ---- 策略监控 + 告警评估 ---- + self._evaluate_monitors(daily_df, quote_extra) + + def _fetch_watchlist_quotes(self) -> None: + """Free 档自选股实时: 只拉取最多 5 个 symbols。""" + from app.services import preferences + from app.tickflow.client import get_paid_realtime_client + + symbols = preferences.get_realtime_watchlist_symbols() + if not symbols: + logger.info("自选实时未配置标的, 跳过行情拉取") + return + + tf = get_paid_realtime_client() + if tf is None: + logger.warning("自选实时拉取失败:未配置付费服务器 API Key") + return + + t0 = time.perf_counter() + now_ts = time.perf_counter() + try: + resp = tf.quotes.get(symbols=symbols) or [] + except Exception as e: # noqa: BLE001 + logger.warning("自选实时拉取失败: %s", e) + return + + if not resp: + logger.warning("自选实时行情数据为空") + return + + records = [] + for q in resp: + ext = q.get("ext") or {} + last_price = q.get("last_price") + prev_close = q.get("prev_close") + change_amount = ext.get("change_amount") + change_pct = ext.get("change_pct") + if change_amount is None and last_price is not None and prev_close is not None: + change_amount = float(last_price) - float(prev_close) + if change_pct is None and change_amount is not None and prev_close not in (None, 0): + change_pct = float(change_amount) / float(prev_close) * 100 + records.append({ + "symbol": q.get("symbol"), + "name": q.get("name") or ext.get("name"), + "last_price": last_price, + "prev_close": prev_close, + "open": q.get("open"), + "high": q.get("high"), + "low": q.get("low"), + "volume": q.get("volume"), + "amount": q.get("amount"), + "change_pct": change_pct, + "change_amount": change_amount, + "amplitude": ext.get("amplitude"), + "turnover_rate": ext.get("turnover_rate"), + "timestamp": q.get("timestamp"), + "session": q.get("session"), + }) + + fetch_ms = (time.perf_counter() - t0) * 1000 + fetched_at = time.time() * 1000 + with self._lock: + self._fetch_time = now_ts + self._fetch_ms = fetch_ms + self._fetched_at = fetched_at + self._symbol_count = len(records) + self._index_symbol_count = 0 + self._etf_symbol_count = 0 + self._index_quotes_cache = None + + logger.info("自选实时刷新: %d 只股票, 耗时 %.0fms", len(records), fetch_ms) + + daily_df = self._build_daily(records) + quote_extra = self._build_quote_extra(records) + if not daily_df.is_empty() and self._repo: + try: + self._repo.merge_live_daily_asset("stock", daily_df) + except Exception as e: # noqa: BLE001 + logger.warning("自选实时日K写盘失败: %s", e) + self._flush_live_enriched(daily_df, quote_extra, asset_type="stock", merge=True) + + self._update_event.set() + self._evaluate_monitors(daily_df, quote_extra) + + # ================================================================ + # 工具 + # ================================================================ + + @staticmethod + def _build_daily(records: list[dict]) -> pl.DataFrame: + """将 API records 转为日K格式 DataFrame (只有 OHLCV, 写 kline_daily 用)。""" + if not records: + return pl.DataFrame() + df = pl.DataFrame(records) + cols_map = { + "symbol": "symbol", + "last_price": "close", + "open": "open", + "high": "high", + "low": "low", + "volume": "volume", + "amount": "amount", + } + select_exprs = [] + for src, dst in cols_map.items(): + if src in df.columns: + select_exprs.append(pl.col(src).alias(dst)) + if not select_exprs: + return pl.DataFrame() + result = df.select(select_exprs).with_columns( + pl.lit(date.today()).cast(pl.Date).alias("date"), + ) + # 修复: API 在非交易时段可能返回 open/high/low=0 或 null, + # 导致蜡烛从 0 开始。用 close 填充这些异常值。 + for col in ("open", "high", "low"): + if col in result.columns: + result = result.with_columns( + pl.when((pl.col(col) == 0) | pl.col(col).is_null()) + .then(pl.col("close")) + .otherwise(pl.col(col)) + .alias(col) + ) + return result + + @staticmethod + def _build_quote_extra(records: list[dict]) -> pl.DataFrame: + """构建 API 直接提供的补充字段 (不写 daily, 只传给 enriched 计算)。 + + 包含: prev_close, change_pct, change_amount, amplitude, turnover_rate。 + """ + if not records: + return pl.DataFrame() + df = pl.DataFrame(records) + keep = [c for c in [ + "symbol", "prev_close", "change_pct", "change_amount", + "amplitude", "turnover_rate", + ] if c in df.columns] + if not keep or "symbol" not in keep: + return pl.DataFrame() + return df.select(keep) + + @staticmethod + def _build_index_quotes(records: list[dict]) -> pl.DataFrame: + """构建指数实时行情缓存,不落股票 parquet。 + + 注意: API 返回的 change_pct/amplitude 是小数 (0.0366 = 3.66%), + 统一转成百分比输出, 与 _fallback_index_quotes_from_daily 口径一致 + (前端指数侧不×100, 直接 toFixed(2)% 展示)。 + """ + if not records: + return pl.DataFrame() + df = pl.DataFrame(records) + keep = [c for c in [ + "symbol", "name", "last_price", "prev_close", "open", "high", "low", + "volume", "amount", "change_pct", "change_amount", "amplitude", "timestamp", "session", + ] if c in df.columns] + if not keep or "symbol" not in keep: + return pl.DataFrame() + df = df.select(keep) + # change_pct / amplitude: 小数 → 百分比 (统一指数展示口径) + for col in ("change_pct", "amplitude"): + if col in df.columns: + df = df.with_columns((pl.col(col).cast(pl.Float64) * 100).alias(col)) + if "last_price" in df.columns and "close" not in df.columns: + df = df.with_columns(pl.col("last_price").alias("close")) + return df + + @staticmethod + def _is_trading_hours() -> bool: + now = datetime.now() + t = now.time() + morning = dt_time(9, 15) <= t <= dt_time(11, 35) + afternoon = dt_time(12, 55) <= t <= dt_time(15, 5) + return now.weekday() < 5 and (morning or afternoon) + + @staticmethod + def _save_enabled(enabled: bool) -> None: + from app.services import preferences + preferences.save({"realtime_quotes_enabled": enabled}) + + # ================================================================ + # 策略监控 + # ================================================================ + + def _evaluate_monitors(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame | None) -> None: + """行情更新后评估统一监控规则引擎,并刷新策略结果缓存。""" + try: + # 获取 enriched 数据 (刚算好的) + enriched_today, enriched_date = self.get_enriched_today() + if enriched_today.is_empty(): + return + + all_alerts: list[dict] = [] + rule_events: list[dict] = [] + engine = None + + # 通用监控规则评估 (统一引擎: signal/price/market/strategy) + if self._app_state: + engine = getattr(self._app_state, "monitor_engine", None) + if engine and engine.rule_count > 0: + # 预构建 symbol → name 映射 (enriched 已 drop name 列, 引擎触发时回填用) + try: + inst_df = self._app_state.repo.get_instruments() + if not inst_df.is_empty() and "symbol" in inst_df.columns and "name" in inst_df.columns: + engine.set_name_map({ + row["symbol"]: row["name"] + for row in inst_df.select(["symbol", "name"]).iter_rows(named=True) + if row.get("name") + }) + except Exception as e: # noqa: BLE001 + logger.debug("name_map 构建失败 (不影响监控): %s", e) + # 连板梯队封单监控: 有 ladder 规则时, 从 depth_service 注入封单量到 enriched + eval_df = enriched_today + if engine.has_rule_type("ladder"): + eval_df = self._inject_sealed_vol(enriched_today, enriched_date) + rule_events = engine.evaluate(eval_df) + if rule_events: + # 落盘到 alerts.jsonl + try: + from app.services import alert_store + alert_store.append_many( + self._app_state.repo.store.data_dir, rule_events, + ) + except Exception as e: # noqa: BLE001 + logger.warning("告警落盘失败: %s", e) + # 转为 SSE 推送格式 (兼容旧 alert schema) + for ev in rule_events: + all_alerts.append({ + "source": ev["source"], + "type": ev["type"], + "rule_id": ev.get("rule_id"), + "strategy_id": ev.get("rule_id") if ev["source"] == "strategy" else None, + "symbol": ev["symbol"], + "name": ev["name"], + "message": ev["message"], + "price": ev["price"], + "change_pct": ev["change_pct"], + "signals": ev["signals"], + "severity": ev.get("severity", "info"), + "conditions": ev.get("conditions") or [], + "logic": ev.get("logic") or "and", + }) + + # 策略页实时回显: 不写文件 (实时行情每轮更新 enriched, 写文件会被 read_cache + # 的 mtime 校验判过期, 反复读不到)。监控引擎本轮已算出的结果存在内存 + # (latest_strategy_results), 由 /api/screener/cached 端点直接叠加读取。 + + # 推入待推送队列 + 通知 SSE (含背压保护) + if all_alerts: + with self._lock: + self._pending_alerts.extend(all_alerts) + # 背压: 超出上限丢弃最旧 + if len(self._pending_alerts) > self._max_pending_alerts: + overflow = len(self._pending_alerts) - self._max_pending_alerts + self._pending_alerts = self._pending_alerts[overflow:] + self._alert_event.set() + logger.info("监控评估完成: %d 条通知", len(all_alerts)) + + # 系统通知 (可选通道, 由 preferences 开关控制)。 + # cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。 + self._maybe_send_system_notifications(all_alerts) + + # Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。 + # 紧随系统通知, 同样静默降级不阻断主流程。 + if rule_events: + self._maybe_send_webhook(rule_events, engine) + + except Exception as e: # noqa: BLE001 + logger.warning("监控评估失败: %s", e) + + def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame: + """从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。 + + 涨停封单(买一量) + 跌停封单(卖一量)合并, 供 ladder 规则评估。 + depth 未就绪时返回原 df (不注入, ladder 规则安全降级不触发)。 + """ + try: + depth_svc = getattr(self._app_state, "depth_service", None) + if not depth_svc: + return enriched_today + # enriched_date 可能是 date 或字符串, 统一为 date + from datetime import date as date_cls + target_date = enriched_date if isinstance(enriched_date, date_cls) else date_cls.fromisoformat(str(enriched_date)) + # 取涨停 + 跌停封单, 合并 {symbol: vol} + up_map = depth_svc.get_sealed_map(target_date, is_down=False) + down_map = depth_svc.get_sealed_map(target_date, is_down=True) + sealed: dict[str, int] = {} + for m in (up_map, down_map): + for sym, info in m.items(): + vol = (info or {}).get("vol") + if vol and vol > 0: + sealed[sym] = vol # 后者覆盖前者 (同 symbol 不可能在涨跌停都封单) + if not sealed: + return enriched_today + # 构造 (symbol, _sealed_vol) DataFrame, join 到 enriched 副本 + sealed_df = pl.DataFrame({ + "symbol": list(sealed.keys()), + "_sealed_vol": list(sealed.values()), + }) + # 若已有残留列先移除 (避免重复 join 报错) + df = enriched_today.drop("_sealed_vol") if "_sealed_vol" in enriched_today.columns else enriched_today + return df.join(sealed_df, on="symbol", how="left") + except Exception as e: # noqa: BLE001 + logger.debug("封单注入失败 (ladder 规则将不触发): %s", e) + return enriched_today + + def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None: + """把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。 + + - 全局飞书 URL 未配置: 直接返回 + - 仅推送 webhook_enabled=True 的规则触发的告警 + - 失败静默, 不阻断主流程 + - 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重 + + 注意: 用 rule_events (含 rule_id) 而非重建后的 all_alerts, + 以便反查引擎规则判断是否启用推送。 + """ + try: + from app.services import preferences + from app.services import webhook_adapter + + url = preferences.get_feishu_webhook_url() + if not url: + return + secret = preferences.get_feishu_webhook_secret() + + # 反查规则, 过滤出启用推送的事件 + source_labels = { + "strategy": "策略", "signal": "信号", + "price": "价格", "market": "异动", + } + rules = engine.rules if engine is not None else {} + pushed = 0 + for ev in rule_events: + rule = rules.get(ev.get("rule_id")) + if not rule or not rule.get("webhook_enabled"): + continue + source = ev.get("source", "") + source_label = source_labels.get(source, source or "通知") + symbol = ev.get("symbol") or "" + name = ev.get("name") or "" + message = ev.get("message") or "" + title = f"TickFlow · {source_label}" + body = f"{symbol} {name} {message}".strip() if symbol else (message or name) + if webhook_adapter.send_feishu(url, title, body, secret): + pushed += 1 + if pushed: + logger.info("飞书 Webhook 推送: %d 条", pushed) + except Exception as e: # noqa: BLE001 + logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e) + + def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None: + """把告警转发到操作系统通知中心 (由 preferences 开关控制)。 + + - 开关关闭: 直接返回 + - 开关开启: 逐条发系统通知; 失败静默, 不阻断主流程 + - 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重 + - 批量策略事件 (symbol="") 聚合为一条通知, 避免刷屏 + """ + try: + from app.services import preferences + from app.services import notify_adapter + + if not preferences.get_system_notify_enabled(): + return + + for ev in all_alerts: + # 通知标题: 用 source 分类 (策略/信号/价格/异动) + source = ev.get("source", "") + source_label = { + "strategy": "策略", "signal": "信号", + "price": "价格", "market": "异动", + }.get(source, source or "通知") + + name = ev.get("name") or "" + symbol = ev.get("symbol") or "" + message = ev.get("message") or "" + + # 正文: 优先用现成 message, 拼上 symbol/name 让用户一眼定位 + if symbol: + body = f"{symbol} {name} {message}".strip() + else: + body = message or name + + title = f"TickFlow · {source_label}" + notify_adapter.notify(title, body) + except Exception as e: # noqa: BLE001 + logger.debug("系统通知发送异常 (不影响告警主流程): %s", e) + + @staticmethod + def _get_strategy_monitor(): + """获取 StrategyMonitorService — 不再使用, 改用 _app_state 注入。""" + return None + + # ================================================================ + # enriched 增量计算 + # ================================================================ + + def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None, asset_type: str = "stock", merge: bool = False) -> None: + """增量计算今天的 enriched: 用昨天的递推状态 + 今天 OHLCV → 只算今天 5500 行。 + + quote_extra: API 直接提供的补充字段 (prev_close, change_pct 等), + 不写 daily, 直接传给 compute_enriched_today 避免重复计算。 + """ + try: + today = date.today() + t0 = time.perf_counter() + + # ---- 尝试增量路径 ---- + live_agg = self._repo.get_live_agg() if asset_type == "stock" else pl.DataFrame() + prev_enriched, prev_date = ( + self._repo.get_enriched_latest() + if asset_type == "stock" + else self._repo.get_enriched_latest_asset(asset_type) + ) + + use_incremental = ( + asset_type == "stock" + and not live_agg.is_empty() + and not prev_enriched.is_empty() + and prev_date is not None + ) + + if use_incremental: + from app.indicators.pipeline import compute_enriched_today + instruments = self._repo.get_instruments() + # 将 API 直接提供的补充字段 JOIN 到 daily_df + today_ohlcv = daily_df + if quote_extra is not None and not quote_extra.is_empty(): + today_ohlcv = daily_df.join(quote_extra, on="symbol", how="left") + enriched_today = compute_enriched_today( + live_agg=live_agg, + prev_enriched=prev_enriched, + today_ohlcv=today_ohlcv, + instruments=instruments, + ) + if enriched_today.is_empty(): + logger.warning("增量计算结果为空, 回退到全量计算") + use_incremental = False + + # ---- 全量回退路径 ---- + if not use_incremental: + from datetime import timedelta + from app.indicators.pipeline import compute_enriched + + logger.info("enriched 全量计算 (live_agg=%s, 上次日期=%s)", + "ok" if not live_agg.is_empty() else "空", prev_date) + + cutoff = today - timedelta(days=90) + table = "kline_etf_daily" if asset_type == "etf" else "kline_daily" + daily_glob = str(self._repo.store.data_dir / table / "**" / "*.parquet") + ohlcv_cols = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"] + hist_df = ( + pl.scan_parquet(daily_glob) + .filter(pl.col("date") >= cutoff) + .sort(["symbol", "date"]) + .collect() + ) + if hist_df.is_empty(): + return + + hist_cols = [c for c in ohlcv_cols if c in hist_df.columns] + hist_df = hist_df.select(hist_cols).filter(pl.col("date") != today) + daily_ohlcv = daily_df.select([c for c in ohlcv_cols if c in daily_df.columns]) + full_df = pl.concat([hist_df, daily_ohlcv], how="diagonal_relaxed") + full_df = full_df.sort(["symbol", "date"]) + + factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor" + factor_path = self._repo.store.data_dir / factor_dir / "all.parquet" + factors = pl.DataFrame() + if factor_path.exists(): + try: + factors = pl.read_parquet(factor_path) + except Exception: + pass + instruments = self._repo.get_instruments() if asset_type == "stock" else None + + enriched_full = compute_enriched(full_df, factors=factors, instruments=instruments) + enriched_today = enriched_full.filter(pl.col("date") == today) + + if enriched_today.is_empty(): + return + + # ---- 写盘 + 更新缓存 ---- + if merge: + self._repo.merge_live_enriched_asset(asset_type, enriched_today) + else: + self._repo.flush_live_enriched_asset(asset_type, enriched_today) + + elapsed = time.perf_counter() - t0 + mode_label = "增量" if use_incremental else "全量" + logger.info("enriched %s: %d 只, %s, 耗时 %.0fms", + mode_label, len(enriched_today), today, elapsed * 1000) + except Exception as e: # noqa: BLE001 + logger.warning("enriched 计算失败: %s", e) diff --git a/serve/backend/app/services/rps_rotation.py b/serve/backend/app/services/rps_rotation.py new file mode 100644 index 0000000..95921e8 --- /dev/null +++ b/serve/backend/app/services/rps_rotation.py @@ -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"], + } diff --git a/serve/backend/app/services/screener.py b/serve/backend/app/services/screener.py new file mode 100644 index 0000000..b4c46b0 --- /dev/null +++ b/serve/backend/app/services/screener.py @@ -0,0 +1,593 @@ +"""Screener 服务(§6.3)。 + +性能优化: + - enriched parquet 仅存 14 列基础数据, 指标和信号即时计算 + - preset 策略: 从内存缓存或即时计算获取完整指标, ~10-50ms + - custom SQL: DuckDB (用户传 SQL WHERE 字符串), ~10-50ms +""" +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from datetime import date, timedelta + +import polars as pl + +from app.tickflow.repository import KlineRepository + +logger = logging.getLogger(__name__) + +# ── 进程级历史数据缓存 (避免 run_all 每次重新扫描 parquet + 计算指标) ── +_history_cache: dict[tuple[date, int], tuple[float, pl.DataFrame]] = {} +_HISTORY_CACHE_TTL = 120.0 # 秒 + + +# 内置预设策略 — Polars 表达式方式 +PRESET_STRATEGIES: dict[str, dict] = { + "trend_breakout": { + "name": "趋势突破", + "description": "MA60 上方 + 60 日新高 + 量能 ≥ 2 倍均量", + "filter": ( + (pl.col("close") > pl.col("ma60")) + & pl.col("signal_n_day_high").fill_null(False) + & (pl.col("vol_ratio_5d") >= 2.0) + ), + "order_by": "momentum_60d", + "descending": True, + "limit": 100, + }, + "ma_golden_cross": { + "name": "MA 金叉", + "description": "MA5 上穿 MA20 当日触发,量能配合", + "filter": ( + pl.col("signal_ma_golden_5_20").fill_null(False) + & (pl.col("vol_ratio_5d") >= 1.2) + & (pl.col("close") > pl.col("ma60")) + ), + "order_by": "momentum_20d", + "descending": True, + "limit": 100, + }, + "macd_golden": { + "name": "MACD 金叉放量", + "description": "MACD 金叉当日 + 量能放大", + "filter": ( + pl.col("signal_macd_golden").fill_null(False) + & (pl.col("vol_ratio_5d") >= 1.5) + ), + "order_by": "momentum_60d", + "descending": True, + "limit": 100, + }, + "volume_price_surge": { + "name": "量价齐升", + "description": "突破 MA20 + 放量 + 收阳", + "filter": ( + pl.col("signal_ma20_breakout").fill_null(False) + & (pl.col("vol_ratio_5d") >= 2.0) + & (pl.col("close") > pl.col("open")) + ), + "order_by": "vol_ratio_5d", + "descending": True, + "limit": 100, + }, + "low_volatility_leader": { + "name": "低波动龙头", + "description": "20 日动量为正 + 年化波动 < 30% + MA20 上方", + "filter": ( + (pl.col("momentum_20d") > 0) + & (pl.col("annual_vol_20d") < 0.30) + & (pl.col("close") > pl.col("ma20")) + ), + "order_by": "momentum_60d", + "descending": True, + "limit": 100, + }, + "broken_board_recovery": { + "name": "断板反包", + "description": "连板 ≥2 后断板 1-2 天,出现放量反包信号", + "filter": ( + pl.col("signal_limit_up").fill_null(False) + & (pl.col("vol_ratio_5d") >= 1.5) + & (pl.col("change_pct") > 0.03) + ), + "order_by": "change_pct", + "descending": True, + "limit": 100, + }, + "oversold_bounce": { + "name": "超跌反弹", + "description": "RSI14 < 30 超卖区 + 当日收阳 + 放量,抄底信号", + "filter": ( + (pl.col("rsi_14") < 30) + & (pl.col("close") > pl.col("open")) + & (pl.col("vol_ratio_5d") >= 1.2) + ), + "order_by": "rsi_14", + "descending": False, + "limit": 100, + }, + "boll_breakout": { + "name": "布林突破", + "description": "突破布林上轨 + 放量,强势加速信号", + "filter": ( + pl.col("signal_boll_breakout_upper").fill_null(False) + & (pl.col("vol_ratio_5d") >= 1.5) + ), + "order_by": "vol_ratio_5d", + "descending": True, + "limit": 100, + }, + "bullish_alignment": { + "name": "均线多头", + "description": "MA5 > MA10 > MA20 > MA60 多头排列 + 短期动量为正", + "filter": ( + (pl.col("ma5") > pl.col("ma10")) + & (pl.col("ma10") > pl.col("ma20")) + & (pl.col("ma20") > pl.col("ma60")) + & (pl.col("momentum_20d") > 0) + ), + "order_by": "momentum_60d", + "descending": True, + "limit": 100, + }, + "consecutive_limit_ups": { + "name": "连板股", + "description": "当日涨停且连续涨停 ≥ 2 天,强势追涨", + "filter": ( + pl.col("signal_limit_up").fill_null(False) + & (pl.col("consecutive_limit_ups") >= 2) + ), + "order_by": "consecutive_limit_ups", + "descending": True, + "limit": 100, + }, + "pullback_to_support": { + "name": "缩量回踩", + "description": "回踩 MA20 附近 + 缩量 + 中期趋势向上", + "filter": ( + (pl.col("close") > pl.col("ma20") * 0.98) + & (pl.col("close") < pl.col("ma20") * 1.02) + & (pl.col("vol_ratio_5d") < 0.8) + & (pl.col("close") > pl.col("ma60")) + & (pl.col("momentum_20d") > 0) + ), + "order_by": "momentum_60d", + "descending": True, + "limit": 100, + }, + "n_day_low_reversal": { + "name": "新低反转", + "description": "触及 60 日新低后当日收阳放量,反转信号", + "filter": ( + pl.col("signal_n_day_low").fill_null(False) + & (pl.col("close") > pl.col("open")) + & (pl.col("vol_ratio_5d") >= 1.5) + ), + "order_by": "change_pct", + "descending": True, + "limit": 100, + }, +} + + +@dataclass +class ScreenerResult: + as_of: date + strategy: str | None + rows: list[dict] = field(default_factory=list) + total: int = 0 + elapsed_ms: float = 0.0 + + +class ScreenerService: + def __init__(self, repo: KlineRepository) -> None: + self.repo = repo + + @staticmethod + def clear_history_cache() -> None: + """清空进程级 _history_cache (TTL 缓存)。 + + 清除数据后调用, 避免内存里的旧历史窗口残留导致策略/看板仍命中旧数据。 + """ + _history_cache.clear() + + def _load_enriched_for_date(self, target_date: date) -> pl.DataFrame: + """从 enriched parquet 读取指定日期的基础数据并即时计算完整指标+信号。 + + enriched parquet 仅存 14 列。读取后需要即时计算 ma/ema/macd/kdj/rsi/boll/momentum/signal 等列。 + 对于最新日, 优先使用内存缓存 (已包含完整指标)。 + """ + # 优先使用 repo 最新日缓存 + cache, cache_date = self.repo.get_enriched_latest() + if cache is not None and not cache.is_empty() and cache_date == target_date: + df = cache + # JOIN instruments + df_i = self.repo.get_instruments() + if not df_i.is_empty(): + inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in df_i.columns] + if "name" not in df.columns: + df = df.join(df_i.select(inst_cols), on="symbol", how="left") + return df + + # 尝试从 repo 级预计算历史缓存中提取目标日期 + cached_hist = self.repo.get_enriched_history(target_date, 1) + if cached_hist is not None and not cached_hist.is_empty() and "date" in cached_hist.columns: + df = cached_hist.filter(pl.col("date") == target_date) + if not df.is_empty(): + logger.debug("_load_enriched_for_date: repo history cache for %s", target_date) + # JOIN instruments + df_i = self.repo.get_instruments() + if not df_i.is_empty(): + inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in df_i.columns] + if "name" not in df.columns: + df = df.join(df_i.select(inst_cols), on="symbol", how="left") + return df + + # 历史日期: 从 parquet 读取 14 列, 即时计算指标 (慢路径) + enriched_dir = self.repo.store.data_dir / "kline_daily_enriched" + ds = target_date.isoformat() + target_parquet = enriched_dir / f"date={ds}" / "part.parquet" + + if not target_parquet.exists(): + return pl.DataFrame() + + try: + df = pl.read_parquet(target_parquet) + except Exception as e: # noqa: BLE001 + logger.warning("load_enriched_for_date failed: %s", e) + return pl.DataFrame() + + if df.is_empty(): + return df + + # 即时计算指标: 需要加载历史窗口作 warmup + df_full = self._compute_enriched_full(df, target_date) + return df_full + + def _compute_enriched_full(self, df_target: pl.DataFrame, target_date: date) -> pl.DataFrame: + """从 14 列基础数据即时计算完整 enriched (含全部指标和信号)。 + + 读取历史数据作为指标计算的 warmup, 计算完成后只返回目标日期的行。 + """ + from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals + + # 加载 warmup 历史 (目标日期前 ~120 天) + enriched_dir = self.repo.store.data_dir / "kline_daily_enriched" + start = target_date - timedelta(days=150) + read_cols = ["symbol", "date", "open", "high", "low", "close", "volume", + "amount", "raw_close", "raw_high", "raw_low"] + + try: + lf = ( + pl.scan_parquet(str(enriched_dir / "**" / "*.parquet")) + .filter( + (pl.col("date") >= start) + & (pl.col("date") <= target_date) + ) + .sort(["symbol", "date"]) + ) + available = [c for c in read_cols if c in lf.schema] + df_hist = lf.select(available).collect() + except Exception as e: # noqa: BLE001 + logger.warning("warmup history load failed: %s", e) + df_hist = df_target + + if df_hist.is_empty(): + df_hist = df_target + + # 计算指标 + df_full = compute_indicators(df_hist) + df_full = compute_signals(df_full) + + # 计算涨跌停信号 (需要 instruments) + instruments = self.repo.get_instruments() + if instruments is not None and not instruments.is_empty(): + df_full = compute_limit_signals(df_full, instruments) + + # 只保留目标日期 + df_result = df_full.filter(pl.col("date") == target_date) + + # JOIN instruments (name, total_shares, float_shares) + if not instruments.is_empty(): + inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in instruments.columns] + if "name" not in df_result.columns: + df_result = df_result.join(instruments.select(inst_cols), on="symbol", how="left") + + return df_result + + def _load_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame: + """读取目标日期之前的基础行情数据, 供历史窗口策略使用。 + + 优先从 repo 内存缓存获取 (启动时已预计算), 命中时 0ms。 + 缓存 miss 时走 scan_parquet + compute_indicators 慢路径。 + """ + # 优先级 1: repo 级预计算缓存 (启动时 _refresh_enriched 已计算完整历史) + t0 = time.perf_counter() + cached = self.repo.get_enriched_history(target_date, lookback_days) + if cached is not None and not cached.is_empty(): + # JOIN instruments (repo 缓存不含 name 等列) + instruments = self.repo.get_instruments() + if instruments is not None and not instruments.is_empty() and "name" not in cached.columns: + inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] + if c in instruments.columns] + cached = cached.join(instruments.select(inst_cols), on="symbol", how="left") + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("_load_enriched_history(%s, %d): repo cache hit, %.1fms, %d rows", + target_date, lookback_days, elapsed, len(cached)) + return cached + + # 优先级 2: 进程级 history_cache (之前的 TTL 缓存) + cache_key = (target_date, lookback_days) + now = time.monotonic() + ttl_cached = _history_cache.get(cache_key) + if ttl_cached is not None: + ts, cached_df = ttl_cached + if now - ts < _HISTORY_CACHE_TTL: + logger.debug("history TTL cache hit: %s lookback=%d", target_date, lookback_days) + return cached_df + del _history_cache[cache_key] + + # 优先级 3: scan_parquet + compute_indicators (慢路径, ~5s) + logger.warning("_load_enriched_history cache miss, computing indicators (%s, %d)...", + target_date, lookback_days) + from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals + + warmup = 60 + start = target_date - timedelta(days=min((lookback_days + warmup) * 2, 180)) + + enriched_dir = self.repo.store.data_dir / "kline_daily_enriched" + read_cols = ["symbol", "date", "open", "high", "low", "close", "volume", + "amount", "raw_close", "raw_high", "raw_low"] + + try: + lf = ( + pl.scan_parquet(str(enriched_dir / "**" / "*.parquet")) + .filter((pl.col("date") >= start) & (pl.col("date") <= target_date)) + .sort(["symbol", "date"]) + ) + available = [c for c in read_cols if c in lf.collect_schema().names()] + df_hist = lf.select(available).collect() + except Exception as e: # noqa: BLE001 + logger.warning("load_enriched_history failed: %s", e) + return pl.DataFrame() + + if df_hist.is_empty(): + return pl.DataFrame() + + df_full = compute_indicators(df_hist) + df_full = compute_signals(df_full) + + instruments = self.repo.get_instruments() + if instruments is not None and not instruments.is_empty(): + df_full = compute_limit_signals(df_full, instruments) + + if instruments is not None and not instruments.is_empty(): + inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in instruments.columns] + if "name" not in df_full.columns: + df_full = df_full.join(instruments.select(inst_cols), on="symbol", how="left") + + # 裁剪掉 warmup 部分, 只保留 lookback 范围 (减少 group_by 开销) + lookback_start = target_date - timedelta(days=lookback_days) + if "date" in df_full.columns: + df_full = df_full.filter(pl.col("date") >= lookback_start) + + df_full = df_full.sort(["symbol", "date"]) + + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("_load_enriched_history(%s, %d): computed in %.1fms, %d rows", + target_date, lookback_days, elapsed, len(df_full)) + + _history_cache[cache_key] = (now, df_full) + if len(_history_cache) > 10: + expired = [k for k, (ts, _) in _history_cache.items() if now - ts > _HISTORY_CACHE_TTL] + for k in expired: + del _history_cache[k] + + return df_full + + def run( + self, + as_of: date, + conditions: list[str], + order_by: str | None = None, + limit: int = 30, + pool: list[str] | None = None, + ) -> ScreenerResult: + """自定义 SQL 条件选股。 + + 先通过 Polars 即时计算完整指标, 再用 DuckDB 做 SQL WHERE 过滤。 + kline_enriched DuckDB 视图只有 14 列, 不能直接用于指标过滤。 + """ + t0 = time.perf_counter() + + if not conditions: + return ScreenerResult(as_of=as_of, strategy=None) + + # 从即时计算获取完整 enriched 数据 + df = self._load_enriched_for_date(as_of) + if df.is_empty(): + return ScreenerResult(as_of=as_of, strategy=None) + + # Pool 过滤 + if pool: + df = df.filter(pl.col("symbol").is_in(pool)) + + # 用 DuckDB 做 SQL 过滤 (注册临时视图) + try: + import duckdb + con = duckdb.connect(database=":memory:") + con.register("enriched", df.to_arrow()) + where = " AND ".join(f"({c})" for c in conditions) + sql = f"SELECT * FROM enriched WHERE {where}" + if order_by: + sql += f" ORDER BY {order_by}" + if limit: + sql += f" LIMIT {limit}" + df_result = con.execute(sql).pl() + con.close() + except Exception as e: # noqa: BLE001 + logger.warning("screener SQL query failed: %s", e) + df_result = pl.DataFrame() + + rows = df_result.to_dicts() if not df_result.is_empty() else [] + elapsed = (time.perf_counter() - t0) * 1000 + + return ScreenerResult( + as_of=as_of, + strategy=None, + rows=rows, + total=len(rows), + elapsed_ms=elapsed, + ) + + def run_preset( + self, + strategy_id: str, + as_of: date, + pool: list[str] | None = None, + precomputed: pl.DataFrame | None = None, + basic_filter: dict | None = None, + display_limit: int | None = None, + ) -> ScreenerResult: + """预设策略选股 — 从 enriched 读取预计算好的指标列后过滤。 + + - precomputed 不为空: 直接复用(run_all 场景) + - precomputed 为空: 从 enriched 读目标日期 + - basic_filter: 用户保存的基础参数过滤(boards、价格等) + """ + t0 = time.perf_counter() + + strat = PRESET_STRATEGIES.get(strategy_id) + if not strat: + raise ValueError(f"unknown strategy: {strategy_id}") + + if precomputed is not None and not precomputed.is_empty(): + df = precomputed + else: + df = self._load_enriched_for_date(as_of) + if df.is_empty(): + return ScreenerResult(as_of=as_of, strategy=strategy_id) + + # 应用用户基础参数过滤(boards、价格区间等) + if basic_filter and basic_filter.get("enabled", True): + df = self._apply_basic_filter(df, basic_filter) + + # 应用策略过滤 + df = df.filter(strat["filter"]) + + # 应用 pool + if pool: + df = df.filter(pl.col("symbol").is_in(pool)) + + # 排序 + 限制 + order_col = strat["order_by"] + if order_col in df.columns: + df = df.sort(order_col, descending=strat.get("descending", True)) + + # display_limit: None=不限制, 0=全部, N=前N个 + if display_limit == 0: + limit = None # 不限制 + elif display_limit is not None: + limit = display_limit + else: + limit = None # 未配置时默认不限制 + if limit is not None and limit > 0: + df = df.head(limit) + + # 基于排序列生成 0-100 评分 (与 StrategyEngine 统一) + if order_col in df.columns and not df.is_empty(): + col_vals = df[order_col].cast(pl.Float64) + col_min = col_vals.min() + col_max = col_vals.max() + col_range = col_max - col_min + if col_range and col_range > 0: + normalized = (col_vals - col_min) / col_range + else: + normalized = pl.Series("norm", [0.5] * len(df)) + if not strat.get("descending", True): + normalized = 1.0 - normalized + df = df.with_columns((normalized * 100).alias("score")) + + rows = df.to_dicts() + elapsed = (time.perf_counter() - t0) * 1000 + + # sanitize + for r in rows: + for k, v in list(r.items()): + if isinstance(v, float) and (v != v or abs(v) == float("inf")): + r[k] = None + + return ScreenerResult( + as_of=as_of, + strategy=strategy_id, + rows=rows, + total=len(rows), + elapsed_ms=elapsed, + ) + + @staticmethod + def _apply_basic_filter(df: pl.DataFrame, bf: dict) -> pl.DataFrame: + """应用用户基础参数过滤(boards、价格区间、市值等)""" + exprs: list[pl.Expr] = [] + if bf.get("price_min") is not None: + exprs.append(pl.col("close") >= bf["price_min"]) + if bf.get("price_max") is not None: + exprs.append(pl.col("close") <= bf["price_max"]) + if bf.get("float_cap_min") is not None and "float_shares" in df.columns: + exprs.append(pl.col("close") * pl.col("float_shares") >= bf["float_cap_min"]) + if bf.get("float_cap_max") is not None and "float_shares" in df.columns: + exprs.append(pl.col("close") * pl.col("float_shares") <= bf["float_cap_max"]) + if bf.get("amount_min") is not None: + exprs.append(pl.col("amount") >= bf["amount_min"]) + if bf.get("amount_max") is not None: + exprs.append(pl.col("amount") <= bf["amount_max"]) + if bf.get("turnover_min") is not None and "turnover_rate" in df.columns: + exprs.append(pl.col("turnover_rate") >= bf["turnover_min"]) + if bf.get("turnover_max") is not None and "turnover_rate" in df.columns: + exprs.append(pl.col("turnover_rate") <= bf["turnover_max"]) + if bf.get("exclude_st") and "name" in df.columns: + exprs.append(~pl.col("name").str.contains("(?i)ST|\\*ST|退")) + # 板块过滤 + boards = bf.get("boards") + if boards and isinstance(boards, list) and len(boards) > 0: + board_exprs: list[pl.Expr] = [] + for b in boards: + if b == "沪主板": + board_exprs.append(pl.col("symbol").str.starts_with("60")) + elif b == "深主板": + board_exprs.append( + pl.col("symbol").str.starts_with("00") + | pl.col("symbol").str.starts_with("001") + ) + elif b == "创业板": + board_exprs.append( + pl.col("symbol").str.starts_with("300") + | pl.col("symbol").str.starts_with("301") + ) + elif b == "科创板": + board_exprs.append(pl.col("symbol").str.starts_with("688")) + elif b == "北交所": + board_exprs.append(pl.col("symbol").str.contains(r"\.BJ$")) + if board_exprs: + exprs.append(pl.any_horizontal(board_exprs)) + if exprs: + return df.filter(pl.all_horizontal(exprs)) + return df + + def latest_date(self) -> date | None: + d = self.repo.enriched_latest_date() + if d: + return d + # 回退 DuckDB + try: + res = self.repo.execute_one( + "SELECT max(date) FROM kline_enriched", + ) + if res and res[0]: + d = res[0] + return d if isinstance(d, date) else date.fromisoformat(str(d)) + except Exception: # noqa: BLE001 + return None + return None diff --git a/serve/backend/app/services/stock_analyzer.py b/serve/backend/app/services/stock_analyzer.py new file mode 100644 index 0000000..c312459 --- /dev/null +++ b/serve/backend/app/services/stock_analyzer.py @@ -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) diff --git a/serve/backend/app/services/stock_reports.py b/serve/backend/app/services/stock_reports.py new file mode 100644 index 0000000..d522d23 --- /dev/null +++ b/serve/backend/app/services/stock_reports.py @@ -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") diff --git a/serve/backend/app/services/strategy_cache.py b/serve/backend/app/services/strategy_cache.py new file mode 100644 index 0000000..201134d --- /dev/null +++ b/serve/backend/app/services/strategy_cache.py @@ -0,0 +1,149 @@ +"""策略结果缓存 — 写入本地文件,供策略页面秒加载。 + +缓存结构: + { + "as_of": "2024-01-15", + "results": { strategy_id: { total, as_of, rows } }, + "today_ever_matched": { strategy_id: [symbol, ...] }, // 今日曾命中 symbol 并集 + "today_ever_rows": { strategy_id: { symbol: row_data } },// 今日曾命中的完整行数据 + "updated_at": 1705324800000 # Unix ms + } + +文件路径: data/user_data/strategy_cache.json +""" +from __future__ import annotations + +import json +import logging +import time +from datetime import date, datetime +from pathlib import Path +from typing import Any + + +def _json_default(obj: Any) -> Any: + """处理 date/datetime 等 JSON 不认识的类型。""" + if isinstance(obj, date): + return obj.isoformat() + if isinstance(obj, datetime): + return obj.isoformat() + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + +logger = logging.getLogger(__name__) + +_CACHE_FILENAME = "strategy_cache.json" + + +def _cache_path(data_dir: Path) -> Path: + return data_dir / "user_data" / _CACHE_FILENAME + + +def _enriched_parquet_path(data_dir: Path, as_of: str) -> Path: + """返回 enriched parquet 文件路径。""" + return data_dir / "kline_daily_enriched" / f"date={as_of}" / "part.parquet" + + +def _get_enriched_mtime(data_dir: Path, as_of: str) -> float | None: + """返回 enriched parquet 文件的 mtime (秒)。文件不存在返回 None。""" + p = _enriched_parquet_path(data_dir, as_of) + try: + return p.stat().st_mtime + except FileNotFoundError: + return None + + +def read_cache(data_dir: Path) -> dict | None: + """读取策略缓存文件。返回 None 表示无缓存或读取失败。 + + 说明: 原先有 enriched mtime 过期校验 (数据文件变化 → 判过期返回 None), + 但在有实时行情的系统里, enriched parquet 每轮被刷新 → mtime 必然变化 → + 缓存被永久判死, 策略页读不到数据。且判过期后不触发重算, 只能让用户手动重跑, + 保护价值有限。故移除: 盘后缓存总能读出, 实时新鲜度由 /api/screener/cached + 端点叠加监控引擎的内存实时结果 (latest_strategy_results) 来保证。 + """ + path = _cache_path(data_dir) + if not path.exists(): + return None + try: + text = path.read_text(encoding="utf-8") + if not text.strip(): + return None + cached = json.loads(text) + except Exception as e: # noqa: BLE001 + logger.warning("读取策略缓存失败: %s", e) + return None + + return cached + + +def _rows_to_symbol_map(rows: list[dict]) -> dict[str, dict]: + """将 rows 列表转为 {symbol: row_data} 映射。""" + result: dict[str, dict] = {} + for row in rows: + sym = row.get("symbol") + if sym: + result[sym] = row + return result + + +def write_cache( + data_dir: Path, + as_of: str, + results: dict[str, Any], +) -> None: + """将策略结果写入缓存文件,同时更新今日曾命中集合。 + + - 日期变更时重置 today_ever_matched 和 today_ever_rows + - 同一天内合并 (并集) 之前曾命中的 symbol,并用最新行数据更新 + """ + path = _cache_path(data_dir) + path.parent.mkdir(parents=True, exist_ok=True) + + # 读取旧缓存 + old = read_cache(data_dir) + old_as_of = old.get("as_of") if old else None + old_ever_rows: dict[str, dict[str, dict]] = old.get("today_ever_rows", {}) if old else {} + + # 当前命中的行数据 → symbol 映射 + current_row_maps: dict[str, dict[str, dict]] = {} + for sid, r in results.items(): + current_row_maps[sid] = _rows_to_symbol_map(r.get("rows", [])) + + if old_as_of and old_as_of == as_of and old_ever_rows: + # 同一天: 合并 — 用当前行数据更新旧数据 (保持最新价格等) + merged_rows: dict[str, dict[str, dict]] = {} + all_keys = set(old_ever_rows.keys()) | set(current_row_maps.keys()) + for sid in all_keys: + old_map = old_ever_rows.get(sid, {}) + cur_map = current_row_maps.get(sid, {}) + # 以旧数据为基础,用当前数据覆盖 (当前数据更新鲜) + combined = {**old_map, **cur_map} + merged_rows[sid] = combined + today_ever_rows = merged_rows + else: + # 新的一天或首次写入 + today_ever_rows = current_row_maps + + # 从 ever_rows 提取 symbol 列表 (用于快速计数) + today_ever_matched = {sid: sorted(maps.keys()) for sid, maps in today_ever_rows.items()} + + # enriched_mtime: 盘后缓存写入时记录 (向后兼容旧字段)。read_cache 已不再用它 + # 做过期校验, 实时新鲜度改由 /cached 端点叠加监控引擎内存结果保证。 + enriched_mtime = _get_enriched_mtime(data_dir, as_of) + + payload = { + "as_of": as_of, + "results": results, + "today_ever_matched": today_ever_matched, + "today_ever_rows": today_ever_rows, + "enriched_mtime": enriched_mtime, + "updated_at": int(time.time() * 1000), + } + try: + path.write_text(json.dumps(payload, ensure_ascii=False, default=_json_default), encoding="utf-8") + total_rows = sum(len(r.get("rows", [])) for r in results.values()) + total_ever = sum(len(v) for v in today_ever_matched.values()) + logger.info("策略缓存已写入: %s, %d 策略, %d 命中, %d 曾命中", as_of, len(results), total_rows, total_ever) + except Exception as e: # noqa: BLE001 + logger.warning("写入策略缓存失败: %s", e) diff --git a/serve/backend/app/services/watchlist.py b/serve/backend/app/services/watchlist.py new file mode 100644 index 0000000..7515b72 --- /dev/null +++ b/serve/backend/app/services/watchlist.py @@ -0,0 +1,144 @@ +"""自选股服务(§6.1)。 + +存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。 +""" +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path + +import polars as pl + +from app.config import settings +from app.tickflow.capabilities import Cap, CapabilitySet +from app.tickflow.client import get_client + +logger = logging.getLogger(__name__) + + +def _path() -> Path: + p = settings.data_dir / "user_data" / "watchlist.parquet" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def list_symbols() -> list[dict]: + p = _path() + if not p.exists(): + return [] + df = pl.read_parquet(p) + if df.is_empty(): + return [] + return df.to_dicts() + + +def add(symbol: str, note: str = "") -> list[dict]: + p = _path() + if p.exists(): + df = pl.read_parquet(p) + # 已存在则先移除,后面重新插入到最前面 + if symbol in df["symbol"].to_list(): + df = df.filter(pl.col("symbol") != symbol) + else: + df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}) + + new_row = pl.DataFrame({ + "symbol": [symbol], + "added_at": [datetime.utcnow().isoformat(timespec="seconds")], + "note": [note], + }) + out = pl.concat([new_row, df], how="diagonal_relaxed") + out.write_parquet(p) + return out.to_dicts() + + +def remove(symbol: str) -> list[dict]: + p = _path() + if not p.exists(): + return [] + df = pl.read_parquet(p) + df = df.filter(pl.col("symbol") != symbol) + df.write_parquet(p) + return df.to_dicts() + + +def move_to_top(symbol: str) -> list[dict]: + p = _path() + if not p.exists(): + return [] + df = pl.read_parquet(p) + if df.is_empty() or symbol not in df["symbol"].to_list(): + return df.to_dicts() + target = df.filter(pl.col("symbol") == symbol) + rest = df.filter(pl.col("symbol") != symbol) + out = pl.concat([target, rest], how="diagonal_relaxed") + out.write_parquet(p) + return out.to_dicts() + + +def clear() -> int: + """清空自选列表。返回移除的数量。""" + p = _path() + if not p.exists(): + return 0 + df = pl.read_parquet(p) + count = df.height + if count > 0: + pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p) + return count + + +def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]: + """拉取实时行情。 + + 优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。 + timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。 + """ + from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout + + if not symbols: + return [] + + tf = get_client() + quotes: list[dict] = [] + + # 走 batch + batch_size = 5 + if capset.has(Cap.QUOTE_BATCH): + lim = capset.limits(Cap.QUOTE_BATCH) + batch_size = lim.batch if lim and lim.batch else 50 + elif capset.has(Cap.QUOTE_BY_SYMBOL): + lim = capset.limits(Cap.QUOTE_BY_SYMBOL) + batch_size = lim.batch if lim and lim.batch else 5 + else: + # 无任何实时行情能力(none/free 档走 free-api 服务器,不提供实时行情) + # 提前返回空,避免发起注定失败的请求 + return [] + + chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] + + # 用线程池为每个批次加超时保护 + pool = ThreadPoolExecutor(max_workers=1) + for chunk in chunks: + try: + future = pool.submit(tf.quotes.get, symbols=chunk, as_dataframe=True) + raw = future.result(timeout=timeout_s) + if raw is None or len(raw) == 0: + continue + df = pl.from_pandas(raw) + rename_map = { + "last_price": "price", + "ext.change_pct": "pct", + "ext.name": "name", + } + df = df.rename({k: v for k, v in rename_map.items() if k in df.columns}) + quotes.extend(df.to_dicts()) + except FuturesTimeout: + logger.warning("quote fetch timeout (%.1fs) for %d symbols", timeout_s, len(chunk)) + break # 超时后不再尝试后续批次 + except Exception as e: # noqa: BLE001 + logger.warning("quote fetch failed for %d symbols: %s", len(chunk), e) + pool.shutdown(wait=False) + + return quotes diff --git a/serve/backend/app/services/webhook_adapter.py b/serve/backend/app/services/webhook_adapter.py new file mode 100644 index 0000000..afb76f5 --- /dev/null +++ b/serve/backend/app/services/webhook_adapter.py @@ -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) diff --git a/serve/backend/app/strategy/__init__.py b/serve/backend/app/strategy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serve/backend/app/strategy/ai_generator.py b/serve/backend/app/strategy/ai_generator.py new file mode 100644 index 0000000..4db6a5e --- /dev/null +++ b/serve/backend/app/strategy/ai_generator.py @@ -0,0 +1,134 @@ +"""AI 策略生成器 — 读取策略开发文档 + 调用 LLM 生成策略代码。 + +职责: 接收用户自然语言描述 → 读取 docs/strategy-guide.md → 调用 LLM → 返回策略代码。 +不知道: 引擎内部、API、前端、配置持久化、回测。 +""" +from __future__ import annotations + +import ast +import logging +import re +import tempfile +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 策略开发文档路径 +GUIDE_PATH = Path(__file__).resolve().parent.parent.parent.parent / "docs" / "strategy-guide.md" + +_SYSTEM_PREFIX = """你是A股量化策略设计专家。根据用户描述的需求,参考下方的《策略开发指南》生成一个完整的策略Python文件。 + +核心约束: +- 只创建这一个 .py 文件,不要修改任何现有文件,不要跨文件引用 +- 只 import polars as pl,不 import 其他模块 + +要求: +1. 用户可能调整的策略阈值通过 META["params"] 暴露;公式常数、固定窗口边界、布尔开关不必强行参数化 +2. 遵循指南中的文件结构,但优先贴合用户规则,不要为了套模板歪曲策略含义 +3. ENTRY_SIGNALS/EXIT_SIGNALS 根据策略逻辑自行选择匹配的信号列,不要照搬示例 +4. scoring 权重根据策略核心逻辑定制,总和 = 1.0 +5. 优先使用 Polars 表达式、窗口函数、聚合和 with_columns/filter 实现,避免逐行/逐股 Python 循环;只有表达式难以描述的复杂状态机才使用 partition_by/to_dicts +6. 直接输出Python代码,不要输出其他内容 + +--- 策略开发指南 --- + +""" + + +class AIStrategyGenerator: + """AI 策略生成器""" + + def __init__(self) -> None: + self._guide_cache: str | None = None + + def _get_guide(self) -> str: + if self._guide_cache is None: + if GUIDE_PATH.exists(): + self._guide_cache = GUIDE_PATH.read_text(encoding="utf-8") + else: + logger.warning("strategy-guide.md not found at %s", GUIDE_PATH) + self._guide_cache = "" + return self._guide_cache + + async def generate(self, user_prompt: str) -> dict: + """根据用户描述生成策略代码 + + Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None} + """ + guide = self._get_guide() + + # 调用 LLM + code = await self._call_llm(user_prompt, guide) + + # 验证 + try: + self._validate_safety(code) + except ValueError as e: + return {"code": code, "meta": {}, "valid": False, "error": str(e)} + + # 试加载获取 META + try: + meta = self._extract_meta(code) + except Exception as e: + return {"code": code, "meta": {}, "valid": False, "error": f"解析META失败: {e}"} + + return {"code": code, "meta": meta, "valid": True, "error": None} + + async def _call_llm(self, user_prompt: str, guide: str) -> str: + """Call the configured AI provider and return generated strategy code.""" + from app.services.ai_provider import generate_ai_text + + content = await generate_ai_text( + [ + {"role": "system", "content": _SYSTEM_PREFIX + guide}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.3, + max_tokens=3000, + ) + # 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: + content = content.split("```", 1)[1].split("```", 1)[0].strip() + return content + + @staticmethod + def _validate_safety(code: str) -> None: + """AST 级安全检查""" + tree = ast.parse(code) + + forbidden_modules = {"os", "sys", "subprocess", "socket", "shutil", + "pathlib", "http", "urllib", "requests", "httpx"} + forbidden_calls = {"open", "exec", "eval", "compile", "__import__", + "globals", "locals", "vars", "dir", "getattr", + "setattr", "delattr", "type", "input"} + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.split(".")[0] not in ("polars",): + if alias.name.split(".")[0] in forbidden_modules: + raise ValueError(f"禁止 import {alias.name}") + if isinstance(node, ast.ImportFrom): + if node.module and node.module.split(".")[0] not in ("polars",): + if node.module.split(".")[0] in forbidden_modules: + raise ValueError(f"禁止 from {node.module} import") + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id in forbidden_calls: + raise ValueError(f"禁止调用 {node.func.id}()") + + @staticmethod + def _extract_meta(code: str) -> dict: + """从代码字符串中提取 META 字典(不执行代码)""" + tree = ast.parse(code) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "META": + # 找到 META 赋值,用 compile+eval 安全提取 + # 只允许字面量 + meta_node = node.value + code_obj = compile(ast.Expression(meta_node), "", "eval") + return eval(code_obj, {"__builtins__": {}}) # noqa: S307 + return {} diff --git a/serve/backend/app/strategy/builtin/__init__.py b/serve/backend/app/strategy/builtin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serve/backend/app/strategy/builtin/boll_breakout.py b/serve/backend/app/strategy/builtin/boll_breakout.py new file mode 100644 index 0000000..cb86588 --- /dev/null +++ b/serve/backend/app/strategy/builtin/boll_breakout.py @@ -0,0 +1,31 @@ +"""布林突破 — 突破布林上轨 + 放量""" +import polars as pl + +META = { + "id": "boll_breakout", + "name": "布林突破", + "description": "突破布林上轨 + 放量, 强势加速信号", + "tags": ["布林", "突破"], + "params": [ + {"id": "vol_ratio_min", "label": "最低量比", "type": "float", + "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1}, + ], + "scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_boll_breakout_upper"] +EXIT_SIGNALS = ["signal_boll_breakdown_lower"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + vol_min = params.get("vol_ratio_min", 1.5) + return ( + pl.col("signal_boll_breakout_upper").fill_null(False) + & (pl.col("vol_ratio_5d") >= vol_min) + ) diff --git a/serve/backend/app/strategy/builtin/broken_board_recovery.py b/serve/backend/app/strategy/builtin/broken_board_recovery.py new file mode 100644 index 0000000..4fd9b5e --- /dev/null +++ b/serve/backend/app/strategy/builtin/broken_board_recovery.py @@ -0,0 +1,35 @@ +"""断板反包 — 涨停 + 放量 + 涨幅 >3%""" +import polars as pl + +META = { + "id": "broken_board_recovery", + "name": "断板反包", + "description": "连板≥2后断板1-2天, 出现放量反包信号", + "tags": ["涨停", "反包"], + "params": [ + {"id": "vol_ratio_min", "label": "最低量比", "type": "float", + "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1}, + {"id": "change_pct_min", "label": "最低涨幅", "type": "float", + "default": 0.03, "min": 0.01, "max": 0.10, "step": 0.01}, + ], + "scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_limit_up"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 10 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + vol_min = params.get("vol_ratio_min", 1.5) + chg_min = params.get("change_pct_min", 0.03) + return ( + pl.col("signal_limit_up").fill_null(False) + & (pl.col("vol_ratio_5d") >= vol_min) + & (pl.col("change_pct") > chg_min) + ) diff --git a/serve/backend/app/strategy/builtin/bullish_alignment.py b/serve/backend/app/strategy/builtin/bullish_alignment.py new file mode 100644 index 0000000..8dc3fc1 --- /dev/null +++ b/serve/backend/app/strategy/builtin/bullish_alignment.py @@ -0,0 +1,29 @@ +"""均线多头 — MA5>MA10>MA20>MA60 + 短期动量为正""" +import polars as pl + +META = { + "id": "bullish_alignment", + "name": "均线多头", + "description": "MA5>MA10>MA20>MA60多头排列 + 短期动量为正", + "tags": ["均线", "多头"], + "params": [], + "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_ma_golden_5_20", "signal_ma_golden_20_60"] +EXIT_SIGNALS = ["signal_ma_dead_5_20", "signal_ma20_breakdown"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 20 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + return ( + (pl.col("ma5") > pl.col("ma10")) + & (pl.col("ma10") > pl.col("ma20")) + & (pl.col("ma20") > pl.col("ma60")) + & (pl.col("momentum_20d") > 0) + ) diff --git a/serve/backend/app/strategy/builtin/consecutive_limit_ups.py b/serve/backend/app/strategy/builtin/consecutive_limit_ups.py new file mode 100644 index 0000000..4e864ac --- /dev/null +++ b/serve/backend/app/strategy/builtin/consecutive_limit_ups.py @@ -0,0 +1,31 @@ +"""连板股 — 涨停且连续涨停≥2天""" +import polars as pl + +META = { + "id": "consecutive_limit_ups", + "name": "连板股", + "description": "当日涨停且连续涨停≥2天, 强势追涨", + "tags": ["涨停", "连板"], + "params": [ + {"id": "min_boards", "label": "最少连板数", "type": "int", + "default": 2, "min": 1, "max": 20, "step": 1}, + ], + "scoring": {"consecutive_limit_ups": 0.5, "change_pct": 0.3, "amount": 0.2}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_limit_up"] +EXIT_SIGNALS = [] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 5 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + min_boards = params.get("min_boards", 2) + return ( + pl.col("signal_limit_up").fill_null(False) + & (pl.col("consecutive_limit_ups") >= min_boards) + ) diff --git a/serve/backend/app/strategy/builtin/high_turnover_surge.py b/serve/backend/app/strategy/builtin/high_turnover_surge.py new file mode 100644 index 0000000..53b8276 --- /dev/null +++ b/serve/backend/app/strategy/builtin/high_turnover_surge.py @@ -0,0 +1,34 @@ +"""高换手拉升 — 换手率 > 5% 且涨幅 > 3%, 资金活跃""" +import polars as pl + +META = { + "id": "high_turnover_surge", + "name": "高换手拉升", + "description": "换手率 > 5% 且涨幅 > 3%, 资金活跃", + "tags": ["换手率", "放量", "资金"], + "params": [ + {"id": "min_turnover", "label": "最低换手率%", "type": "float", + "default": 5.0, "min": 1.0, "max": 20.0, "step": 0.5}, + {"id": "min_change", "label": "最低涨幅%", "type": "float", + "default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5}, + ], + "scoring": {"turnover_rate": 0.4, "change_pct": 0.3, "momentum_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +ENTRY_SIGNALS = ["signal_volume_surge"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 10 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + min_to = params.get("min_turnover", 5.0) / 100.0 + min_chg = params.get("min_change", 3.0) / 100.0 + return ( + (pl.col("turnover_rate") > min_to) + & (pl.col("change_pct") > min_chg) + ) diff --git a/serve/backend/app/strategy/builtin/limit_up_momentum.py b/serve/backend/app/strategy/builtin/limit_up_momentum.py new file mode 100644 index 0000000..65afe29 --- /dev/null +++ b/serve/backend/app/strategy/builtin/limit_up_momentum.py @@ -0,0 +1,34 @@ +"""连板接力 — 近2日涨停且今日涨幅 > 5%, 连板股追踪""" +import polars as pl + +META = { + "id": "limit_up_momentum", + "name": "连板接力", + "description": "连板股 + 今日涨幅 > 5%, 连板接力追踪", + "tags": ["涨停", "连板", "接力"], + "params": [ + {"id": "min_change", "label": "最低涨幅%", "type": "float", + "default": 5.0, "min": 2.0, "max": 15.0, "step": 0.5}, + {"id": "min_boards", "label": "最少连板", "type": "int", + "default": 1, "min": 1, "max": 10, "step": 1}, + ], + "scoring": {"consecutive_limit_ups": 0.4, "change_pct": 0.3, "amount": 0.3}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +ENTRY_SIGNALS = ["signal_limit_up"] +EXIT_SIGNALS = [] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 5 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + min_chg = params.get("min_change", 5.0) / 100.0 + min_boards = params.get("min_boards", 1) + return ( + (pl.col("change_pct") > min_chg) + & (pl.col("consecutive_limit_ups") >= min_boards) + ) diff --git a/serve/backend/app/strategy/builtin/low_volatility_leader.py b/serve/backend/app/strategy/builtin/low_volatility_leader.py new file mode 100644 index 0000000..f32a3a8 --- /dev/null +++ b/serve/backend/app/strategy/builtin/low_volatility_leader.py @@ -0,0 +1,32 @@ +"""低波动龙头 — 正动量 + 低波动 + MA20上方""" +import polars as pl + +META = { + "id": "low_volatility_leader", + "name": "低波动龙头", + "description": "20日动量为正 + 年化波动 < 30% + MA20上方", + "tags": ["低波动", "龙头"], + "params": [ + {"id": "vol_max", "label": "最大年化波动", "type": "float", + "default": 0.30, "min": 0.05, "max": 1.0, "step": 0.01}, + ], + "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_ma20_breakout"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 30 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + vol_max = params.get("vol_max", 0.30) + return ( + (pl.col("momentum_20d") > 0) + & (pl.col("annual_vol_20d") < vol_max) + & (pl.col("close") > pl.col("ma20")) + ) diff --git a/serve/backend/app/strategy/builtin/ma_golden_cross.py b/serve/backend/app/strategy/builtin/ma_golden_cross.py new file mode 100644 index 0000000..81b81fb --- /dev/null +++ b/serve/backend/app/strategy/builtin/ma_golden_cross.py @@ -0,0 +1,32 @@ +"""MA金叉 — MA5上穿MA20 + 量能配合 + MA60上方""" +import polars as pl + +META = { + "id": "ma_golden_cross", + "name": "MA 金叉", + "description": "MA5上穿MA20当日触发, 量能配合", + "tags": ["均线", "金叉"], + "params": [ + {"id": "vol_ratio_min", "label": "最低量比", "type": "float", + "default": 1.2, "min": 0.5, "max": 5.0, "step": 0.1}, + ], + "scoring": {"momentum_20d": 0.5, "vol_ratio_5d": 0.3, "change_pct": 0.2}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_ma_golden_5_20"] +EXIT_SIGNALS = ["signal_ma_dead_5_20"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + vol_min = params.get("vol_ratio_min", 1.2) + return ( + pl.col("signal_ma_golden_5_20").fill_null(False) + & (pl.col("vol_ratio_5d") >= vol_min) + & (pl.col("close") > pl.col("ma60")) + ) diff --git a/serve/backend/app/strategy/builtin/macd_golden.py b/serve/backend/app/strategy/builtin/macd_golden.py new file mode 100644 index 0000000..2ea8006 --- /dev/null +++ b/serve/backend/app/strategy/builtin/macd_golden.py @@ -0,0 +1,31 @@ +"""MACD金叉放量 — MACD金叉当日 + 量能放大""" +import polars as pl + +META = { + "id": "macd_golden", + "name": "MACD 金叉放量", + "description": "MACD金叉当日 + 量能放大", + "tags": ["MACD", "金叉", "放量"], + "params": [ + {"id": "vol_ratio_min", "label": "最低量比", "type": "float", + "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1}, + ], + "scoring": {"momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_macd_golden"] +EXIT_SIGNALS = ["signal_macd_dead"] +STOP_LOSS = -0.07 +MAX_HOLD_DAYS = 20 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + vol_min = params.get("vol_ratio_min", 1.5) + return ( + pl.col("signal_macd_golden").fill_null(False) + & (pl.col("vol_ratio_5d") >= vol_min) + ) diff --git a/serve/backend/app/strategy/builtin/n_day_low_reversal.py b/serve/backend/app/strategy/builtin/n_day_low_reversal.py new file mode 100644 index 0000000..11219c2 --- /dev/null +++ b/serve/backend/app/strategy/builtin/n_day_low_reversal.py @@ -0,0 +1,32 @@ +"""新低反转 — 60日新低后收阳放量""" +import polars as pl + +META = { + "id": "n_day_low_reversal", + "name": "新低反转", + "description": "触及60日新低后当日收阳放量, 反转信号", + "tags": ["反转", "新低"], + "params": [ + {"id": "vol_ratio_min", "label": "最低量比", "type": "float", + "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1}, + ], + "scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_n_day_low"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + vol_min = params.get("vol_ratio_min", 1.5) + return ( + pl.col("signal_n_day_low").fill_null(False) + & (pl.col("close") > pl.col("open")) + & (pl.col("vol_ratio_5d") >= vol_min) + ) diff --git a/serve/backend/app/strategy/builtin/near_limit_up.py b/serve/backend/app/strategy/builtin/near_limit_up.py new file mode 100644 index 0000000..4442e06 --- /dev/null +++ b/serve/backend/app/strategy/builtin/near_limit_up.py @@ -0,0 +1,55 @@ +"""逼近涨停 — 涨幅 > 7% 且距涨停 < 3%, 盘后选股""" +import polars as pl + + +def _limit_pct() -> pl.Expr: + """根据板块和 ST 动态计算涨跌幅限制 (小数)。 + 创业板(300/301)/科创板(688): 20% + 北交所(.BJ): 30% + ST: 5% + 主板: 10% + """ + is_st = pl.col("name").str.contains("(?i)ST").fill_null(False) + is_cyb = pl.col("symbol").str.starts_with("300") | pl.col("symbol").str.starts_with("301") + is_kcb = pl.col("symbol").str.starts_with("688") + is_bj = pl.col("symbol").str.contains(r"\.BJ$") + return ( + pl.when(is_st).then(0.05) + .when(is_cyb | is_kcb).then(0.20) + .when(is_bj).then(0.30) + .otherwise(0.10) + ) + + +META = { + "id": "near_limit_up", + "name": "逼近涨停", + "description": "涨幅 > 7% 且距涨停 < 3%, 追涨信号", + "tags": ["涨停", "追涨"], + "params": [ + {"id": "min_change", "label": "最低涨幅%", "type": "float", + "default": 7.0, "min": 3.0, "max": 15.0, "step": 1.0}, + {"id": "limit_gap", "label": "距涨停空间%", "type": "float", + "default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5}, + ], + "scoring": {"change_pct": 0.5, "amount": 0.3, "momentum_5d": 0.2}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +ENTRY_SIGNALS = [] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 5 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + min_chg = params.get("min_change", 7.0) / 100.0 + gap = params.get("limit_gap", 3.0) / 100.0 + lp = _limit_pct() + return ( + (pl.col("change_pct") > min_chg) + & (pl.col("change_pct") < lp - gap) + ) diff --git a/serve/backend/app/strategy/builtin/oversold_bounce.py b/serve/backend/app/strategy/builtin/oversold_bounce.py new file mode 100644 index 0000000..8adc124 --- /dev/null +++ b/serve/backend/app/strategy/builtin/oversold_bounce.py @@ -0,0 +1,37 @@ +"""超跌反弹 — RSI14 < 30 + 收阳 + 放量""" +import polars as pl + +META = { + "id": "oversold_bounce", + "name": "超跌反弹", + "description": "RSI14 < 30超卖区 + 当日收阳 + 放量, 抄底信号", + "tags": ["超跌", "反弹", "RSI"], + "params": [ + {"id": "rsi_max", "label": "RSI上限", "type": "float", + "default": 30.0, "min": 10.0, "max": 50.0, "step": 1.0}, + {"id": "vol_ratio_min", "label": "最低量比", "type": "float", + "default": 1.2, "min": 0.5, "max": 5.0, "step": 0.1}, + ], + "scoring": {"change_pct": 0.3, "vol_ratio_5d": 0.3, "momentum_5d": 0.2, "rsi_14": 0.2}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = [] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 15 +ALERTS = [ + {"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"}, +] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + rsi_max = params.get("rsi_max", 30.0) + vol_min = params.get("vol_ratio_min", 1.2) + return ( + (pl.col("rsi_14") < rsi_max) + & (pl.col("close") > pl.col("open")) + & (pl.col("vol_ratio_5d") >= vol_min) + ) diff --git a/serve/backend/app/strategy/builtin/oversold_reversal.py b/serve/backend/app/strategy/builtin/oversold_reversal.py new file mode 100644 index 0000000..ba31a6b --- /dev/null +++ b/serve/backend/app/strategy/builtin/oversold_reversal.py @@ -0,0 +1,37 @@ +"""超跌反弹 — RSI14 < 30 + 涨幅 > 1% + 站上 MA5, 超卖反弹信号""" +import polars as pl + +META = { + "id": "oversold_reversal", + "name": "超跌反转", + "description": "RSI14 < 30超卖 + 涨幅 > 1% + 站上MA5, 超卖反转信号", + "tags": ["超跌", "反弹", "RSI"], + "params": [ + {"id": "rsi_max", "label": "RSI上限", "type": "float", + "default": 30.0, "min": 10.0, "max": 50.0, "step": 1.0}, + {"id": "min_change", "label": "最低涨幅%", "type": "float", + "default": 1.0, "min": 0.5, "max": 5.0, "step": 0.5}, + ], + "scoring": {"change_pct": 0.4, "rsi_14": 0.3, "vol_ratio_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +ENTRY_SIGNALS = [] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 15 +ALERTS = [ + {"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"}, +] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + rsi_max = params.get("rsi_max", 30.0) + min_chg = params.get("min_change", 1.0) / 100.0 + return ( + (pl.col("rsi_14") < rsi_max) + & (pl.col("change_pct") > min_chg) + & (pl.col("close") > pl.col("ma5")) + ) diff --git a/serve/backend/app/strategy/builtin/pullback_ma20_bounce.py b/serve/backend/app/strategy/builtin/pullback_ma20_bounce.py new file mode 100644 index 0000000..9c56adb --- /dev/null +++ b/serve/backend/app/strategy/builtin/pullback_ma20_bounce.py @@ -0,0 +1,34 @@ +"""均线回踩反弹 — 价格在 MA20 附近(±2%)且 MA 多头排列, 回踩买入""" +import polars as pl + +META = { + "id": "pullback_ma20_bounce", + "name": "均线回踩反弹", + "description": "价格在MA20附近(±2%)且MA5>MA20>MA60多头排列, 回踩买入", + "tags": ["回踩", "均线", "反弹"], + "params": [ + {"id": "ma_proximity", "label": "MA偏离度%", "type": "float", + "default": 2.0, "min": 0.5, "max": 5.0, "step": 0.5}, + ], + "scoring": {"momentum_60d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +ENTRY_SIGNALS = ["signal_ma_golden_5_20"] +EXIT_SIGNALS = ["signal_ma20_breakdown", "signal_ma_dead_5_20"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 15 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + proximity = params.get("ma_proximity", 2.0) / 100.0 + return ( + (pl.col("close") > pl.col("ma20") * (1 - proximity)) + & (pl.col("close") < pl.col("ma20") * (1 + proximity)) + & (pl.col("ma5") > pl.col("ma20")) + & (pl.col("ma20") > pl.col("ma60")) + & (pl.col("change_pct") > 0) + ) diff --git a/serve/backend/app/strategy/builtin/pullback_to_support.py b/serve/backend/app/strategy/builtin/pullback_to_support.py new file mode 100644 index 0000000..36fc51a --- /dev/null +++ b/serve/backend/app/strategy/builtin/pullback_to_support.py @@ -0,0 +1,37 @@ +"""缩量回踩 — 回踩MA20附近 + 缩量 + 中期趋势向上""" +import polars as pl + +META = { + "id": "pullback_to_support", + "name": "缩量回踩", + "description": "回踩MA20附近 + 缩量 + 中期趋势向上", + "tags": ["回踩", "支撑"], + "params": [ + {"id": "ma_proximity", "label": "均线偏离度", "type": "float", + "default": 0.02, "min": 0.01, "max": 0.05, "step": 0.005}, + {"id": "vol_ratio_max", "label": "最大量比", "type": "float", + "default": 0.8, "min": 0.2, "max": 1.5, "step": 0.1}, + ], + "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_ma_golden_5_20"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 20 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + proximity = params.get("ma_proximity", 0.02) + vol_max = params.get("vol_ratio_max", 0.8) + return ( + (pl.col("close") > pl.col("ma20") * (1 - proximity)) + & (pl.col("close") < pl.col("ma20") * (1 + proximity)) + & (pl.col("vol_ratio_5d") < vol_max) + & (pl.col("close") > pl.col("ma60")) + & (pl.col("momentum_20d") > 0) + ) diff --git a/serve/backend/app/strategy/builtin/strong_open.py b/serve/backend/app/strategy/builtin/strong_open.py new file mode 100644 index 0000000..918842b --- /dev/null +++ b/serve/backend/app/strategy/builtin/strong_open.py @@ -0,0 +1,35 @@ +"""强势高开 — 高开 > 3% 且保持上涨, 集合竞价强势""" +import polars as pl + +META = { + "id": "strong_open", + "name": "强势高开", + "description": "高开 > 3% 且收盘高于开盘价, 集合竞价强势", + "tags": ["高开", "强势"], + "params": [ + {"id": "min_open_gap", "label": "最低高开%", "type": "float", + "default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5}, + {"id": "min_change", "label": "最低涨幅%", "type": "float", + "default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5}, + ], + "scoring": {"change_pct": 0.4, "amplitude": 0.2, "amount": 0.4}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +ENTRY_SIGNALS = [] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 10 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + min_gap = params.get("min_open_gap", 3.0) / 100.0 + min_chg = params.get("min_change", 3.0) / 100.0 + return ( + (pl.col("open") > pl.col("prev_close") * (1 + min_gap)) + & (pl.col("close") > pl.col("open")) + & (pl.col("change_pct") > min_chg) + ) diff --git a/serve/backend/app/strategy/builtin/trend_breakout.py b/serve/backend/app/strategy/builtin/trend_breakout.py new file mode 100644 index 0000000..eb95cca --- /dev/null +++ b/serve/backend/app/strategy/builtin/trend_breakout.py @@ -0,0 +1,42 @@ +"""趋势突破 — MA60上方 + 60日新高 + 放量""" +import polars as pl + +META = { + "id": "trend_breakout", + "name": "趋势突破", + "description": "MA60上方 + 60日新高 + 量能 ≥ 2倍均量", + "tags": ["趋势", "突破", "放量"], + "basic_filter": { + "price_min": 5, + "price_max": 200, + "market_cap_min": 20e8, + "amount_min": 1e8, + "exclude_st": True, + "exclude_new_days": 60, + }, + "params": [ + {"id": "vol_ratio_min", "label": "最低量比", "type": "float", + "default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1}, + ], + "scoring": {"momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_n_day_high"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.08 +MAX_HOLD_DAYS = 20 +ALERTS = [ + {"field": "signal_volume_surge", "message": "放量异动"}, +] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + vol_min = params.get("vol_ratio_min", 2.0) + return ( + (pl.col("close") > pl.col("ma60")) + & pl.col("signal_n_day_high").fill_null(False) + & (pl.col("vol_ratio_5d") >= vol_min) + ) diff --git a/serve/backend/app/strategy/builtin/volume_price_surge.py b/serve/backend/app/strategy/builtin/volume_price_surge.py new file mode 100644 index 0000000..c3573e4 --- /dev/null +++ b/serve/backend/app/strategy/builtin/volume_price_surge.py @@ -0,0 +1,32 @@ +"""量价齐升 — 突破MA20 + 放量 + 收阳""" +import polars as pl + +META = { + "id": "volume_price_surge", + "name": "量价齐升", + "description": "突破MA20 + 放量 + 收阳", + "tags": ["量价", "突破"], + "params": [ + {"id": "vol_ratio_min", "label": "最低量比", "type": "float", + "default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1}, + ], + "scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_ma20_breakout"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 +ALERTS = [] + + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + vol_min = params.get("vol_ratio_min", 2.0) + return ( + pl.col("signal_ma20_breakout").fill_null(False) + & (pl.col("vol_ratio_5d") >= vol_min) + & (pl.col("close") > pl.col("open")) + ) diff --git a/serve/backend/app/strategy/config.py b/serve/backend/app/strategy/config.py new file mode 100644 index 0000000..3c594f7 --- /dev/null +++ b/serve/backend/app/strategy/config.py @@ -0,0 +1,71 @@ +"""策略配置持久化 — 读写用户覆盖值。 + +职责: 将每个策略的用户定制设置(基础参数、策略参数、评分、买卖信号)持久化到 JSON。 +不知道: 引擎、AI、前端、回测。 +存储: data/user_data/strategy_overrides/{strategy_id}.json +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _overrides_dir(data_dir: Path) -> Path: + d = data_dir / "user_data" / "strategy_overrides" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _path(data_dir: Path, strategy_id: str) -> Path: + return _overrides_dir(data_dir) / f"{strategy_id}.json" + + +def load_override(data_dir: Path, strategy_id: str) -> dict: + """读取策略的用户覆盖配置,不存在返回空 dict""" + p = _path(data_dir, strategy_id) + if not p.exists(): + return {} + try: + data = json.loads(p.read_text(encoding="utf-8")) + # 清理 basic_filter 中值为 None/空的键(避免固化无意义的空值) + bf = data.get("basic_filter") + if isinstance(bf, dict): + cleaned = {k: v for k, v in bf.items() if v is not None} + if cleaned: + data["basic_filter"] = cleaned + else: + del data["basic_filter"] + return data + except Exception as e: + logger.warning("load override %s failed: %s", strategy_id, e) + return {} + + +def save_override(data_dir: Path, strategy_id: str, overrides: dict) -> None: + """保存策略的用户覆盖配置(全量覆盖写)""" + p = _path(data_dir, strategy_id) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(overrides, ensure_ascii=False, indent=2), encoding="utf-8") + + +def delete_override(data_dir: Path, strategy_id: str) -> None: + """删除策略的用户覆盖配置(重置为默认值)""" + p = _path(data_dir, strategy_id) + if p.exists(): + p.unlink() + + +def list_overrides(data_dir: Path) -> dict[str, dict]: + """返回所有策略的覆盖配置 {strategy_id: overrides}""" + d = _overrides_dir(data_dir) + result: dict[str, dict] = {} + for f in d.glob("*.json"): + try: + sid = f.stem + result[sid] = json.loads(f.read_text(encoding="utf-8")) + except Exception: + continue + return result diff --git a/serve/backend/app/strategy/custom_signals.py b/serve/backend/app/strategy/custom_signals.py new file mode 100644 index 0000000..cc05a83 --- /dev/null +++ b/serve/backend/app/strategy/custom_signals.py @@ -0,0 +1,207 @@ +"""自定义信号 — 用户用「字段 + 运算符 + 值」组合出的布尔信号。 + +职责: + - 从 data/user_data/custom_signals/*.json 加载信号定义 + - 把每个信号的 conditions 编译成一条 Polars 布尔表达式(AND 组合) + - 供 pipeline 在 compute_signals / compute_enriched_today 末尾注入为列 + +不知道: 引擎、AI、API、回测、监控。纯函数 + 模块级缓存。 + +设计: + - 信号列名加前缀 ``csg_`` 避免与内置 ``signal_`` 列冲突。 + - 回测/选股/监控都按列名找信号,因此注入列后零特殊处理即可三处生效。 + - 字段白名单 + 固定运算符集,杜绝任意表达式注入。 + - 第一版只支持 AND(多条件同时满足)。 +""" +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path + +import polars as pl + +logger = logging.getLogger(__name__) + +# ── 常量 ──────────────────────────────────────────────── +PREFIX = "csg_" # 自定义信号列名前缀 +ID_RE = re.compile(r"^[a-z0-9_]{1,40}$") +OPS = {">", ">=", "<", "<=", "==", "!="} + +# 字段白名单:只允许这些列出现在条件里(防注入)。均为数值型。 +# 与 ENRICHED_COLUMNS 的数值列保持一致,排除 symbol/date/name 等非数值列。 +ALLOWED_FIELDS: frozenset[str] = frozenset({ + # 行情 + "open", "high", "low", "close", "volume", "amount", "turnover_rate", + "consecutive_limit_ups", "consecutive_limit_downs", + # 基础 + "prev_close", "change_pct", "change_amount", "amplitude", + # 均线 / 指数均线 + "ma5", "ma10", "ma20", "ma30", "ma60", + "ema5", "ema10", "ema20", "ema30", "ema60", + # MACD / BOLL / KDJ / ATR + "macd_dif", "macd_dea", "macd_hist", + "boll_upper", "boll_lower", + "kdj_k", "kdj_d", "kdj_j", + "atr_14", + # 量价 / 极值 / 动量 / 波动率 / RSI + "vol_ma5", "vol_ma10", "vol_ratio_5d", + "high_60d", "low_60d", + "momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d", + "annual_vol_20d", + "rsi_6", "rsi_14", "rsi_24", +}) + +# 运算符 → Polars 表达式构造器(输入 col_expr, value) +_OP_BUILDERS = { + ">": lambda c, v: c > v, + ">=": lambda c, v: c >= v, + "<": lambda c, v: c < v, + "<=": lambda c, v: c <= v, + "==": lambda c, v: c == v, + "!=": lambda c, v: c != v, +} + + +# ── 持久化(镜像 strategy/config.py 的写法)────────────── +def _dir(data_dir: Path) -> Path: + d = data_dir / "user_data" / "custom_signals" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _path(data_dir: Path, signal_id: str) -> Path: + return _dir(data_dir) / f"{signal_id}.json" + + +def load_all(data_dir: Path) -> list[dict]: + """读取全部自定义信号定义。损坏的文件被跳过。""" + d = _dir(data_dir) + out: list[dict] = [] + for f in sorted(d.glob("*.json")): + try: + out.append(json.loads(f.read_text(encoding="utf-8"))) + except Exception as e: + logger.warning("custom signal load failed %s: %s", f.name, e) + return out + + +def save_one(data_dir: Path, sig: dict) -> None: + p = _path(data_dir, sig["id"]) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(sig, ensure_ascii=False, indent=2), encoding="utf-8") + + +def delete_one(data_dir: Path, signal_id: str) -> bool: + p = _path(data_dir, signal_id) + if p.exists(): + p.unlink() + return True + return False + + +# ── 校验 ──────────────────────────────────────────────── +def _parse_right(right: str) -> tuple[str, object]: + """解析右值。返回 ('field', colname) 或 ('const', float)。""" + if isinstance(right, (int, float)): + return ("const", float(right)) + if not isinstance(right, str): + raise ValueError(f"非法右值: {right!r}") + if right.startswith("field:"): + col = right[len("field:"):] + if col not in ALLOWED_FIELDS: + raise ValueError(f"右值字段不在白名单: {col}") + return ("field", col) + # 纯数字 + try: + return ("const", float(right)) + except ValueError: + raise ValueError(f"非法右值(应为 field:xxx 或数字): {right!r}") + + +def validate(sig: dict) -> None: + """校验一个信号定义,非法则抛 ValueError(含中文信息)。""" + sid = sig.get("id", "") + if not isinstance(sid, str) or not ID_RE.match(sid): + raise ValueError(f"信号 id 非法(仅小写字母数字下划线,1-40字符): {sid!r}") + if not isinstance(sig.get("name"), str) or not sig["name"].strip(): + raise ValueError("信号 name 不能为空") + if sig.get("kind") not in ("entry", "exit", "both"): + raise ValueError("kind 必须是 entry / exit / both") + conds = sig.get("conditions") + if not isinstance(conds, list) or len(conds) == 0: + raise ValueError("conditions 不能为空") + if len(conds) > 8: + raise ValueError("conditions 最多 8 条") + for i, c in enumerate(conds): + if not isinstance(c, dict): + raise ValueError(f"第 {i+1} 个条件格式错误") + left = c.get("left", "") + if left not in ALLOWED_FIELDS: + raise ValueError(f"第 {i+1} 个条件: 字段 {left!r} 不在白名单") + if c.get("op") not in OPS: + raise ValueError(f"第 {i+1} 个条件: 运算符 {c.get('op')!r} 非法") + _parse_right(c.get("right")) # 会校验右值字段/数字 + + +# ── 编译为 Polars 表达式 ───────────────────────────────── +def column_name(signal_id: str) -> str: + """信号 id → DataFrame 列名(加前缀)。""" + return f"{PREFIX}{signal_id}" + + +def build_expressions(signals: list[dict]) -> dict[str, pl.Expr]: + """把多个自定义信号编译成 {column_name: pl.Expr}。 + + - 只处理 enabled != False 的信号。 + - 单个信号内多条件用 ``&`` 串联(AND)。 + - 编译失败的信号被跳过并告警(不影响其它信号)。 + """ + out: dict[str, pl.Expr] = {} + for sig in signals: + if sig.get("enabled") is False: + continue + try: + conds = sig["conditions"] + col_name = column_name(sig["id"]) + parts: list[pl.Expr] = [] + for c in conds: + left = c["left"] + op = c["op"] + kind, val = _parse_right(c["right"]) + right_expr = pl.col(val) if kind == "field" else val + parts.append(_OP_BUILDERS[op](pl.col(left), right_expr)) + combined = parts[0] + for p in parts[1:]: + combined = combined & p + out[col_name] = combined + except Exception as e: + logger.warning("custom signal compile failed %s: %s", sig.get("id"), e) + return out + + +def inject(df: pl.DataFrame, exprs: dict[str, pl.Expr]) -> pl.DataFrame: + """把编译好的信号表达式作为列加入 df。仅添加 df 已含其依赖列的信号。""" + if df.is_empty() or not exprs: + return df + cols = set(df.columns) + add: dict[str, pl.Expr] = {} + for name, expr in exprs.items(): + # 提取该表达式引用的所有字段列,缺失则跳过(避免运行时报错) + needed = _expr_root_columns(expr) + if needed.issubset(cols): + add[name] = expr + if add: + df = df.with_columns([e.alias(n) for n, e in add.items()]) + return df + + +def _expr_root_columns(expr: pl.Expr) -> set[str]: + """尽力提取表达式里出现的列名。失败则返回空集(保守跳过)。""" + try: + # Polars 的 meta.root_names() 返回表达式引用的根列名 + names = expr.meta.root_names() + return set(names) + except Exception: + return set() diff --git a/serve/backend/app/strategy/engine.py b/serve/backend/app/strategy/engine.py new file mode 100644 index 0000000..67ef2ad --- /dev/null +++ b/serve/backend/app/strategy/engine.py @@ -0,0 +1,453 @@ +"""策略引擎 — 加载、执行、评分。 + +职责: 从文件系统加载策略 Python 模块,执行两阶段过滤(基础+策略), + 通用评分排序。 +不知道: AI、API、前端、配置持久化、回测。 +""" +from __future__ import annotations + +import importlib.util +import logging +import time +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path +from typing import Any, Callable + +import polars as pl + +logger = logging.getLogger(__name__) + +# 引擎级默认基础过滤 — 策略未定义 BASIC_FILTER 时兜底 +DEFAULT_BASIC_FILTER: dict = { + "price_min": 3, + "price_max": 300, + "market_cap_min": 10e8, + "float_cap_min": None, + "float_cap_max": None, + "amount_min": 0.2e8, + "amount_max": None, + "turnover_min": None, + "turnover_max": None, + "exclude_st": True, + "exclude_new_days": 30, + "boards": ["沪主板", "深主板", "创业板", "科创板", "北交所"], +} + + +@dataclass +class StrategyDef: + """加载后的策略定义(只读数据 + filter 函数引用)""" + meta: dict + basic_filter: dict + entry_signals: list[str] + exit_signals: list[str] + stop_loss: float | None + trailing_stop: float | None + trailing_take_profit_activate: float | None + trailing_take_profit_drawdown: float | None + max_hold_days: int | None + alerts: list[dict] + filter_fn: Callable[[pl.DataFrame, dict], pl.Expr] | None + filter_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None + lookback_days: int + source: str # "builtin" | "custom" | "ai" + file_path: Path | None = None + + +@dataclass +class StrategyResult: + """策略执行结果""" + as_of: date + strategy_id: str + rows: list[dict] = field(default_factory=list) + total: int = 0 + elapsed_ms: float = 0.0 + scores: dict[str, float] = field(default_factory=dict) + + +class StrategyEngine: + """策略引擎 — 策略加载 + 执行 + 评分""" + + def __init__(self, enriched_loader: Callable[[date], pl.DataFrame], + enriched_history_loader: Callable[[date, int], pl.DataFrame] | None = None, + strategy_dirs: list[Path] | None = None): + """ + Args: + enriched_loader: (date) -> pl.DataFrame, 加载指定日期的 enriched 数据 + strategy_dirs: 策略文件搜索目录列表 + """ + self._loader = enriched_loader + self._history_loader = enriched_history_loader + self._strategies: dict[str, StrategyDef] = {} + self._strategy_dirs = strategy_dirs or [] + self._load_all() + + # ================================================================ + # 加载 + # ================================================================ + + def _load_all(self) -> None: + self._strategies.clear() + for d in self._strategy_dirs: + if not d.exists(): + continue + for f in sorted(d.glob("*.py")): + if f.name.startswith("_"): + continue + try: + s = self._load_file(f) + self._strategies[s.meta["id"]] = s + logger.debug("loaded strategy: %s (%s)", s.meta["id"], s.source) + except Exception as e: + logger.warning("load strategy %s failed: %s", f.name, e) + + @staticmethod + def _load_file(path: Path) -> StrategyDef: + """从 Python 文件加载策略定义""" + spec = importlib.util.spec_from_file_location(path.stem, path) + if spec is None or spec.loader is None: + raise ValueError(f"cannot load module from {path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + meta = getattr(mod, "META", {}) + meta.setdefault("id", path.stem) + meta.setdefault("name", path.stem) + meta.setdefault("description", "") + meta.setdefault("tags", []) + meta.setdefault("params", []) + meta.setdefault("scoring", {}) + meta.setdefault("order_by", "score") + meta.setdefault("descending", True) + meta.setdefault("limit", 100) + + # 合并默认基础过滤 + bf = {**DEFAULT_BASIC_FILTER} + strat_bf = getattr(mod, "BASIC_FILTER", None) + if strat_bf: + bf.update(strat_bf) + # meta 里的 basic_filter 也合并(优先级最高) + meta_bf = meta.get("basic_filter") + if meta_bf: + bf.update(meta_bf) + + source = "custom" + if "builtin" in str(path).replace("\\", "/"): + source = "builtin" + elif "/ai/" in str(path).replace("\\", "/") or "\\ai\\" in str(path): + source = "ai" + + return StrategyDef( + meta=meta, + basic_filter=bf, + entry_signals=getattr(mod, "ENTRY_SIGNALS", []), + exit_signals=getattr(mod, "EXIT_SIGNALS", []), + stop_loss=getattr(mod, "STOP_LOSS", None), + trailing_stop=getattr(mod, "TRAILING_STOP", None), + trailing_take_profit_activate=getattr(mod, "TRAILING_TAKE_PROFIT_ACTIVATE", None), + trailing_take_profit_drawdown=getattr(mod, "TRAILING_TAKE_PROFIT_DRAWDOWN", None), + max_hold_days=getattr(mod, "MAX_HOLD_DAYS", None), + alerts=getattr(mod, "ALERTS", []), + filter_fn=getattr(mod, "filter", None), + filter_history_fn=getattr(mod, "filter_history", None), + lookback_days=int(getattr(mod, "LOOKBACK_DAYS", meta.get("lookback_days", 1)) or 1), + source=source, + file_path=path, + ) + + def reload(self) -> None: + """热重载所有策略""" + self._load_all() + + # ================================================================ + # 查询 + # ================================================================ + + def list_strategies(self) -> list[dict]: + """返回所有策略的元信息""" + result = [] + for s in self._strategies.values(): + result.append({**s.meta, "source": s.source}) + return result + + def get(self, strategy_id: str) -> StrategyDef: + s = self._strategies.get(strategy_id) + if not s: + raise ValueError(f"unknown strategy: {strategy_id}") + return s + + def has(self, strategy_id: str) -> bool: + return strategy_id in self._strategies + + # ================================================================ + # 执行 + # ================================================================ + + def run( + self, + strategy_id: str, + as_of: date, + pool: list[str] | None = None, + params: dict | None = None, + overrides: dict | None = None, + precomputed: pl.DataFrame | None = None, + precomputed_history: pl.DataFrame | None = None, + ) -> StrategyResult: + """执行策略: 基础过滤 → 策略过滤 → 评分排序 + + Args: + strategy_id: 策略 ID + as_of: 选股日期 + pool: 限定股票池 + params: 策略参数 (用户在设置面板调的值) + overrides: 用户覆盖配置 (basic_filter/scoring/stop_loss 等) + precomputed: 已加载的 enriched 数据 (run_all 场景复用) + precomputed_history: 已加载的历史窗口数据 (run_all 场景复用) + """ + t0 = time.perf_counter() + + s = self.get(strategy_id) + params = params or {} + overrides = overrides or {} + + # 加载数据。普通策略只读目标日期;声明 filter_history 的策略读取历史窗口。 + if s.filter_history_fn: + if precomputed_history is not None and not precomputed_history.is_empty(): + df = precomputed_history + elif self._history_loader: + df = self._history_loader(as_of, max(1, s.lookback_days)) + else: + logger.warning("strategy %s requires history loader", strategy_id) + return StrategyResult(as_of=as_of, strategy_id=strategy_id) + if df.is_empty(): + return StrategyResult(as_of=as_of, strategy_id=strategy_id) + df = s.filter_history_fn(df, params) + if df.is_empty(): + return StrategyResult(as_of=as_of, strategy_id=strategy_id) + if "date" in df.columns: + df = df.filter(pl.col("date") == as_of) + elif precomputed is not None and not precomputed.is_empty(): + df = precomputed + else: + df = self._loader(as_of) + if df.is_empty(): + return StrategyResult(as_of=as_of, strategy_id=strategy_id) + + # 基础过滤: 策略默认 basic_filter 兜底, 用户 override 优先覆盖。 + # 这样策略文件里写的 exclude_st/price_min 等默认值即使前端没保存也能生效。 + bf = dict(s.basic_filter) if s.basic_filter else {} + if overrides and overrides.get("basic_filter"): + bf.update(overrides["basic_filter"]) + + # Stage 1: 基础过滤(enabled 默认开启; 显式 enabled=false 才跳过) + if bf and bf.get("enabled", True): + df = self._apply_basic_filter(df, bf) + + # Pool 过滤 + if pool: + df = df.filter(pl.col("symbol").is_in(pool)) + + # Stage 2: 策略过滤 + if s.filter_fn: + expr = s.filter_fn(df, params) + df = df.filter(expr) + + # Stage 3: 评分 + scoring = s.meta.get("scoring", {}) + scoring_overrides = overrides.get("scoring") + if scoring_overrides: + scoring = {**scoring, **scoring_overrides} + df = self._apply_scoring(df, scoring) + + # 排序 + 限制 + limit = s.meta.get("limit", 100) + order_desc = s.meta.get("descending", True) + if "score" in df.columns: + df = df.sort("score", descending=order_desc) + elif s.meta.get("order_by") and s.meta["order_by"] != "score": + ob = s.meta["order_by"] + if ob in df.columns: + df = df.sort(ob, descending=order_desc) + df = df.head(limit) + + # 输出 + rows = _sanitize(df.to_dicts()) + elapsed = (time.perf_counter() - t0) * 1000 + + scores: dict[str, float] = {} + if "score" in df.columns: + for r in df.iter_rows(named=True): + scores[r["symbol"]] = float(r.get("score") or 0) + + return StrategyResult( + as_of=as_of, + strategy_id=strategy_id, + rows=rows, + total=len(rows), + elapsed_ms=elapsed, + scores=scores, + ) + + def run_all(self, as_of: date, params_map: dict | None = None, + overrides_map: dict | None = None) -> dict[str, StrategyResult]: + """批量执行所有策略 (enriched 只加载一次,基础过滤按策略分组缓存,历史数据共享)""" + df = self._loader(as_of) + params_map = params_map or {} + overrides_map = overrides_map or {} + + # 历史策略: 找最大 lookback,一次加载共享 + history_strats = [(sid, s) for sid, s in self._strategies.items() if s.filter_history_fn] + if history_strats and self._history_loader: + max_lookback = max(s.lookback_days for _, s in history_strats) + shared_history = self._history_loader(as_of, max(1, max_lookback)) + else: + shared_history = None + + # 按 basic_filter hash 分组,避免重复过滤 + bf_cache: dict[str, pl.DataFrame] = {} + results: dict[str, StrategyResult] = {} + + for sid, strat in self._strategies.items(): + try: + bf_key = _dict_hash(strat.basic_filter) + if bf_key not in bf_cache: + if strat.basic_filter.get("enabled", True): + bf_cache[bf_key] = self._apply_basic_filter(df, strat.basic_filter) + else: + bf_cache[bf_key] = df + base = bf_cache[bf_key] + + # 从已过滤的 base 执行 (filter_history 策略使用共享历史) + results[sid] = self.run( + sid, as_of, + params=params_map.get(sid), + overrides=overrides_map.get(sid), + precomputed=base, + precomputed_history=shared_history, + ) + except Exception as e: + logger.warning("run strategy %s failed: %s", sid, e) + + return results + + # ================================================================ + # 内部: 基础过滤 + # ================================================================ + + @staticmethod + def _basic_filter_expr(df: pl.DataFrame, bf: dict) -> pl.Expr | None: + """构建基础过滤表达式。回测可复用为买入候选 mask,不删除行情行。""" + exprs: list[pl.Expr] = [] + if bf.get("price_min") is not None: + exprs.append(pl.col("close") >= bf["price_min"]) + if bf.get("price_max") is not None: + exprs.append(pl.col("close") <= bf["price_max"]) + if bf.get("market_cap_min") is not None and "total_shares" in df.columns: + exprs.append( + pl.col("close") * pl.col("total_shares") >= bf["market_cap_min"] + ) + if bf.get("market_cap_max") is not None and "total_shares" in df.columns: + exprs.append( + pl.col("close") * pl.col("total_shares") <= bf["market_cap_max"] + ) + # 流通市值 + if bf.get("float_cap_min") is not None and "float_shares" in df.columns: + exprs.append( + pl.col("close") * pl.col("float_shares") >= bf["float_cap_min"] + ) + if bf.get("float_cap_max") is not None and "float_shares" in df.columns: + exprs.append( + pl.col("close") * pl.col("float_shares") <= bf["float_cap_max"] + ) + if bf.get("amount_min") is not None: + exprs.append(pl.col("amount") >= bf["amount_min"]) + if bf.get("amount_max") is not None: + exprs.append(pl.col("amount") <= bf["amount_max"]) + # 换手率 + if bf.get("turnover_min") is not None and "turnover_rate" in df.columns: + exprs.append(pl.col("turnover_rate") >= bf["turnover_min"]) + if bf.get("turnover_max") is not None and "turnover_rate" in df.columns: + exprs.append(pl.col("turnover_rate") <= bf["turnover_max"]) + if bf.get("exclude_st") and "name" in df.columns: + exprs.append(~pl.col("name").str.contains("(?i)ST|\\*ST|退")) + # 板块过滤 + boards = bf.get("boards") + if boards and isinstance(boards, list) and len(boards) > 0: + board_exprs: list[pl.Expr] = [] + for b in boards: + if b == "沪主板": + board_exprs.append(pl.col("symbol").str.starts_with("60")) + elif b == "深主板": + board_exprs.append( + pl.col("symbol").str.starts_with("00") + | pl.col("symbol").str.starts_with("001") + ) + elif b == "创业板": + board_exprs.append( + pl.col("symbol").str.starts_with("300") + | pl.col("symbol").str.starts_with("301") + ) + elif b == "科创板": + board_exprs.append(pl.col("symbol").str.starts_with("688")) + elif b == "北交所": + board_exprs.append(pl.col("symbol").str.contains(r"\.BJ$")) + if board_exprs: + exprs.append(pl.any_horizontal(board_exprs)) + if exprs: + return pl.all_horizontal(exprs) + return None + + @staticmethod + def _apply_basic_filter(df: pl.DataFrame, bf: dict) -> pl.DataFrame: + """Stage 1: 基础参数过滤""" + expr = StrategyEngine._basic_filter_expr(df, bf) + if expr is not None: + return df.filter(expr) + return df + + # ================================================================ + # 内部: 评分 + # ================================================================ + + @staticmethod + def _apply_scoring(df: pl.DataFrame, weights: dict) -> pl.DataFrame: + """通用评分: min-max 归一化 → 加权求和 → 0~100 分""" + if not weights: + return df + total_weight = sum(weights.values()) + if total_weight <= 0: + return df + + score_parts: list[pl.Expr] = [] + for col, weight in weights.items(): + if col not in df.columns: + continue + w = weight / total_weight + col_min = pl.col(col).min() + col_range = pl.col(col).max() - col_min + normalized = pl.when(col_range > 0).then( + (pl.col(col) - col_min) / col_range + ).otherwise(pl.lit(0.5)) + score_parts.append(normalized * w) + + if not score_parts: + return df + + score_expr = score_parts[0] + for part in score_parts[1:]: + score_expr = score_expr + part + return df.with_columns((score_expr * 100).alias("score")) + + +def _sanitize(rows: list[dict]) -> list[dict]: + for r in rows: + for k, v in list(r.items()): + if isinstance(v, float) and (v != v or abs(v) == float("inf")): + r[k] = None + return rows + + +def _dict_hash(d: dict) -> str: + """用于 basic_filter 分组缓存""" + return str(sorted(d.items())) diff --git a/serve/backend/app/strategy/monitor.py b/serve/backend/app/strategy/monitor.py new file mode 100644 index 0000000..99d92ca --- /dev/null +++ b/serve/backend/app/strategy/monitor.py @@ -0,0 +1,857 @@ +"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。 + +职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。 +不知道: 策略加载逻辑、AI、API、配置持久化、回测。 +依赖: 外部调用 on_quote_update() 传入实时数据。 + +本模块含两个评估器: + 1. StrategyMonitorService — 旧的策略监控 (type=strategy),第二步迁移到 MonitorRuleEngine + 2. MonitorRuleEngine — 通用规则引擎,覆盖 signal/price/market/strategy 四类, + 支持 scope (symbols/all/sector) + 多条件 AND/OR + cooldown 去重 +""" +from __future__ import annotations + +import datetime as _dt +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Callable + +import polars as pl + +from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器 +from app.strategy import config as _strategy_config + +logger = logging.getLogger(__name__) + +# 信号 / 字段中文名映射 — 与前端 lib/signals.ts 对齐, 用于告警 message / 推送文案。 +# signal_* 为内置原子信号, 其余为技术指标/行情字段。 +_SIGNAL_CN: dict[str, str] = { + # 内置信号 + "signal_ma_golden_5_20": "MA5上穿MA20", "signal_ma_dead_5_20": "MA5下穿MA20", + "signal_ma_golden_20_60": "MA20上穿MA60", "signal_macd_golden": "MACD金叉", + "signal_macd_dead": "MACD死叉", "signal_ma20_breakout": "突破MA20", + "signal_ma20_breakdown": "跌破MA20", "signal_n_day_high": "60日新高", + "signal_n_day_low": "60日新低", "signal_boll_breakout_upper": "突破布林上轨", + "signal_boll_breakdown_lower": "跌破布林下轨", "signal_volume_surge": "放量", + "signal_limit_up": "涨停", "signal_limit_down": "跌停", + "signal_limit_down_recovery": "跌停翘板", "signal_broken_limit_up": "炸板", + # 行情字段 + "close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价", + "change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅", + "turnover_rate": "换手率", "volume": "成交量", "amount": "成交额", + # 均线 + "ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60", + "ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20", + # MACD / BOLL / KDJ / RSI + "macd_dif": "MACD-DIF", "macd_dea": "MACD-DEA", "macd_hist": "MACD柱", + "boll_upper": "布林上轨", "boll_lower": "布林下轨", + "kdj_k": "KDJ-K", "kdj_d": "KDJ-D", "kdj_j": "KDJ-J", + "rsi_6": "RSI6", "rsi_14": "RSI14", "rsi_24": "RSI24", + # 量能 / 动量 / 波动 + "vol_ratio_5d": "5日量比", "vol_ratio_20d": "20日量比", + "vol_ma5": "5日均量", "vol_ma10": "10日均量", + "high_60d": "60日最高", "low_60d": "60日最低", + "momentum_5d": "5日动量", "momentum_20d": "20日动量", "momentum_60d": "60日动量", + "atr_14": "ATR14", "annual_vol_20d": "20日年化波动", + "consecutive_limit_ups": "连板数", "consecutive_limit_downs": "跌停连板", +} + + +def _signal_cn_name(name: str) -> str: + """返回信号/字段的中文名, 找不到原样返回 (与前端 cnSignal 对齐)。""" + return _SIGNAL_CN.get(name, name) + + +@dataclass +class StrategyAlert: + """策略告警""" + type: str # "entry" | "exit" | "alert" + strategy_id: str + symbol: str + name: str | None + message: str + price: float | None = None + change_pct: float | None = None + signals: list[str] = field(default_factory=list) + + +class StrategyMonitorService: + """策略实时监控服务""" + + def __init__(self, alert_handler: Callable[[StrategyAlert], None] | None = None): + """ + Args: + alert_handler: 告警回调 (如推 SSE) + """ + self._alert_handler = alert_handler + # strategy_id → 监控配置 + self._watching: dict[str, dict] = {} + + def start(self, strategy_id: str, config: dict) -> None: + """开始监控一个策略 + + config: { + "entry_signals": ["signal_n_day_high", ...], + "exit_signals": ["signal_ma20_breakdown", ...], + "alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}], + } + """ + self._watching[strategy_id] = config + logger.info("strategy monitor started: %s", strategy_id) + + def stop(self, strategy_id: str) -> None: + self._watching.pop(strategy_id, None) + logger.info("strategy monitor stopped: %s", strategy_id) + + def stop_all(self) -> None: + self._watching.clear() + + @property + def watching(self) -> dict[str, dict]: + return dict(self._watching) + + def on_quote_update(self, df: pl.DataFrame) -> list[StrategyAlert]: + """行情更新后调用。向量化检查所有监控策略。 + + Args: + df: 实时 enriched 数据 (~5500行) + Returns: + 触发的告警列表 + """ + if not self._watching or df.is_empty(): + return [] + + all_alerts: list[StrategyAlert] = [] + + for strategy_id, cfg in self._watching.items(): + # 买入信号 + entry_sigs = cfg.get("entry_signals", []) + if entry_sigs: + for sym, name, price, pct, hit_sigs in self._check_signals(df, entry_sigs): + alert = StrategyAlert( + type="entry", + strategy_id=strategy_id, + symbol=sym, + name=name, + message=f"买入信号触发", + price=price, + change_pct=pct, + signals=hit_sigs, + ) + all_alerts.append(alert) + self._emit(alert) + + # 卖出信号 + exit_sigs = cfg.get("exit_signals", []) + if exit_sigs: + for sym, name, price, pct, hit_sigs in self._check_signals(df, exit_sigs): + alert = StrategyAlert( + type="exit", + strategy_id=strategy_id, + symbol=sym, + name=name, + message=f"卖出信号触发", + price=price, + change_pct=pct, + signals=hit_sigs, + ) + all_alerts.append(alert) + self._emit(alert) + + # 提醒条件 + for alert_cfg in cfg.get("alerts", []): + for sym, name, price, pct in self._check_alert(df, alert_cfg): + alert = StrategyAlert( + type="alert", + strategy_id=strategy_id, + symbol=sym, + name=name, + message=alert_cfg.get("message", "提醒"), + price=price, + change_pct=pct, + ) + all_alerts.append(alert) + self._emit(alert) + + return all_alerts + + def _emit(self, alert: StrategyAlert) -> None: + if self._alert_handler: + try: + self._alert_handler(alert) + except Exception as e: + logger.warning("alert handler failed: %s", e) + + @staticmethod + def _check_signals( + df: pl.DataFrame, + signals: list[str], + ) -> list[tuple[str, str | None, float | None, float | None, list[str]]]: + """检查信号列,返回 [(symbol, name, price, change_pct, [hit_signals])]。 + 支持内置 signal_ 与自定义 csg_ 前缀。""" + cols = set(df.columns) + resolved: list[tuple[str, str]] = [] # (原值, 列名) + for s in signals: + col = s if (s.startswith("signal_") or s.startswith("csg_")) else f"signal_{s}" + if col in cols: + resolved.append((s, col)) + if not resolved: + return [] + + mask = pl.any_horizontal(pl.col(c).fill_null(False) for _, c in resolved) + hit_df = df.filter(mask) + + results = [] + for row in hit_df.iter_rows(named=True): + sym = row.get("symbol", "") + name = row.get("name") + price = row.get("close") + pct = row.get("change_pct") + hit_sigs = [orig for orig, col in resolved if row.get(col)] + results.append((sym, name, price, pct, hit_sigs)) + return results + + @staticmethod + def _check_alert( + df: pl.DataFrame, + alert: dict, + ) -> list[tuple[str, str | None, float | None, float | None]]: + """检查阈值型提醒条件""" + field = alert.get("field", "") + if field not in df.columns: + return [] + + if "op" in alert: + # 阈值比较 + op = alert["op"] + value = alert["value"] + col = pl.col(field) + ops = { + ">": col > value, + ">=": col >= value, + "<": col < value, + "<=": col <= value, + } + expr = ops.get(op) + if expr is None: + return [] + else: + # 信号列 (布尔) + expr = pl.col(field).fill_null(False) + + hit_df = df.filter(expr) + results = [] + for row in hit_df.iter_rows(named=True): + results.append(( + row.get("symbol", ""), + row.get("name"), + row.get("close"), + row.get("change_pct"), + )) + return results + + +# ================================================================ +# 通用监控规则引擎 MonitorRuleEngine +# ================================================================ + +_SIGNAL_PREFIXES = ("signal_", "csg_") + + +def _is_signal_field(field: str) -> bool: + return any(field.startswith(p) for p in _SIGNAL_PREFIXES) + + +def _build_condition_mask(df: pl.DataFrame, conditions: list[dict], logic: str) -> pl.DataFrame: + """根据 conditions + logic 构建过滤后的命中 DataFrame。 + + conditions: [{"field","op","value"?}] — op=truth 为布尔信号, 否则阈值比较 + logic: "and" | "or" + 返回命中行 (含 symbol/name/close/change_pct + 各信号列) + """ + cols = set(df.columns) + parts: list[pl.Expr] = [] + for c in conditions: + field = c["field"] + if field not in cols: + return df.head(0) # 字段缺失,无法判定 → 空结果 + op = c["op"] + if op == "truth": + parts.append(pl.col(field).fill_null(False)) + elif op in _OP_BUILDERS: + parts.append(_OP_BUILDERS[op](pl.col(field), c["value"])) + else: + return df.head(0) + if not parts: + return df.head(0) + if logic == "or": + mask = pl.any_horizontal(parts) + else: + mask = pl.all_horizontal(parts) + return df.filter(mask) + + +class MonitorRuleEngine: + """通用监控规则引擎 — 接收实时行情 DataFrame,评估所有规则,返回 AlertEvent。 + + 与 StrategyMonitorService 的区别: + - 规则来自 monitor_rules 存储 (用户可配), 而非写死的 strategy config + - 支持 scope (symbols/all/sector) 过滤作用域 + - 支持 conditions + logic (AND/OR) 任意组合 + - ★ cooldown 去重: 同一 (rule_id, symbol) 在冷却期内不重复触发 + """ + + def __init__(self, alert_handler: Callable[[dict], None] | None = None): + self._alert_handler = alert_handler + self._rules: dict[str, dict] = {} # rule_id → rule + # (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。 + self._last_fire: dict[tuple[str, str], float] = {} + self._strategy_engine = None # 延迟注入, type=strategy 规则用它跑选股 + # symbol → 股票名 (enriched DataFrame 已 drop name 列, 触发时从此映射回填) + self._name_map: dict[str, str] = {} + # 策略选股池状态: strategy_id → 上期选股符号集合 (用于 diff 变更) + self._strategy_pools: dict[str, set[str]] = {} + # 数据目录 (用于加载策略 overrides) + self._data_dir = None + # 历史窗口加载器: (target_date, lookback_days) → 多日 enriched DataFrame。 + # 用于声明 filter_history 的策略 (如反包), 实时监控时拼历史窗口 + 今日行情跑选股。 + # 为 None 时, filter_history 策略仍会被跳过 (保持旧行为, 不破坏无历史场景)。 + self._history_loader: Callable[[_dt.date, int], "pl.DataFrame"] | None = None + # 本轮 evaluate() 产出的策略选股结果: strategy_id → {rows, total, as_of} + # 供策略页实时回显复用 (/api/screener/cached 端点直接读取此内存结果), 避免重跑 + self._latest_strategy_results: dict[str, dict] = {} + + def set_strategy_engine(self, engine) -> None: + """注入 StrategyEngine, type=strategy 规则据此跑选股。""" + self._strategy_engine = engine + + def set_data_dir(self, data_dir) -> None: + """注入数据目录, 用于加载策略的用户覆盖配置。""" + self._data_dir = data_dir + + def set_history_loader(self, fn) -> None: + """注入历史窗口加载器, 用于声明 filter_history 的策略跑实时监控。 + + loader 签名: (target_date, lookback_days) → 多日 enriched DataFrame。 + 复用 ScreenerService._load_enriched_history (三级缓存, 命中 ~0ms)。 + 为 None 时 filter_history 策略退回到跳过逻辑 (不破坏无历史场景)。 + """ + self._history_loader = fn + + def set_name_map(self, name_map: dict[str, str]) -> None: + """注入 symbol → 股票名 映射, 用于在告警事件里回填 name 字段。 + + enriched DataFrame 在 pipeline 计算后不含 name 列 (见 indicators/pipeline.py), + 触发时从 instruments 表预构建此映射, 保证 AlertEvent.name 有值。 + """ + self._name_map = name_map or {} + + # ── 规则管理 ─────────────────────────────────────── + def set_rules(self, rules: list[dict]) -> None: + """批量设置规则 (覆盖)。用于启动时 reload。""" + self._rules = {} + for r in rules: + if r.get("enabled") is not False: + self._rules[r["id"]] = r + logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules)) + + def add_rule(self, rule: dict) -> None: + if rule.get("enabled") is not False: + self._rules[rule["id"]] = rule + else: + self._rules.pop(rule["id"], None) + + def remove_rule(self, rule_id: str) -> None: + self._rules.pop(rule_id, None) + # 清理对应的 cooldown 记录 + self._last_fire = {k: v for k, v in self._last_fire.items() if k[0] != rule_id} + + def clear(self) -> None: + self._rules.clear() + self._last_fire.clear() + + @property + def rules(self) -> dict[str, dict]: + return dict(self._rules) + + @property + def rule_count(self) -> int: + return len(self._rules) + + def latest_strategy_results(self) -> dict[str, dict]: + """返回本轮 evaluate() 产出的策略选股结果 (strategy_id → {rows, total, as_of})。 + + 供策略页实时回显复用: /api/screener/cached 端点直接读取此内存结果, + 避免对被监控的策略重跑第二遍。无 type=strategy 规则时返回空 dict。 + """ + return self._latest_strategy_results + + def has_rule_type(self, rtype: str) -> bool: + """是否存在指定类型的 (已启用) 规则。供 quote_service 判断是否需要注入特殊数据。""" + if not self._rules: + return False + return any( + r.get("enabled", True) and r.get("type") == rtype + for r in self._rules.values() + ) + + # ── 评估 ─────────────────────────────────────────── + def evaluate(self, df: pl.DataFrame) -> list[dict]: + """行情更新后评估所有规则。 + + Args: + df: 实时 enriched 数据 (~5500行, 含 signal_/csg_/指标列) + Returns: + 触发的 AlertEvent dict 列表 (含 ts/rule_id/source/type/symbol/...) + """ + if not self._rules or df.is_empty(): + return [] + + now = time.time() + events: list[dict] = [] + # 每轮重置: 只保留本次 evaluate 产出的策略结果 + self._latest_strategy_results = {} + + for rule_id, rule in self._rules.items(): + try: + events.extend(self._evaluate_rule(df, rule, now)) + except Exception as e: + logger.warning("规则评估失败 %s: %s", rule_id, e) + + return events + + def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]: + """评估单条规则,返回触发的 events。""" + # 1. 按 scope 过滤作用域 + scoped = self._apply_scope(df, rule) + if scoped.is_empty(): + return [] + + # 2. 根据 type 构建命中集 + # 元组格式: (event_type, symbol, name, price, pct, signals) + hit_rows: list[tuple[str, str, Any, Any, Any, list[str]]] = [] + + rtype = rule.get("type", "signal") + if rtype == "strategy": + # 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件 + hit_rows = self._match_strategy(scoped, rule) + elif rtype == "ladder": + # 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message) + return self._evaluate_ladder(scoped, rule, now) + else: + # signal / price / market: 通用条件匹配 + for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule): + hit_rows.append((rtype, sym, name, price, pct, hit_sigs)) + + if not hit_rows: + return [] + + # 3. cooldown 去重 + 生成 events + cooldown = rule.get("cooldown_seconds", 3600) + severity = rule.get("severity", "info") + source = rtype + + events: list[dict] = [] + for ev_type, sym, name, price, pct, hit_sigs in hit_rows: + # cooldown 键: 批量事件用特殊键, 单只事件用 (rule_id, symbol) + is_batch = sym == "_batch" + if is_batch: + key = (rule["id"], f"_{ev_type}_batch") + else: + key = (rule["id"], sym) + last = self._last_fire.get(key) + if last is not None and (now - last) < cooldown: + continue # 冷却期内, 跳过 + self._last_fire[key] = now + + # 批量事件: name 存放预构建的消息文本 + if is_batch: + resolved_name = "" + message = name # name 字段即批量消息 + else: + resolved_name = name if name else self._name_map.get(sym) + message = rule.get("message", "") or self._default_message( + rule, ev_type=ev_type, sym=sym, name=resolved_name, + pct=pct, price=price, + conditions=list(rule.get("conditions", [])) if rule.get("type") != "strategy" else None, + ) + + ev = { + "ts": int(now * 1000), + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "source": source, + "type": ev_type, + "symbol": "" if is_batch else sym, + "name": resolved_name, + "message": message, + "price": price, + "change_pct": pct, + "signals": hit_sigs, + "severity": severity, + # 触发条件快照 (signal/price/market 类型): 用于触发记录展示 + # 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。 + "conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [], + "logic": rule.get("logic", "and") if rtype != "strategy" else "and", + } + events.append(ev) + if self._alert_handler: + try: + self._alert_handler(ev) + except Exception as e: + logger.warning("alert handler failed: %s", e) + + return events + + @staticmethod + def _apply_scope(df: pl.DataFrame, rule: dict) -> pl.DataFrame: + """按 scope 过滤 DataFrame。""" + scope = rule.get("scope", "symbols") + if scope == "all": + return df + if scope == "symbols": + syms = rule.get("symbols", []) + if not syms: + return df.head(0) + return df.filter(pl.col("symbol").is_in(syms)) + if scope == "sector": + # sector 过滤: 需 df 含板块列 (后续接入 ext_data JOIN) + # 当前先返回全量, sector 精确过滤第二步完善 + return df + return df + + def _match_strategy( + self, df: pl.DataFrame, rule: dict, + ) -> list[tuple[str, str, Any, Any, Any, list[str]]]: + """策略类型评估: 跑策略选股 → 对比上期选股池 → 产出变更事件。 + + 返回 [(event_type, symbol, name, price, pct, signals)] + event_type: "new_entry" (新入选) | "dropped" (已移出) + 单只变更逐只返回; 同一策略 >5 只合并为一条批量事件 (symbol="_batch") + """ + if self._strategy_engine is None: + return [] + sid = rule.get("strategy_id") + if not sid: + return [] + try: + s = self._strategy_engine.get(sid) + except Exception: + return [] + if s is None: + return [] + + # 运行策略选股: 复用当前 enriched DataFrame 跳过数据加载 + overrides = {} + if self._data_dir: + try: + overrides = _strategy_config.load_override(self._data_dir, sid) + except Exception: + pass + + # 声明 filter_history 的策略 (如反包) 需要多日历史窗口才能判定形态。 + # 旧实现因"实时监控不支持 history loader"直接跳过 → 反包等策略盘中永不触发。 + # 现接入 history_loader, 拼历史窗口 + 今日实时行情, 经 precomputed_history 喂给引擎。 + # loader 为 None (未装配) 时退回跳过, 保持旧行为, 不破坏无历史场景。 + run_kwargs: dict = { + "as_of": _dt.date.today(), + "overrides": overrides, + } + if s.filter_history_fn: + if self._history_loader is None: + logger.debug("策略 %s 需要历史数据但未注入 history_loader, 跳过实时监控", sid) + return [] + try: + today = _dt.date.today() + lookback = max(1, getattr(s, "lookback_days", 30)) + hist_df = self._history_loader(today, lookback) + if hist_df is None or hist_df.is_empty(): + logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid) + return [] + # 历史窗口可能与今日已落盘数据重叠: 排掉 hist_df 中 date==today 的行, + # 今日行情始终以实时 df 为准 (盘中逐轮更新, 最接近收盘真相)。 + # 否则 today 行重复会污染 filter_history 的 .over("symbol") 窗口判定。 + if "date" in hist_df.columns: + hist_df = hist_df.filter(pl.col("date") != today) + # 拼接历史窗口 + 今日实时行情 (filter_history 用 .over("symbol") 窗口, 多日天然可用) + run_kwargs["precomputed_history"] = pl.concat( + [hist_df, df], how="diagonal_relaxed" + ) + except Exception as e: + logger.warning("策略 %s 加载历史窗口失败, 跳过: %s", sid, e) + return [] + else: + # 普通策略: 复用当前 enriched DataFrame 跳过数据加载 + run_kwargs["precomputed"] = df + + try: + result = self._strategy_engine.run(sid, **run_kwargs) + except Exception as e: + logger.warning("策略 %s 选股执行失败: %s", sid, e) + return [] + + # 记录本轮完整选股结果 (供策略页实时回显: /cached 端点直接读取, 不落盘)。 + # 与下面的 diff 事件无关 — 无论是否产生 new_entry/dropped, 结果都该可用于回显。 + try: + import math + self._latest_strategy_results[sid] = { + "total": result.total, + "as_of": str(_dt.date.today()), + "rows": [ + {k: (None if isinstance(v, float) and not math.isfinite(v) else v) + for k, v in row.items()} + for row in result.rows + ], + } + except Exception: # noqa: BLE001 + pass + + current_pool: set[str] = {r["symbol"] for r in result.rows} + prev_pool = self._strategy_pools.get(sid) + + # 首次运行: 仅记录当前选股池, 不产生事件 + if prev_pool is None: + self._strategy_pools[sid] = current_pool + return [] + + new_entries = current_pool - prev_pool + dropped = prev_pool - current_pool + + # 无变更 + if not new_entries and not dropped: + return [] + + # 更新存储 + self._strategy_pools[sid] = current_pool + + sname = s.meta.get("name", "") or s.meta.get("id", sid) + + # 构建查找表 (新入选股票可在 result.rows 中找到; 移出股票需从 df 找) + row_map: dict[str, dict] = {r["symbol"]: r for r in result.rows} + dropped_map: dict[str, dict] = {} + if dropped: + try: + _dd = df.filter(pl.col("symbol").is_in(list(dropped))) + for row in _dd.iter_rows(named=True): + dropped_map[row["symbol"]] = row + except Exception: + pass + + results: list[tuple[str, str, Any, Any, Any, list[str]]] = [] + + # ── 新入选 ── + new_list = sorted(new_entries) + if len(new_list) > 5: + names: list[str] = [] + for sym in new_list: + row = row_map.get(sym, {}) + name = row.get("name") or self._name_map.get(sym, sym) + names.append(str(name)) + message = f"策略「{sname}」进入 {len(new_entries)} 只:{'、'.join(names)}" + results.append(("new_entry", "_batch", message, None, None, [])) + else: + for sym in new_list: + row = row_map.get(sym, {}) + name = row.get("name") or self._name_map.get(sym, sym) + price = row.get("close") + pct = row.get("change_pct") + results.append(("new_entry", sym, name, price, pct, [])) + + # ── 已移出 ── + dropped_list = sorted(dropped) + if len(dropped_list) > 5: + names = [] + for sym in dropped_list: + row = dropped_map.get(sym, {}) + name = row.get("name") or self._name_map.get(sym, sym) + names.append(str(name)) + message = f"策略「{sname}」移出 {len(dropped)} 只:{'、'.join(names)}" + results.append(("dropped", "_batch", message, None, None, [])) + else: + for sym in dropped_list: + row = dropped_map.get(sym, {}) + name = row.get("name") or self._name_map.get(sym, sym) + price = row.get("close") + pct = row.get("change_pct") + results.append(("dropped", sym, name, price, pct, [])) + + return results + + @staticmethod + def _match_conditions( + df: pl.DataFrame, rule: dict, + ) -> list[tuple[str, Any, Any, Any, list[str]]]: + """按 conditions + logic 匹配,返回命中行 [(symbol,name,price,pct,signals)]。""" + conditions = rule.get("conditions", []) + logic = rule.get("logic", "and") + if not conditions: + return [] + hit_df = _build_condition_mask(df, conditions, logic) + results = [] + for row in hit_df.iter_rows(named=True): + sym = row.get("symbol", "") + name = row.get("name") + price = row.get("close") + pct = row.get("change_pct") + # 收集命中的信号列名 (仅 op=truth 且为真的) + hit_sigs = [ + c["field"] for c in conditions + if c.get("op") == "truth" and row.get(c["field"]) + ] + results.append((sym, name, price, pct, hit_sigs)) + return results + + def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]: + """评估连板梯队封单监控规则。 + + 封单量从注入的临时列 _sealed_vol (手) 读取 (由 quote_service 评估前注入)。 + 命中条件: 封单比较值 <= threshold (且封单 > 0, 排除无 depth 数据的股票)。 + 涨停(direction=up) → 炸板预警; 跌停(direction=down) → 翘板预警。 + """ + if "_sealed_vol" not in scoped.columns: + return [] # 无封单数据 (depth 未拉取), 安全降级 + + metric = rule.get("metric", "sealed_vol") + threshold = rule.get("threshold", 0) + direction = rule.get("direction", "up") + cooldown = rule.get("cooldown_seconds", 600) + severity = rule.get("severity", "warn") + + # 比较值: sealed_vol 直接用 (手), sealed_amount = 手 × 100股 × close + if metric == "sealed_amount": + cmp_expr = pl.col("_sealed_vol") * 100 * pl.col("close") + unit = "元" + else: + cmp_expr = pl.col("_sealed_vol") + unit = "手" + + # 命中: 封单 > 0 (有数据) 且 比较值 <= 阈值 + hit = scoped.filter( + pl.col("_sealed_vol").is_not_null() + & (pl.col("_sealed_vol") > 0) + & (cmp_expr <= threshold) + ) + if hit.is_empty(): + return [] + + warn_label = "炸板预警" if direction == "up" else "翘板预警" + events: list[dict] = [] + for row in hit.iter_rows(named=True): + sym = row.get("symbol", "") + key = (rule["id"], sym) + last = self._last_fire.get(key) + if last is not None and (now - last) < cooldown: + continue + self._last_fire[key] = now + + name = row.get("name") or self._name_map.get(sym) or sym + price = row.get("close") + pct = row.get("change_pct") + sealed_vol = row.get("_sealed_vol") + # 预警封单值 (展示用) + sealed_value = sealed_vol * 100 * (price or 0) if metric == "sealed_amount" else sealed_vol + + # message 体现预警封单量 + 阈值 + if metric == "sealed_amount": + sv_text = f"{sealed_value / 1e4:.0f}万{unit}" + th_text = f"{threshold / 1e4:.0f}万{unit}" + else: + sv_text = f"{sealed_value:,.0f} {unit}" + th_text = f"{threshold:,.0f} {unit}" + message = f"{warn_label} · 封单 {sv_text} ≤ {th_text}" + + events.append({ + "ts": int(now * 1000), + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "source": "ladder", + "type": warn_label, + "symbol": sym, + "name": name, + "message": message, + "price": price, + "change_pct": pct, + "signals": [], + "severity": severity, + "conditions": [], + "logic": "and", + "sealed_value": sealed_value, # 预警封单量/额 (飞书+记录展示) + "sealed_metric": metric, + }) + return events + + def _default_message(self, rule: dict, ev_type: str = "", sym: str = "", + name: str = "", pct: Any = None, price: Any = None, + conditions: list[dict] | None = None) -> str: + """生成默认 message。 + + - strategy: 按变更方向生成 (进入/移出 + 涨跌幅) + - signal/price/market: 条件摘要 + 现价 + 涨跌幅 (避免笼统的「信号触发」) + """ + rtype = rule.get("type", "signal") + if rtype == "strategy": + # 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分 + sname = "" + sid = rule.get("strategy_id") + if sid and self._strategy_engine is not None: + try: + s = self._strategy_engine.get(sid) + sname = s.meta.get("name", "") or s.meta.get("id", "") + except Exception: # noqa: BLE001 + sname = "" + if not sname: + rn = rule.get("name", "") + sname = rn.split(" · ", 1)[1] if " · " in rn else (rn or "策略") + + if ev_type == "new_entry": + pct_text = "" + if pct is not None: + sign = "+" if pct >= 0 else "" + pct_text = f" {sign}{pct * 100:.1f}%" + return f"策略「{sname}」进入 {name}{pct_text}" + elif ev_type == "dropped": + pct_text = "" + if pct is not None: + sign = "+" if pct >= 0 else "" + pct_text = f" {sign}{pct * 100:.1f}%" + return f"策略「{sname}」移出 {name}{pct_text}" + return f"策略「{sname}」变更" + + # signal / price / market: 条件摘要 + 现价 + 涨跌幅 + # 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2" + cond_text = self._format_conditions_text(rule, conditions) + price_text = f"现价 {price}" if price is not None else "" + pct_text = "" + if pct is not None: + sign = "+" if pct >= 0 else "" + pct_text = f"{sign}{pct * 100:.1f}%" + tail = " · ".join(s for s in (price_text, pct_text) if s) + if cond_text and tail: + return f"{cond_text} · {tail}" + return cond_text or tail or "监控触发" + + @staticmethod + def _format_conditions_text(rule: dict, conditions: list[dict] | None) -> str: + """把 rule.conditions 拼成可读文本 (用于 message / 推送)。 + + op=truth: 直接用信号中文名 (如 "MA20金叉") + op=比较: 字段中文名 + 操作符 + 值 (如 "涨跌幅≥5") + logic: and → "且", or → "或" + """ + conds = conditions if conditions is not None else list(rule.get("conditions", [])) + if not conds: + return "" + logic_word = "且" if rule.get("logic", "and") == "and" else "或" + parts: list[str] = [] + for c in conds: + field = c.get("field", "") + op = c.get("op", "truth") + value = c.get("value") + label = _signal_cn_name(field) or field + if op == "truth": + parts.append(label) + else: + op_map = {"gte": "≥", "lte": "≤", "gt": ">", "lt": "<", "eq": "="} + parts.append(f"{label}{op_map.get(op, op)}{value}") + return f" {logic_word} ".join(parts) diff --git a/serve/backend/app/strategy/monitor_rules.py b/serve/backend/app/strategy/monitor_rules.py new file mode 100644 index 0000000..d074f4e --- /dev/null +++ b/serve/backend/app/strategy/monitor_rules.py @@ -0,0 +1,258 @@ +"""监控规则 — 统一的 MonitorRule 模型,覆盖策略/个股信号/个股价格/市场异动四类。 + +职责: + - 从 data/user_data/monitor_rules/*.json 加载规则定义 + - 校验规则字段合法性 + - 提供 CRUD (load_all / save_one / delete_one) + +不知道: 行情评估引擎、API、告警落盘。纯函数 + 文件存储。 + +设计 (镜像 custom_signals.py 的写法): + - 一对象一文件 + glob 全扫 + 全量重写 + - 字段白名单复用 custom_signals.ALLOWED_FIELDS (阈值条件) + 信号列清单 (布尔条件) + - id 正则与 custom_signals 一致,保证可纳入同一索引体系 +""" +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime, timezone +from pathlib import Path + +from app.strategy.custom_signals import ALLOWED_FIELDS + +logger = logging.getLogger(__name__) + +# ── 常量 ──────────────────────────────────────────────── +ID_RE = re.compile(r"^[a-z0-9_]{1,40}$") +RULE_TYPES = {"strategy", "signal", "price", "market", "ladder"} +SCOPES = {"symbols", "all", "sector"} +LOGICS = {"and", "or"} +DIRECTIONS = {"entry", "exit", "both"} +SEVERITIES = {"info", "warn", "critical"} +OPS = {">", ">=", "<", "<=", "==", "!="} +# ladder 规则: 封单监控的指标 (量=手, 额=元) +LADDER_METRICS = {"sealed_vol", "sealed_amount"} +# ladder 规则: 方向 (up=涨停炸板预警, down=跌停翘板预警) +LADDER_DIRECTIONS = {"up", "down"} + +# 布尔信号列前缀 (op=truth 时 field 取这些) +_SIGNAL_PREFIXES = ("signal_", "csg_") + + +# ── 持久化 (镜像 custom_signals.py) ───────────────────── +def _dir(data_dir: Path) -> Path: + d = data_dir / "user_data" / "monitor_rules" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _path(data_dir: Path, rule_id: str) -> Path: + return _dir(data_dir) / f"{rule_id}.json" + + +def load_all(data_dir: Path) -> list[dict]: + """读取全部监控规则。损坏的文件被跳过。""" + d = _dir(data_dir) + out: list[dict] = [] + for f in sorted(d.glob("*.json")): + try: + out.append(json.loads(f.read_text(encoding="utf-8"))) + except Exception as e: + logger.warning("monitor rule load failed %s: %s", f.name, e) + return out + + +def load_one(data_dir: Path, rule_id: str) -> dict | None: + p = _path(data_dir, rule_id) + if not p.exists(): + return None + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception as e: + logger.warning("monitor rule load failed %s: %s", rule_id, e) + return None + + +def save_one(data_dir: Path, rule: dict) -> None: + p = _path(data_dir, rule["id"]) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(rule, ensure_ascii=False, indent=2), encoding="utf-8") + + +def delete_one(data_dir: Path, rule_id: str) -> bool: + p = _path(data_dir, rule_id) + if p.exists(): + p.unlink() + return True + return False + + +# ── 校验 ──────────────────────────────────────────────── +def _is_signal_field(field: str) -> bool: + """判断 field 是否为布尔信号列 (signal_ / csg_ 前缀)。""" + return any(field.startswith(p) for p in _SIGNAL_PREFIXES) + + +def validate(rule: dict) -> None: + """校验一条监控规则,非法则抛 ValueError (含中文信息)。""" + rid = rule.get("id", "") + if not isinstance(rid, str) or not ID_RE.match(rid): + raise ValueError(f"规则 id 非法 (仅小写字母数字下划线, 1-40字符): {rid!r}") + if not isinstance(rule.get("name"), str) or not rule["name"].strip(): + raise ValueError("规则 name 不能为空") + if rule.get("type") not in RULE_TYPES: + raise ValueError(f"type 必须是 {RULE_TYPES} 之一") + + # 策略类型: 需要 strategy_id + direction,conditions 可空 + if rule.get("type") == "strategy": + if not rule.get("strategy_id"): + raise ValueError("策略类型规则必须指定 strategy_id") + if rule.get("direction", "entry") not in DIRECTIONS: + raise ValueError(f"direction 必须是 {DIRECTIONS} 之一") + elif rule.get("type") == "ladder": + # 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions + if rule.get("metric", "sealed_vol") not in LADDER_METRICS: + raise ValueError(f"metric 必须是 {LADDER_METRICS} 之一") + if rule.get("direction", "up") not in LADDER_DIRECTIONS: + raise ValueError(f"direction 必须是 {LADDER_DIRECTIONS} 之一 (up=涨停炸板, down=跌停翘板)") + thr = rule.get("threshold") + if not isinstance(thr, (int, float)) or thr < 0: + raise ValueError("threshold 必须是非负数字 (封单 ≤ 此值时报警)") + else: + # 信号/价格/市场类型: 需要 conditions + conds = rule.get("conditions") + if not isinstance(conds, list) or len(conds) == 0: + raise ValueError("conditions 不能为空") + if len(conds) > 8: + raise ValueError("conditions 最多 8 条") + if rule.get("logic", "and") not in LOGICS: + raise ValueError(f"logic 必须是 {LOGICS} 之一") + for i, c in enumerate(conds): + if not isinstance(c, dict): + raise ValueError(f"第 {i+1} 个条件格式错误") + field = c.get("field", "") + op = c.get("op", "") + if op == "truth": + # 布尔信号: field 必须是 signal_/csg_ 前缀 + if not _is_signal_field(field): + raise ValueError(f"第 {i+1} 个条件: op=truth 时 field 必须是信号列 (signal_/csg_ 前缀): {field!r}") + elif op in OPS: + # 阈值比较: field 必须在白名单, 需要 value + if field not in ALLOWED_FIELDS: + raise ValueError(f"第 {i+1} 个条件: 阈值字段 {field!r} 不在白名单") + if not isinstance(c.get("value"), (int, float)): + raise ValueError(f"第 {i+1} 个条件: value 必须是数字") + else: + raise ValueError(f"第 {i+1} 个条件: op {op!r} 非法 (应为 truth 或 {OPS})") + + # scope 校验 + if rule.get("scope", "symbols") not in SCOPES: + raise ValueError(f"scope 必须是 {SCOPES} 之一") + if rule.get("scope") == "symbols": + syms = rule.get("symbols") + if not isinstance(syms, list) or len(syms) == 0: + raise ValueError("scope=symbols 时 symbols 不能为空") + + # 其余枚举 + if rule.get("severity", "info") not in SEVERITIES: + raise ValueError(f"severity 必须是 {SEVERITIES} 之一") + cd = rule.get("cooldown_seconds", 3600) + if not isinstance(cd, int) or cd < 0: + raise ValueError("cooldown_seconds 必须是非负整数") + + +def normalize(rule: dict) -> dict: + """补全默认字段,返回规范化后的规则 (不校验)。""" + r = dict(rule) + r.setdefault("enabled", True) + r.setdefault("scope", "symbols") + r.setdefault("symbols", []) + r.setdefault("sector", None) + r.setdefault("strategy_id", None) + # direction 默认值: ladder 用 "up", 其余用 "entry" + r.setdefault("direction", "up" if r.get("type") == "ladder" else "entry") + r.setdefault("conditions", []) + # ladder 专属默认字段 + r.setdefault("metric", "sealed_vol") + r.setdefault("threshold", 0) + r.setdefault("logic", "and") + r.setdefault("cooldown_seconds", 3600) + r.setdefault("severity", "info") + r.setdefault("message", "") + r.setdefault("webhook_url", "") + r.setdefault("webhook_enabled", False) + r.setdefault("created_at", datetime.now(timezone.utc).isoformat()) + return r + + +# 策略监控自动迁移的规则 id 前缀 (固定, 保证幂等) +STRATEGY_RULE_PREFIX = "mr_strategy_" + + +def strategy_rule_id(strategy_id: str) -> str: + """策略监控规则 id = mr_strategy_{strategy_id}。""" + return f"{STRATEGY_RULE_PREFIX}{strategy_id}" + + +def migrate_strategy_monitors(data_dir: Path, strategy_ids: list[str], strategy_names: dict[str, str]) -> list[dict]: + """把 preferences.strategy_monitor_ids 里的策略,同步生成/更新 type=strategy 规则。 + + 幂等: 已存在的策略规则会被更新 (方向/名称),不会重复创建。 + 已从 strategy_ids 移除的策略, 其规则会被停用 (enabled=False) 而非删除 (保留历史触发记录的关联)。 + + Args: + data_dir: 数据目录 + strategy_ids: 当前监控池中的策略 id 列表 + strategy_names: {strategy_id: 策略名} 用于规则显示名 + Returns: + 本次生成/更新的规则列表 + """ + desired = set(strategy_ids) + existing = load_all(data_dir) + # 已存在的策略规则 {strategy_id: rule} + existing_strategy_rules: dict[str, dict] = {} + for r in existing: + rid = r.get("id", "") + if rid.startswith(STRATEGY_RULE_PREFIX): + sid = rid[len(STRATEGY_RULE_PREFIX):] + if sid: + existing_strategy_rules[sid] = r + + touched: list[dict] = [] + # 1. 为当前监控池的策略 upsert 规则 + for sid in desired: + rule_id = strategy_rule_id(sid) + name = strategy_names.get(sid, sid) + rule = existing_strategy_rules.get(sid) + if rule is None: + rule = normalize({ + "id": rule_id, + "name": f"策略监控 · {name}", + "type": "strategy", + "scope": "all", + "strategy_id": sid, + "direction": "entry", + "conditions": [], + "cooldown_seconds": 3600, + "enabled": True, + }) + else: + rule = dict(rule) + rule["enabled"] = True + rule["strategy_id"] = sid + rule["name"] = f"策略监控 · {name}" + rule.setdefault("scope", "all") + rule.setdefault("direction", "entry") + save_one(data_dir, rule) + touched.append(rule) + + # 2. 不在监控池的策略 → 停用其规则 (不删除) + for sid, rule in existing_strategy_rules.items(): + if sid not in desired and rule.get("enabled") is not False: + rule = dict(rule) + rule["enabled"] = False + save_one(data_dir, rule) + + return touched diff --git a/serve/backend/app/strategy/prompt_builder.py b/serve/backend/app/strategy/prompt_builder.py new file mode 100644 index 0000000..896e3ec --- /dev/null +++ b/serve/backend/app/strategy/prompt_builder.py @@ -0,0 +1,65 @@ +"""策略提示词组装器 — 两步定制流程的提示词生成。 + +职责: 加载对应步骤的 Markdown 指南,拼接用户输入,组装 LLM 提示词。 +不知道: LLM 调用、API、前端、引擎执行。 +""" +from __future__ import annotations + +from pathlib import Path + +_DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs" +_cache: dict[str, str] = {} + + +def _load_doc(name: str) -> str: + if name not in _cache: + path = _DOCS_DIR / name + _cache[name] = path.read_text(encoding="utf-8") if path.exists() else "" + return _cache[name] + + +DIRECTION_CN = {"long": "做多", "short": "做空", "monitor": "监控"} + + +def build_step1(name: str, description: str, direction: str, rules: str, strategy_id: str = "") -> str: + """步骤1:规则 → 完整策略代码(参数 + 信号 + 评分 + 告警) + + 注意: strategy-guide.md 已在 ai_generator.py 的 system prompt 中加载, + 此处不再重复加载以节省 token。 + """ + guide = _load_doc("strategy-builder-step1.md") + + id_line = f"\n策略ID(必须使用此ID):{strategy_id}" if strategy_id else "" + + return f"""{guide} + +--- + +请根据以下用户输入生成完整策略代码: + +策略名称:{name}{id_line} +策略描述:{description} +选股方向:{DIRECTION_CN.get(direction, direction)} +策略规则: +{rules} + +只输出 Python 代码。""" + + +def build_step2(current_code: str, instruction: str) -> str: + """步骤2:修改策略任意部分""" + guide = _load_doc("strategy-builder-step2.md") + + return f"""{guide} + +--- + +当前策略代码: +```python +{current_code} +``` + +用户修改指令: +{instruction} + +只输出修改后的完整 Python 代码。""" diff --git a/serve/backend/app/tickflow/__init__.py b/serve/backend/app/tickflow/__init__.py new file mode 100644 index 0000000..ff7675d --- /dev/null +++ b/serve/backend/app/tickflow/__init__.py @@ -0,0 +1 @@ +"""TickFlow 适配层 — 能力探测 / 调度 / Repository。""" diff --git a/serve/backend/app/tickflow/capabilities.py b/serve/backend/app/tickflow/capabilities.py new file mode 100644 index 0000000..633cd46 --- /dev/null +++ b/serve/backend/app/tickflow/capabilities.py @@ -0,0 +1,76 @@ +"""Capability 定义(§5.1)。 + +业务代码只依赖 CapabilitySet,不读 tiers.yaml,不感知"档位"。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class Cap(StrEnum): + """所有 capability 的命名常量。新增能力时只在这里加一行。""" + + QUOTE_BY_SYMBOL = "quote.by_symbol" + QUOTE_BATCH = "quote.batch" + QUOTE_POOL = "quote.pool" + KLINE_DAILY_BY_SYMBOL = "kline.daily.by_symbol" + KLINE_DAILY_BATCH = "kline.daily.batch" + KLINE_MINUTE_BY_SYMBOL = "kline.minute.by_symbol" + KLINE_MINUTE_BATCH = "kline.minute.batch" + INTRADAY = "intraday" + INTRADAY_BATCH = "intraday.batch" + DEPTH5 = "depth5" + DEPTH5_BATCH = "depth5.batch" + WEBSOCKET = "websocket" + FINANCIAL = "financial" + ADJ_FACTOR = "adj_factor" + + +@dataclass(slots=True, frozen=True) +class CapabilityLimits: + """单个 capability 的运行时限制。""" + rpm: int | None = None # 次/分钟,None 表示未知或不限 + batch: int | None = None # 标的/次 + subscribe: int | None = None # WS 订阅上限 + + +class CapabilitySet: + """探测得到的"用户当前可用能力"。业务代码的唯一真理源。""" + + def __init__(self, caps: dict[Cap, CapabilityLimits] | None = None) -> None: + self._caps: dict[Cap, CapabilityLimits] = dict(caps or {}) + + def has(self, cap: Cap) -> bool: + return cap in self._caps + + def limits(self, cap: Cap) -> CapabilityLimits | None: + return self._caps.get(cap) + + def require(self, cap: Cap) -> CapabilityLimits: + """断言可用,否则抛 CapabilityDenied。""" + if cap not in self._caps: + raise CapabilityDenied(cap) + return self._caps[cap] + + def all(self) -> dict[Cap, CapabilityLimits]: + return dict(self._caps) + + def to_dict(self) -> dict[str, dict]: + return { + str(cap): { + "rpm": lim.rpm, + "batch": lim.batch, + "subscribe": lim.subscribe, + } + for cap, lim in self._caps.items() + } + + +class CapabilityDenied(Exception): + """请求的 capability 当前不可用。""" + + def __init__(self, cap: Cap, suggestion: str | None = None) -> None: + self.cap = cap + self.suggestion = suggestion or f"加购『{cap}』能力可解锁" + super().__init__(f"capability not available: {cap}; {self.suggestion}") diff --git a/serve/backend/app/tickflow/client.py b/serve/backend/app/tickflow/client.py new file mode 100644 index 0000000..d02b608 --- /dev/null +++ b/serve/backend/app/tickflow/client.py @@ -0,0 +1,126 @@ +"""TickFlow SDK 封装(§5)。 + +进程内单例;Key 来源(优先级):secrets.json > .env。 +用户改 Key 后需要 `reset_clients()`,然后 `get_client()` 会拿新的。 + +5 档体系下服务器归属: + - 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 + +import os + +from tickflow import AsyncTickFlow, TickFlow + +from app import secrets_store + +_sync_client: TickFlow | None = None +_async_client: AsyncTickFlow | None = None +_paid_realtime_client: TickFlow | None = None + + +# ===== 服务器归属判定 ===== + +# free-api 服务器默认节点(SDK 默认值),none/free 档运行时走这里。 +FREE_ENDPOINT = "https://free-api.tickflow.org" +# 付费端点默认节点(starter+ 运行时走这里,也是端点切换的默认值)。 +PAID_ENDPOINT = "https://api.tickflow.org" + + +def _should_use_free_server() -> bool: + """是否应走 free-api 服务器。 + + 判定依据:无 key,或当前档位为 none/free。 + 付费档(starter+)走付费端点。 + """ + if not secrets_store.get_tickflow_key(): + return True + # 有 key 时按探测出的档位判定(避免读 capabilities.json 在首次启动前未生成的边界) + from app.tickflow.policy import base_tier_name + return base_tier_name() in ("none", "free") + + +def _base_url() -> str | None: + """从 secrets.json 读取用户自定义端点,没有则返回 None(用 SDK 默认)。""" + return secrets_store.load().get("tickflow_base_url") or None + + +def get_client() -> TickFlow: + """同步客户端。能力探测、盘后管道用。""" + global _sync_client + if _sync_client is None: + key = secrets_store.get_tickflow_key() + if _should_use_free_server(): + # none/free 档:走 free-api 服务器(无 key 或免费 key 被 SDK 忽略) + _sync_client = TickFlow.free() + else: + _sync_client = TickFlow(api_key=key, base_url=_base_url()) + return _sync_client + + +def get_async_client() -> AsyncTickFlow: + """异步客户端。FastAPI 请求路径上用。""" + global _async_client + if _async_client is None: + key = secrets_store.get_tickflow_key() + if _should_use_free_server(): + _async_client = AsyncTickFlow.free() + else: + _async_client = AsyncTickFlow(api_key=key, base_url=_base_url()) + return _async_client + + +def get_paid_realtime_client() -> TickFlow | None: + """实时行情专用付费服务器客户端。 + + none/free 的历史日K仍走 get_client() 的 free-api;实时行情全部走付费服务器。 + Free 档如果有有效 key,也使用这里的 paid endpoint 调按标的实时接口。 + """ + global _paid_realtime_client + key = secrets_store.get_tickflow_key() + if not key: + return None + if _paid_realtime_client is None: + _paid_realtime_client = TickFlow(api_key=key, base_url=_base_url()) + return _paid_realtime_client + + +def reset_clients() -> None: + """Key 变化后调用 — 让下一次 get_client() 拿新实例。""" + global _sync_client, _async_client, _paid_realtime_client + _sync_client = None + _async_client = None + _paid_realtime_client = None + + +def current_mode() -> str: + """供 UI 显示当前模式。三态: + + - "none" : 无 key / 无效 key(走 free-api,仅历史日K) + - "free" : 免费有效 key(走 free-api,仅历史日K) + - "api_key" : 付费 key(starter+,走付费端点,有实时行情) + """ + if not secrets_store.get_tickflow_key(): + return "none" + from app.tickflow.policy import base_tier_name + tier = base_tier_name() + if tier in ("none", "free"): + return "free" if tier == "free" else "none" + return "api_key" + + +def current_endpoint() -> str: + """返回当前显示用的端点 URL(对应 endpoints.json 列表项)。 + + - none/free 档:显示 free-api 服务器节点 + - 付费档:显示用户自定义端点(测速切换后)或默认付费节点 api.tickflow.org + """ + if _should_use_free_server(): + return FREE_ENDPOINT + # 自定义端点(付费模式测速切换后):优先返回 + base = _base_url() + if base: + return base.rstrip("/") + return PAID_ENDPOINT diff --git a/serve/backend/app/tickflow/policy.py b/serve/backend/app/tickflow/policy.py new file mode 100644 index 0000000..0442f48 --- /dev/null +++ b/serve/backend/app/tickflow/policy.py @@ -0,0 +1,549 @@ +"""能力探测 + CapabilitySet 持久化(§5.3)。 + +探测策略:逐 capability 用最小代价请求试探。 + - 成功 → 记录可用,优先取响应头 X-RateLimit-* 否则用 tiers.yaml 默认 + - 抛权限错 → 不可用 + - 抛其他错 → 不可用(谨慎,保留日志) + +Tier Label 算法见 §5.3:基线档 + 补丁能力。 +""" +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from app.config import settings +from app import secrets_store + +from .capabilities import Cap, CapabilityLimits, CapabilitySet + +logger = logging.getLogger(__name__) + +_CAPSET_CACHE_FILE = "capabilities.json" + +# 缓存 schema 版本。capabilities 模型有结构性变更时 bump(如新增/拆分 Cap), +# 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。 +# v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量) +# v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测) +# v5: Free 档补充付费服务器 quote.by_symbol(10rpm/5标的),用于自选股实时监控。 +_CACHE_SCHEMA_VERSION = 5 + +# 探测用最小代价请求:挑流通性最好的 1 只标的试 +_PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市 + + +def _load_tiers_yaml() -> dict[str, dict[str, dict[str, Any]]]: + for path in [settings.tiers_yaml, Path("/app/tiers.yaml"), Path("../tiers.yaml")]: + if path.exists(): + with path.open(encoding="utf-8") as f: + return yaml.safe_load(f) + raise FileNotFoundError("tiers.yaml not found") + + +def _tier_to_capset(tier_def: dict[str, dict[str, Any]]) -> CapabilitySet: + caps: dict[Cap, CapabilityLimits] = {} + for cap_name, limits_dict in tier_def.items(): + try: + cap = Cap(cap_name) + except ValueError: + logger.warning("unknown cap in tiers.yaml: %s", cap_name) + continue + caps[cap] = CapabilityLimits( + rpm=limits_dict.get("rpm"), + batch=limits_dict.get("batch"), + subscribe=limits_dict.get("subscribe"), + ) + return CapabilitySet(caps) + + +def _is_transient(e: Exception) -> bool: + """是否为"可重试的瞬时错误"——网络抖动 / 限流 / 服务端 5xx。 + + 与权限/参数错误(403/401/400/404)区分:后者重试也无用,不重试。 + 用类名匹配而非 import SDK 异常,避免探测期对 SDK 内部耦合。 + """ + cls = e.__class__.__name__ + if cls in { + "RateLimitError", "InternalServerError", "APIError", + "ConnectionError", "TimeoutError", "ConnectError", + "ConnectTimeout", "ReadTimeout", "RemoteProtocolError", + "httpx.ConnectError", "httpx.TimeoutException", + }: + return True + # APIError 体系下,status_code 5xx/429 视为瞬时 + status = getattr(e, "status_code", None) + if isinstance(status, int) and (status == 429 or status >= 500): + return True + return False + + +def _call_with_retry(fn, attempts: int = 3, backoff: float = 0.6) -> None: + """调用 fn();对瞬时错误退避重试,权限/参数错误立即抛出。 + + attempts=总尝试次数(含首次)。返回 None,异常由调用方分类。 + """ + last_exc: Exception | None = None + for i in range(attempts): + try: + fn() + return + except Exception as e: # noqa: BLE001 + last_exc = e + # 权限/参数类错误:重试无意义,立即抛出交给 try_call 归类 + if not _is_transient(e): + raise + # 瞬时错误:最后一轮不再 sleep + if i < attempts - 1: + time.sleep(backoff * (i + 1)) + # 重试耗尽,抛出最后一次异常 + assert last_exc is not None + raise last_exc + + +def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: + """逐 capability 试探。需要 API key。 + + **关键**:探测始终在付费端点(api.tickflow.org)上进行,用 key 鉴权验证有效性。 + 绝不能读旧 capabilities 缓存的档位来选服务器 —— 否则首次保存 key 时, + 旧缓存是 none 档 → get_client() 返回 free 服务器 → free 服务器忽略 key → + 乱填 key 也能拿到日K → 误判成 free 档(鸡生蛋蛋生鸡的循环依赖 bug)。 + + 返回 (capset, probe_log)。 + """ + from tickflow import TickFlow + from .client import _base_url, PAID_ENDPOINT + + key = secrets_store.get_tickflow_key() + # 探测专用客户端:强制走付费端点验证 key。 + # 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] = {} + log: list[str] = [] + + def try_call(cap: Cap, fn, default_limits: dict[str, Any]) -> None: + try: + _call_with_retry(fn) + available[cap] = CapabilityLimits( + rpm=default_limits.get("rpm"), + batch=default_limits.get("batch"), + subscribe=default_limits.get("subscribe"), + ) + log.append(f"✓ {cap}") + except Exception as e: # noqa: BLE001 + msg = str(e).lower() + cls = e.__class__.__name__ + # PermissionError 类名 / HTTP 403 / 中英文权限关键词都算"明确无权限" + is_perm_denied = ( + cls in {"PermissionError", "AuthorizationError"} + or "permission" in msg or "unauthorized" in msg + or "403" in msg or "forbidden" in msg + or "套餐" in msg or "权限" in msg or "需要" in msg + ) + if is_perm_denied: + log.append(f"✗ {cap}(无权限)") + else: + # 重试耗尽仍失败的瞬时错误 — 标记为疑似,而非直接判定"无此能力" + log.append(f"? {cap} ({cls}: {e})") + + # 用各档默认上限作为占位(无 X-RateLimit-* 头时) + # 取所有档的并集,逐 cap 试探 + all_caps_defaults: dict[str, dict[str, Any]] = {} + for tier in ("free", "starter", "pro", "expert"): + for cap_name, lim in tiers.get(tier, {}).items(): + all_caps_defaults.setdefault(cap_name, lim) + + def defaults(cap: Cap) -> dict[str, Any]: + return all_caps_defaults.get(str(cap), {}) + + # 全部用 keyword-only 形式调用,符合 SDK 真实签名 + # quote.by_symbol + try_call(Cap.QUOTE_BY_SYMBOL, + lambda: tf.quotes.get(symbols=[_PROBE_SYMBOL], as_dataframe=False), + defaults(Cap.QUOTE_BY_SYMBOL)) + + # quote.batch — 批量行情(POST /v1/quotes)。用 get_by_symbols 试探。 + try_call(Cap.QUOTE_BATCH, + lambda: tf.quotes.get_by_symbols([_PROBE_SYMBOL], as_dataframe=False), + defaults(Cap.QUOTE_BATCH)) + + # quote.pool — 用一个真实存在的 universe id 试探。 + # universes.list() 在 Free 也开放,先拿任意一个 universe id 再用 get_by_universes 试。 + def _probe_pool(): + unis = tf.universes.list() + if not unis: + raise RuntimeError("no universes available") + first_id = unis[0]["id"] if isinstance(unis[0], dict) else getattr(unis[0], "id") + return tf.quotes.get_by_universes([first_id], as_dataframe=False) + + try_call(Cap.QUOTE_POOL, _probe_pool, defaults(Cap.QUOTE_POOL)) + + # kline.daily.by_symbol — Free 也有 + try_call(Cap.KLINE_DAILY_BY_SYMBOL, + lambda: tf.klines.get(_PROBE_SYMBOL, period="1d", count=1, as_dataframe=False), + defaults(Cap.KLINE_DAILY_BY_SYMBOL)) + + # kline.daily.batch + try_call(Cap.KLINE_DAILY_BATCH, + lambda: tf.klines.batch([_PROBE_SYMBOL], period="1d", count=1, as_dataframe=False), + defaults(Cap.KLINE_DAILY_BATCH)) + + # kline.minute.by_symbol + try_call(Cap.KLINE_MINUTE_BY_SYMBOL, + lambda: tf.klines.get(_PROBE_SYMBOL, period="1m", count=1, as_dataframe=False), + defaults(Cap.KLINE_MINUTE_BY_SYMBOL)) + + # kline.minute.batch + try_call(Cap.KLINE_MINUTE_BATCH, + lambda: tf.klines.batch([_PROBE_SYMBOL], period="1m", count=1, as_dataframe=False), + defaults(Cap.KLINE_MINUTE_BATCH)) + + # intraday + try_call(Cap.INTRADAY, + lambda: tf.klines.intraday(_PROBE_SYMBOL, count=1, as_dataframe=False), + defaults(Cap.INTRADAY)) + + # intraday.batch + try_call(Cap.INTRADAY_BATCH, + lambda: tf.klines.intraday_batch([_PROBE_SYMBOL], count=1, as_dataframe=False), + defaults(Cap.INTRADAY_BATCH)) + + # depth5 — 按标的查(单只) + try_call(Cap.DEPTH5, + lambda: tf.depth.get(_PROBE_SYMBOL), + defaults(Cap.DEPTH5)) + + # depth5.batch — 批量查(SDK 0.1.23+ 提供 depth.batch,对应官方 /v1/depth/batch 端点) + try_call(Cap.DEPTH5_BATCH, + lambda: tf.depth.batch([_PROBE_SYMBOL]), + defaults(Cap.DEPTH5_BATCH)) + + # financial — SDK 提供 income / balance_sheet / cash_flow / metrics / shares + # 用 metrics 探测(单据最小) + try_call(Cap.FINANCIAL, + lambda: tf.financials.metrics([_PROBE_SYMBOL], latest=True, as_dataframe=False), + defaults(Cap.FINANCIAL)) + + # adj_factor — 实际在 klines.ex_factors + try_call(Cap.ADJ_FACTOR, + lambda: tf.klines.ex_factors([_PROBE_SYMBOL], as_dataframe=False), + defaults(Cap.ADJ_FACTOR)) + + # websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断 + # 若 expert 的其他 cap 都通,则推断 websocket 也可用 + if (Cap.FINANCIAL in available and Cap.INTRADAY_BATCH in available): + available[Cap.WEBSOCKET] = CapabilityLimits( + subscribe=defaults(Cap.WEBSOCKET).get("subscribe", 100), + ) + log.append("✓ websocket (inferred from expert tier)") + + return CapabilitySet(available), log + + +def detect_capabilities(force: bool = False) -> CapabilitySet: + """探测当前 API Key 的能力集。""" + cache_path = settings.data_dir / _CAPSET_CACHE_FILE + if not force and cache_path.exists(): + with cache_path.open(encoding="utf-8") as f: + cached = json.load(f) + # schema 版本校验:旧缓存或缺版本号 → 过期,丢弃后重新探测 + if cached.get("schema_version") == _CACHE_SCHEMA_VERSION: + return _capset_from_json(cached) + logger.info("capabilities 缓存 schema 版本过期(缓存=%s, 当前=%d), 重新探测", + cached.get("schema_version"), _CACHE_SCHEMA_VERSION) + + tiers = _load_tiers_yaml() + if settings.use_free_mode: + # 无 key —— 归 none 档(走 free-api 服务器,仅历史日K) + capset = _tier_to_capset(tiers["none"]) + _persist(capset, "None", log=["无 API Key(无档 · free-api 服务器)"], missing=[], extras=[]) + return capset + + # 有 API key — 真实探测 + try: + capset, probe_log = _probe_real(tiers) + # 判定档位:无效 key → none,免费 key → free,付费 → starter/pro/expert + classified = _classify_tier(capset, tiers) + if classified.is_invalid: + # 无效 key(连单只日K都拿不到):归 none 档,标记要求清除 key + capset = _tier_to_capset(tiers["none"]) + probe_log.append("⚠ Key 无效(单只日K也无法获取),判定为无档") + _persist(capset, "None", log=probe_log, missing=[], extras=[], invalid_key=True) + return capset + if classified.is_free: + # 免费有效 key:按 free 档能力持久化(日K free-api + 按标的实时)。 + capset = _tier_to_capset(tiers["free"]) + _persist(capset, "Free", log=probe_log + ["✓ 免费有效 key(运行时走 free-api 服务器)"], missing=[], extras=[]) + return capset + # 付费档(starter+) — 探测出的能力即为真实可用 + label, missing, extras = _compute_label_and_missing(capset, tiers) + capset = _override_limits_with_detected_tier(capset, label, tiers) + _persist(capset, label, log=probe_log, missing=missing, extras=extras) + return capset + except Exception as e: + logger.exception("detect_capabilities failed; using none baseline: %s", e) + capset = _tier_to_capset(tiers["none"]) + _persist(capset, "None(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[]) + return capset + + +# ===== Tier 代表性 capability(signature caps)===== +# 拥有**任意一个**即认作该档及以上。自上而下匹配。 +# 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。 +TIER_SIGNATURES: dict[str, set[Cap]] = { + "expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH, Cap.WEBSOCKET}, + "pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL, + Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH}, + "starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH, + Cap.ADJ_FACTOR, Cap.QUOTE_POOL}, + # free / none 不需 signature — 由 _classify_tier 的分水岭逻辑判定 +} + + +@dataclass(slots=True, frozen=True) +class TierClassification: + """档位判定结果。 + + 判定依据是"复权因子分水岭": + - 连单只日K都没有 → 无效 key(is_invalid),归 none 档 + - 有单只日K、无复权因子 → 免费 key(is_free) + - 有复权因子 → 付费档(starter+),具体档位由 signature 决定 + """ + + tier: str # "none" / "free" / "starter" / "pro" / "expert" + is_invalid: bool # 无效 key(连单只日K都拿不到) + is_free: bool # 免费有效 key(有日K、无复权因子) + + +def _classify_tier(capset: CapabilitySet, tiers: dict) -> TierClassification: + """根据探测出的能力集判定档位。 + + 分水岭是 KLINE_DAILY_BY_SYMBOL(单只日K)与 ADJ_FACTOR(复权因子): + - 无单只日K → none(无效 key) + - 有日K无复权 → free(免费 key) + - 有复权因子 → 走 signature 判定 starter/pro/expert + """ + held = set(capset.all().keys()) + + # 1) 连单只日K都没有 → 无效 key + if Cap.KLINE_DAILY_BY_SYMBOL not in held: + return TierClassification(tier="none", is_invalid=True, is_free=False) + + # 2) 有日K但无复权因子 → 免费 key + if Cap.ADJ_FACTOR not in held: + return TierClassification(tier="free", is_invalid=False, is_free=True) + + # 3) 有复权因子 → 付费档,按 signature 自上而下判定 + if held & TIER_SIGNATURES["expert"]: + base = "expert" + elif held & TIER_SIGNATURES["pro"]: + base = "pro" + elif held & TIER_SIGNATURES["starter"]: + base = "starter" + else: + # 有复权因子但无任何代表能力 — 兜底为 starter(复权本身是 starter 特征) + base = "starter" + return TierClassification(tier=base, is_invalid=False, is_free=False) + +# 补丁友好命名(label 后缀用) +_CAP_ALIASES: dict[Cap, str] = { + Cap.KLINE_MINUTE_BATCH: "分钟K", + Cap.KLINE_MINUTE_BY_SYMBOL: "分钟K", + Cap.INTRADAY: "分时", + Cap.INTRADAY_BATCH: "批量分时", + Cap.DEPTH5: "五档", + Cap.DEPTH5_BATCH: "批量五档", + Cap.WEBSOCKET: "WS", + Cap.FINANCIAL: "财务", + Cap.ADJ_FACTOR: "复权", + Cap.QUOTE_BATCH: "批量行情", + Cap.QUOTE_POOL: "标的池", + Cap.KLINE_DAILY_BATCH: "日K批量", +} + + +def _override_limits_with_detected_tier( + capset: CapabilitySet, label: str, tiers: dict, +) -> CapabilitySet: + """探测完成后,用判档对应的 limits 覆盖每个 cap 的速率/批量。 + + 判档前每个 cap 用的是"所有档默认值的并集"(为了不漏数据), + 判档后才知道用户真实档位,limits 用该档的实际值更准。 + label 可能是 "Pro" / "Pro + 分钟K" / "Pro+" 等组合形式 — 取第一个词当作基线档名。 + """ + base_name = label.split()[0].split("+")[0].strip().lower() # "Pro + 分钟K" → "pro" + tier_limits = tiers.get(base_name, {}) + new_caps: dict[Cap, CapabilityLimits] = {} + for cap, _old_lim in capset.all().items(): + spec = tier_limits.get(cap.value) + if spec: + new_caps[cap] = CapabilityLimits( + rpm=spec.get("rpm"), + batch=spec.get("batch"), + subscribe=spec.get("subscribe"), + ) + else: + # 不在该档定义里(extras),用 expert 档兜底(最宽松) + expert_spec = tiers.get("expert", {}).get(cap.value, {}) + new_caps[cap] = CapabilityLimits( + rpm=expert_spec.get("rpm"), + batch=expert_spec.get("batch"), + subscribe=expert_spec.get("subscribe"), + ) + return CapabilitySet(new_caps) + + +def _tier_caps_set(tiers: dict, tier_name: str) -> set[Cap]: + """读 tiers.yaml 的某档定义,转为 Cap 集合。""" + return {Cap(c) for c in tiers.get(tier_name, {}).keys() if c in {x.value for x in Cap}} + + +def _compute_label_and_missing( + capset: CapabilitySet, tiers: dict, +) -> tuple[str, list[str], list[str]]: + """返回 (label, missing_caps, extra_caps)。 + + label:档位标签。 + missing_caps:本档**应有但未探测到**的 capability(用于诊断:可能是探测 bug 或权限丢失)。 + extra_caps:超出本档的额外 capability(自定义组合)。 + """ + held = set(capset.all().keys()) + + # 1) 完全匹配 — 干净命中某档 + for tier_name in ["free", "starter", "pro", "expert"]: + if held == _tier_caps_set(tiers, tier_name): + return tier_name.capitalize(), [], [] + + # 2) 按 signature 自上而下判档 + if held & TIER_SIGNATURES["expert"]: + base = "expert" + elif held & TIER_SIGNATURES["pro"]: + base = "pro" + elif held & TIER_SIGNATURES["starter"]: + base = "starter" + else: + base = "free" + + base_caps = _tier_caps_set(tiers, base) + missing = sorted(c.value for c in (base_caps - held)) + extras = base_caps and (held - base_caps) or set() # extras 是超出该档的部分 + + # 实际超出 = held 中"既不属于本档、也不属于本档下方任何档"的 cap + # 简化:extras = held - base_caps + extras_set = held - base_caps + + # 3) 拼 label + if not extras_set: + # 完全在本档内(可能缺一两项 — 由 missing 反映) + return base.capitalize(), missing, [] + + # 补丁过多 → 用 "≈" 形式 + if len(extras_set) > 3: + return f"{base.capitalize()}+", missing, sorted(c.value for c in extras_set) + + suffix = sorted({_CAP_ALIASES.get(e, str(e)) for e in extras_set}) + return f"{base.capitalize()} + " + " + ".join(suffix), missing, sorted(c.value for c in extras_set) + + +def _compute_label(capset: CapabilitySet, tiers: dict) -> str: + """对外简化签名 — 只要 label。""" + label, _missing, _extras = _compute_label_and_missing(capset, tiers) + return label + + +def _persist( + capset: CapabilitySet, + label: str, + log: list[str] | None = None, + missing: list[str] | None = None, + extras: list[str] | None = None, + invalid_key: bool = False, +) -> None: + settings.data_dir.mkdir(parents=True, exist_ok=True) + cache_path = settings.data_dir / _CAPSET_CACHE_FILE + payload = { + "schema_version": _CACHE_SCHEMA_VERSION, + "label": label, + "capabilities": capset.to_dict(), + "probe_log": log or [], + "missing_caps": missing or [], # 本档应有但未探测到 + "extras_caps": extras or [], # 超出本档的额外能力 + "invalid_key": invalid_key, # 探测出的 key 无效(连单只日K都拿不到) + } + with cache_path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def _capset_from_json(data: dict[str, Any]) -> CapabilitySet: + caps: dict[Cap, CapabilityLimits] = {} + for cap_name, lim in data.get("capabilities", {}).items(): + try: + cap = Cap(cap_name) + except ValueError: + continue + caps[cap] = CapabilityLimits( + rpm=lim.get("rpm"), + batch=lim.get("batch"), + subscribe=lim.get("subscribe"), + ) + return CapabilitySet(caps) + + +def tier_label() -> str: + cache_path = settings.data_dir / _CAPSET_CACHE_FILE + if cache_path.exists(): + with cache_path.open(encoding="utf-8") as f: + return json.load(f).get("label", "Unknown") + return "Unknown" + + +def probe_log() -> list[str]: + cache_path = settings.data_dir / _CAPSET_CACHE_FILE + if cache_path.exists(): + with cache_path.open(encoding="utf-8") as f: + return json.load(f).get("probe_log", []) + return [] + + +def missing_caps() -> list[str]: + """本档应有但未探测到的 capability — 通常意味着探测有 bug 或权限边界。""" + cache_path = settings.data_dir / _CAPSET_CACHE_FILE + if cache_path.exists(): + with cache_path.open(encoding="utf-8") as f: + return json.load(f).get("missing_caps", []) + return [] + + +def extras_caps() -> list[str]: + cache_path = settings.data_dir / _CAPSET_CACHE_FILE + if cache_path.exists(): + with cache_path.open(encoding="utf-8") as f: + return json.load(f).get("extras_caps", []) + return [] + + +def is_invalid_key() -> bool: + """最近一次探测是否判定 key 无效(连单只日K都拿不到)。 + + settings 层据此清除已存的 key,避免乱填的 key 被持久化。 + """ + cache_path = settings.data_dir / _CAPSET_CACHE_FILE + if cache_path.exists(): + with cache_path.open(encoding="utf-8") as f: + return bool(json.load(f).get("invalid_key", False)) + return False + + +def base_tier_name() -> str: + """当前档位的基础名(小写): none / free / starter / pro / expert。 + + 供 client 层判断"是否走 free-api 服务器"(none/free → free 服务器)。 + """ + label = tier_label() + return label.split()[0].split("+")[0].strip().lower() diff --git a/serve/backend/app/tickflow/pools.py b/serve/backend/app/tickflow/pools.py new file mode 100644 index 0000000..77a45d9 --- /dev/null +++ b/serve/backend/app/tickflow/pools.py @@ -0,0 +1,158 @@ +"""标的池(Universe)定义(§6.3)。 + +Phase 1 实现: + - 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存 + - 全 A 通过 instruments.batch 获取 + - 自选池 = 用户的 watchlist +""" +from __future__ import annotations + +import logging +from datetime import date +from pathlib import Path +from typing import Literal + +import polars as pl + +from app.config import settings +from app.tickflow.client import get_client + +logger = logging.getLogger(__name__) + +PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index", "watchlist"] + +# TickFlow universe id 是它内部命名(见 tf.universes.list())。 +# 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。 +# 常见名:沪深300 / 中证500 / 上证50 / 全 A +_POOL_NAME_HINTS = { + "CSI300": ["沪深300", "HS300", "CSI300"], + "CSI500": ["中证500", "ZZ500", "CSI500"], + "SSE50": ["上证50", "SH50", "SSE50"], +} + + +def _find_universe_id(hints: list[str]) -> str | None: + """从 universes.list() 里按 name/id 子串匹配找一个 universe id。""" + try: + tf = get_client() + unis = tf.universes.list() + except Exception as e: # noqa: BLE001 + logger.warning("universes.list failed: %s", e) + return None + for u in unis or []: + item = u if isinstance(u, dict) else {"id": getattr(u, "id", ""), "name": getattr(u, "name", "")} + haystack = (item.get("id", "") + " " + item.get("name", "")).lower() + for h in hints: + if h.lower() in haystack: + return item["id"] + return None + + +def _pool_cache_path(pool_id: str) -> Path: + return settings.data_dir / "pools" / f"{pool_id}.parquet" + + +def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]: + """返回标的池里的 symbol 列表。""" + if pool_id == "watchlist": + return _load_watchlist() + + cache = _pool_cache_path(pool_id) + if cache.exists() and not refresh: + df = pl.read_parquet(cache) + return df["symbol"].to_list() + + symbols = _fetch_pool(pool_id) + if symbols: + cache.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame({"symbol": symbols, "as_of": [date.today()] * len(symbols)}).write_parquet(cache) + return symbols + + +def _fetch_pool(pool_id: PoolId) -> list[str]: + """从 TickFlow 拉取池成份。 + + 实现:先用 universes.list 找到 universe id,再 quotes.get_by_universes 拉成份。 + """ + tf = get_client() + + if pool_id in _POOL_NAME_HINTS: + uid = _find_universe_id(_POOL_NAME_HINTS[pool_id]) + if not uid: + logger.warning("无法在 TickFlow universes 列表里匹配到 %s", pool_id) + return [] + try: + df = tf.quotes.get_by_universes([uid], as_dataframe=True) + if df is not None and len(df) > 0 and "symbol" in df.columns: + return df["symbol"].astype(str).tolist() + except Exception as e: # noqa: BLE001 + logger.warning("fetch pool %s via universe %s failed: %s", pool_id, uid, e) + + if pool_id == "CN_Equity_A": + # 全 A — 优先直接用 CN_Equity_A universe (包含沪深京三市) + uid = _find_universe_id(["CN_Equity_A", "沪深京A股", "全A"]) + if uid: + try: + df = tf.quotes.get_by_universes([uid], as_dataframe=True) + if df is not None and len(df) > 0 and "symbol" in df.columns: + return sorted(set(df["symbol"].astype(str).tolist())) + except Exception as e: # noqa: BLE001 + logger.warning("fetch CN_Equity_A via universe %s failed: %s", uid, e) + + # fallback: 聚合申万一级行业 (覆盖度较低, 缺北交所/新股) + try: + unis = tf.universes.list() + except Exception as e: # noqa: BLE001 + logger.warning("universes.list failed: %s", e) + unis = [] + sw1_ids = [] + for u in unis or []: + item = u if isinstance(u, dict) else {"id": getattr(u, "id", "")} + uid = item.get("id", "") + if "SW1_" in uid: + sw1_ids.append(uid) + if sw1_ids: + try: + df = tf.quotes.get_by_universes(sw1_ids, as_dataframe=True) + if df is not None and "symbol" in df.columns: + return sorted(set(df["symbol"].astype(str).tolist())) + except Exception as e: # noqa: BLE001 + logger.warning("aggregate SW1 fetch failed: %s", e) + + if pool_id == "CN_Index": + uid = _find_universe_id(["CN_Index", "沪深指数", "指数"]) + ids = [uid] if uid else ["CN_Index"] + try: + df = tf.quotes.get_by_universes(ids, as_dataframe=True) + if df is not None and len(df) > 0 and "symbol" in df.columns: + return sorted(set(df["symbol"].astype(str).tolist())) + except Exception as e: # noqa: BLE001 + logger.warning("fetch CN_Index via universe %s failed: %s", ids, e) + + return [] + + +def _load_watchlist() -> list[str]: + """读取用户自选(由 watchlist service 维护)。""" + path = settings.data_dir / "user_data" / "watchlist.parquet" + if not path.exists(): + return [] + df = pl.read_parquet(path) + if df.is_empty() or "symbol" not in df.columns: + return [] + return df["symbol"].to_list() + + +# 兜底:Free 用户/无 API 时给一个小型可用集合,让 UI 不至于空白 +DEMO_SYMBOLS = [ + "600000.SH", # 浦发银行 + "600036.SH", # 招商银行 + "600519.SH", # 贵州茅台 + "601318.SH", # 中国平安 + "601398.SH", # 工商银行 + "000001.SZ", # 平安银行 + "000333.SZ", # 美的集团 + "000651.SZ", # 格力电器 + "000858.SZ", # 五粮液 + "002594.SZ", # 比亚迪 +] diff --git a/serve/backend/app/tickflow/repository.py b/serve/backend/app/tickflow/repository.py new file mode 100644 index 0000000..7ddd521 --- /dev/null +++ b/serve/backend/app/tickflow/repository.py @@ -0,0 +1,1494 @@ +"""Repository 层(§7.4)。 + +数据分层: + - DuckDB 视图: 冷查询(统计、元数据、用户自定义SQL) + - Polars 缓存: 热路径(enriched 最新日 ~5500行 + instruments ~5500行) + - Polars scan_parquet: 分钟K/历史日K (predicate pushdown) + +缓存生命周期: + - startup 时不加载(数据可能为空) + - pipeline 完成后调用 refresh_cache() + - 服务层通过 get_enriched_latest() / get_instruments() 获取缓存 +""" +from __future__ import annotations + +import logging +import sys +import threading +from datetime import date +from pathlib import Path + +import duckdb +import polars as pl + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class DataStore: + """唯一的存储入口 — 进程启动时创建。""" + + def __init__(self, data_dir: Path | None = None) -> None: + 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", + "backtest_results", + "screener_results", + "ai_cache", + "user_data", + "depth5", + ): + (self.data_dir / sub).mkdir(parents=True, exist_ok=True) + + # 财务数据子目录 + for sub in ("metrics", "income", "balance_sheet", "cash_flow"): + (self.data_dir / "financials" / sub).mkdir(parents=True, exist_ok=True) + + # DuckDB 内存模式 — 不建 .db 文件(§7.1) + 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() + statements = [ + f"""CREATE OR REPLACE VIEW kline_daily AS + SELECT * FROM read_parquet('{d}/kline_daily/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_enriched AS + SELECT * FROM read_parquet('{d}/kline_daily_enriched/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_index_daily AS + 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 + SELECT * FROM read_parquet('{d}/kline_ext/**/*.parquet', union_by_name=true)""", + # 财务数据视图 + f"""CREATE OR REPLACE VIEW financials_metrics AS + SELECT * FROM read_parquet('{d}/financials/metrics/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW financials_income AS + SELECT * FROM read_parquet('{d}/financials/income/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW financials_balance_sheet AS + SELECT * FROM read_parquet('{d}/financials/balance_sheet/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW financials_cash_flow AS + SELECT * FROM read_parquet('{d}/financials/cash_flow/*.parquet', union_by_name=true)""", + # 五档盘口 sealed 真假涨停(独立旁路存储,不进 enriched) + f"""CREATE OR REPLACE VIEW depth5 AS + SELECT * FROM read_parquet('{d}/depth5/**/*.parquet', union_by_name=true)""", + ] + for sql in statements: + try: + 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: + """日 K / 分钟 K 的读写入口。""" + + def __init__(self, store: DataStore) -> None: + self.store = store + self.db = store.db + self._lock = threading.Lock() + + # ---- Polars 缓存 ---- + self._enriched_cache: pl.DataFrame | None = None # 最新一天 (~5500行) + 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 非线程安全,所有读路径须走此方法。""" + with self._lock: + return self.db.execute(sql, params or []).fetchall() + + def execute_one(self, sql: str, params: list | None = None) -> tuple | None: + """线程安全的 SELECT → fetchone。""" + with self._lock: + return self.db.execute(sql, params or []).fetchone() + + # ================================================================ + # Polars 缓存管理 + # ================================================================ + + def refresh_cache(self) -> None: + """刷新 Polars 缓存。在 pipeline 完成后、服务启动时调用。""" + self._refresh_instruments() + self._refresh_index_instruments() + self._refresh_etf_instruments() + self._refresh_enriched() + + def clear_cache(self) -> None: + """清空所有 Polars 内存缓存。 + + 与 refresh_cache 的区别: refresh_cache 在磁盘无数据时会提前 return, + 导致内存里的旧缓存残留 (clear 数据后看板仍显示旧数据的根因)。 + 本方法无条件清空, 供清除数据/重置场景调用。 + """ + self._enriched_cache = None + self._enriched_cache_date = None + self._enriched_history_cache = None + 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 最新日到内存 + 构建聚合表。 + + enriched parquet 仅存 14 列基础数据。启动时读入历史数据并即时计算完整指标, + 将结果缓存在内存中供各服务使用。 + + 优化: 扩大历史读取范围, 同时缓存完整历史 (含指标), 供 filter_history 策略直接复用。 + """ + try: + latest = self._latest_enriched_date_duckdb() + if not latest: + # 磁盘已无数据: 必须清空内存缓存, 否则旧数据会残留 + # (清数据后看板仍显示旧数据的根因) + self.clear_cache() + return + + # Step 1: 直接读最新日期的分区文件 (仅 14 列) + enriched_dir = self.store.data_dir / "kline_daily_enriched" + ds = latest.isoformat() if hasattr(latest, "isoformat") else str(latest) + target_parquet = enriched_dir / f"date={ds}" / "part.parquet" + + if not target_parquet.exists(): + return + + df_latest = pl.read_parquet(target_parquet) + if df_latest.is_empty(): + return + + # Step 2: 读近 300 天 14 列数据 → compute → filter(latest) → 缓存 + # 300 日历天 ≈ 210 交易日, 覆盖 filter_history 最大 lookback(90) + warmup(60) + try: + from datetime import timedelta + from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_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] + lf = ( + pl.scan_parquet(self._enriched_glob) + .filter(pl.col("date") >= start_full) + .sort(["symbol", "date"]) + ) + df_hist = lf.select(read_cols).collect() + if not df_hist.is_empty(): + instruments = self._instruments_cache if self._instruments_cache is not None else pl.DataFrame() + df_full = compute_indicators(df_hist) + df_full = compute_signals(df_full) + if instruments is not None and not instruments.is_empty(): + df_full = compute_limit_signals(df_full, instruments) + + # JOIN instruments 到完整历史 (filter_history/basic_filter 需要 name/股本等列) + if instruments is not None and not instruments.is_empty(): + inst_cols = [c for c in ["name", "total_shares", "float_shares"] + if c in instruments.columns and c not in df_full.columns] + if inst_cols: + df_full = df_full.join( + instruments.select(["symbol", *inst_cols]).unique(subset=["symbol"]), + on="symbol", + how="left", + ) + + # 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用 + self._enriched_history_cache = df_full + self._enriched_history_start = df_full["date"].min() + logger.info("enriched 历史缓存: %d rows, %s ~ %s", + len(df_full), self._enriched_history_start, latest) + + # 只取最新一天作为 enriched_cache + df_today = df_full.filter(pl.col("date") == latest) + if not df_today.is_empty(): + self._enriched_cache = df_today + self._enriched_cache_date = latest + # 构建盘中递推基准: 若最新分区是今天的实时盘中数据, + # 递推状态必须停在上一交易日, 不能把今天作为“昨日”。 + self._build_live_agg(self._live_agg_baseline_date(latest)) + logger.info("enriched 缓存已计算: %d 只, 日期 %s (即时计算)", len(df_today), latest) + return + except Exception as e: # noqa: BLE001 + logger.warning("enriched 即时计算失败, 使用原始 14 列缓存: %s", e) + + # 降级: 直接使用 14 列数据 + 构建 live_agg + self._enriched_cache = df_latest + self._enriched_cache_date = latest + self._build_live_agg(self._live_agg_baseline_date(latest)) + + logger.info("enriched 缓存已加载: %d 只, 日期 %s", len(df_latest), latest) + except Exception as e: # noqa: BLE001 + logger.warning("enriched 缓存刷新失败: %s", e) + + def _build_live_agg(self, latest: date) -> None: + """从 OHLCV 即时计算递推状态 + 窗口聚合, 构建盘中实时聚合表。 + + 优化: 优先使用 _enriched_history_cache (启动时已计算), 避免重复 compute_indicators。 + """ + from datetime import timedelta + from app.indicators.pipeline import _ema_alpha + + start_60d = latest - timedelta(days=90) # 日历90天 ≈ 60个交易日 + + # 优先使用已有的历史缓存 (避免重复 scan_parquet + compute_indicators) + if self._enriched_history_cache is not None and not self._enriched_history_cache.is_empty(): + hist_all = self._enriched_history_cache + if "date" in hist_all.columns and hist_all["date"].min() <= start_60d: + # 从历史缓存中提取所需列 (历史缓存已有指标列) + base_cols = ["symbol", "date", "open", "high", "low", "close", "volume", + "raw_close", "raw_high", "raw_low"] + needed = [c for c in base_cols if c in hist_all.columns] + df_hist = hist_all.filter( + (pl.col("date") >= start_60d) & (pl.col("date") <= latest) + ).select(needed).sort(["symbol", "date"]) + + # 用历史缓存的指标列提取最新日状态 (无需再次 compute_indicators) + state_source = hist_all.filter(pl.col("date") == latest) + + state_cols = [ + "symbol", + "ema5", "ema10", "ema20", "ema30", "ema60", + "macd_dea", + "kdj_k", "kdj_d", + "atr_14", + "close", "high", "low", + "annual_vol_20d", + ] + existing_state = [c for c in state_cols if c in state_source.columns] + agg_a = state_source.select(existing_state) + else: + df_hist = pl.DataFrame() + agg_a = pl.DataFrame() + else: + # 降级: 读 parquet + compute_indicators + df_hist, agg_a = self._build_live_agg_from_parquet(latest, start_60d) + + if df_hist.is_empty(): + self._live_agg_cache = pl.DataFrame() + self._live_agg_cache_date = None + return + + if agg_a.is_empty(): + self._live_agg_cache = pl.DataFrame() + self._live_agg_cache_date = None + return + + # 单独计算 _ema12 / _ema26 (compute_indicators 内部会 drop 掉) + df_ema = df_hist.sort(["symbol", "date"]).with_columns([ + pl.col("close").ewm_mean(alpha=_ema_alpha(12), adjust=False).over("symbol").alias("_ema12"), + pl.col("close").ewm_mean(alpha=_ema_alpha(26), adjust=False).over("symbol").alias("_ema26"), + ]).filter(pl.col("date") == latest).select("symbol", "_ema12", "_ema26") + + agg_a = agg_a.join(df_ema, on="symbol", how="inner") + + # 单独计算 RSI 状态列 (compute_indicators 内部会 drop 掉) + df_rsi_base = df_hist.sort(["symbol", "date"]).with_columns( + pl.col("close").diff().over("symbol").alias("_daily_delta") + ) + gain = pl.when(pl.col("_daily_delta") > 0).then(pl.col("_daily_delta")).otherwise(0.0) + loss = pl.when(pl.col("_daily_delta") < 0).then(-pl.col("_daily_delta")).otherwise(0.0) + rsi_exprs = [] + for n in (6, 14, 24): + a = 1.0 / n + rsi_exprs.append(gain.ewm_mean(alpha=a, adjust=False).over("symbol").alias(f"_rsi_avg_gain_{n}")) + rsi_exprs.append(loss.ewm_mean(alpha=a, adjust=False).over("symbol").alias(f"_rsi_avg_loss_{n}")) + df_rsi = ( + df_rsi_base + .with_columns(rsi_exprs) + .filter(pl.col("date") == latest) + .select("symbol", *[f"_rsi_avg_gain_{n}" for n in (6, 14, 24)], + *[f"_rsi_avg_loss_{n}" for n in (6, 14, 24)]) + ) + agg_a = agg_a.join(df_rsi, on="symbol", how="inner") + + # 前复权因子: adj_factor = close(复权) / raw_close(原始) + if "raw_close" in df_hist.columns: + adj_factor_df = ( + df_hist.filter(pl.col("date") == latest) + .select("symbol", (pl.col("close") / pl.col("raw_close")).alias("_adj_factor")) + ) + agg_a = agg_a.join(adj_factor_df, on="symbol", how="left") + if "_adj_factor" in agg_a.columns: + agg_a = agg_a.with_columns(pl.col("_adj_factor").fill_null(1.0)) + + # annual_vol_20d 递推状态: 最近 19 天日收益率的部分和 / 平方和 + df_daily_pct = ( + df_hist.sort(["symbol", "date"]) + .with_columns( + pl.col("close").pct_change().over("symbol").alias("_daily_pct") + ) + ) + df_vol = df_daily_pct.group_by("symbol").agg([ + pl.col("_daily_pct").tail(19).sum().alias("_vol_19d_pct_sum"), + (pl.col("_daily_pct") ** 2).tail(19).sum().alias("_vol_19d_pct_sq_sum"), + ]) + agg_a = agg_a.join(df_vol, on="symbol", how="left") + + # 昨日连板数: 从 enriched parquet 取 (用于增量计算同向 +1) + lf = pl.scan_parquet(self._enriched_glob).filter(pl.col("date") == latest) + consec_cols = [c for c in ["symbol", "consecutive_limit_ups", "consecutive_limit_downs"] + if c in lf.collect_schema().names()] + if len(consec_cols) == 3: + consec_df = lf.select(consec_cols).collect() + if not consec_df.is_empty(): + consec = consec_df.select( + "symbol", + pl.col("consecutive_limit_ups").alias("_prev_consec_up"), + pl.col("consecutive_limit_downs").alias("_prev_consec_down"), + ) + agg_a = agg_a.join(consec, on="symbol", how="left") + + # B类: 按 symbol 分组聚合 — 窗口统计 + agg_b = ( + df_hist.sort(["symbol", "date"]) + .group_by("symbol") + .agg([ + pl.col("close").tail(4).sum().alias("_ma5_partial_sum"), + pl.col("close").tail(9).sum().alias("_ma10_partial_sum"), + pl.col("close").tail(19).sum().alias("_ma20_partial_sum"), + pl.col("close").tail(29).sum().alias("_ma30_partial_sum"), + pl.col("close").tail(59).sum().alias("_ma60_partial_sum"), + + pl.col("close").tail(19).sum().alias("_boll_partial_sum"), + (pl.col("close").tail(19) ** 2).sum().alias("_boll_partial_sq_sum"), + + pl.col("high").tail(59).max().alias("_high_59d"), + pl.col("low").tail(59).min().alias("_low_59d"), + + pl.col("close").tail(5).first().alias("_close_5d_ago"), + pl.col("close").tail(10).first().alias("_close_10d_ago"), + pl.col("close").tail(20).first().alias("_close_20d_ago"), + pl.col("close").tail(30).first().alias("_close_30d_ago"), + pl.col("close").tail(60).first().alias("_close_60d_ago"), + + pl.col("volume").tail(4).sum().alias("_vol_ma5_partial_sum"), + pl.col("volume").tail(9).sum().alias("_vol_ma10_partial_sum"), + + pl.col("low").tail(8).min().alias("_kdj_8d_low"), + pl.col("high").tail(8).max().alias("_kdj_8d_high"), + + pl.col("close").tail(59).len().alias("_window_len"), + ]) + ) + + self._live_agg_cache = agg_a.join(agg_b, on="symbol", how="inner") + self._live_agg_cache_date = latest + + def _live_agg_baseline_date(self, latest: date) -> date: + """盘中递推基准日期。当天实时分区存在时使用上一可用交易日。""" + if latest != date.today(): + return latest + try: + row = self.execute_one( + "SELECT max(date) FROM kline_enriched WHERE date < ?", + [latest], + ) + if row and row[0]: + d = row[0] + return d if isinstance(d, date) else date.fromisoformat(str(d)) + except Exception: # noqa: BLE001 + pass + return latest + + def _build_live_agg_from_parquet(self, latest: date, start_60d: date) -> tuple[pl.DataFrame, pl.DataFrame]: + """降级路径: 从 parquet 读取数据并计算指标 (当 _enriched_history_cache 不可用时)。""" + from app.indicators.pipeline import compute_indicators + + lf = ( + pl.scan_parquet(self._enriched_glob) + .filter(pl.col("date") >= start_60d) + .filter(pl.col("date") <= latest) + .sort(["symbol", "date"]) + ) + + read_cols = [c for c in ["symbol", "date", "open", "high", "low", "close", "volume", + "raw_close", "raw_high", "raw_low"] + if c in lf.collect_schema().names()] + df_hist = lf.select(read_cols).collect() + + if df_hist.is_empty(): + return df_hist, pl.DataFrame() + + df_with_indicators = compute_indicators(df_hist) + + state_cols = [ + "symbol", + "ema5", "ema10", "ema20", "ema30", "ema60", + "macd_dea", + "kdj_k", "kdj_d", + "atr_14", + "close", "high", "low", + "annual_vol_20d", + ] + existing_state = [c for c in state_cols if c in df_with_indicators.columns] + agg_a = df_with_indicators.filter(pl.col("date") == latest).select(existing_state) + + 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: + df = pl.scan_parquet(self._inst_glob).collect() + if not df.is_empty(): + self._instruments_cache = df + logger.info("instruments 缓存已加载: %d 只", len(df)) + except Exception as e: # noqa: BLE001 + logger.warning("instruments 缓存刷新失败: %s", e) + + def _refresh_index_instruments(self) -> None: + """加载指数 instruments 到内存。""" + try: + df = pl.scan_parquet(self._index_inst_glob).collect() + if not df.is_empty(): + self._index_instruments_cache = df + logger.info("index instruments 缓存已加载: %d 只", len(df)) + 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: + self._refresh_enriched() + if self._enriched_cache is None: + 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)。 + + warmup 部分在 _refresh_enriched 计算指标时已使用, 策略只需要最终的 lookback 窗口。 + 返回 ~33万行 (90日历天) 而非 ~107万行, filter_history 策略的 group_by 快 20x+。 + """ + cache = self._enriched_history_cache + if cache is None or cache.is_empty(): + return None + if "date" not in cache.columns: + return None + cache_max = cache["date"].max() + cache_min = cache["date"].min() + from datetime import timedelta + # 验证缓存覆盖完整范围 (含 warmup) + warmup_start = target_date - timedelta(days=(lookback_days + 60) * 2) + if cache_min > warmup_start or cache_max < target_date: + return None + # 只返回 lookback 范围 (日历天数 ≈ 2/3 交易日, 足够覆盖) + lookback_start = target_date - timedelta(days=lookback_days) + return cache.filter((pl.col("date") >= lookback_start) & (pl.col("date") <= target_date)) + + def get_enriched_range( + self, + start: date, + end: date, + symbols: list[str] | None = None, + columns: list[str] | None = None, + ) -> pl.DataFrame | None: + """从预计算 enriched 历史缓存返回完整区间;缓存不覆盖时返回 None。""" + if self._enriched_history_cache is None: + self._refresh_enriched() + cache = self._enriched_history_cache + if cache is None or cache.is_empty() or "date" not in cache.columns: + return None + + cache_min = cache["date"].min() + cache_max = cache["date"].max() + if cache_min > start or cache_max < end: + return None + + df = cache.filter((pl.col("date") >= start) & (pl.col("date") <= end)) + if symbols is not None: + df = df.filter(pl.col("symbol").is_in(symbols)) + if columns and not df.is_empty(): + existing = [c for c in columns if c in df.columns] + if "symbol" not in existing and "symbol" in df.columns: + existing.insert(0, "symbol") + if "date" not in existing and "date" in df.columns: + existing.insert(1, "date") + df = df.select(existing) + 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 + + def get_instruments(self) -> pl.DataFrame: + """返回缓存的 instruments DataFrame。如无缓存则懒加载。""" + if self._instruments_cache is None: + self._refresh_instruments() + if self._instruments_cache is None: + return pl.DataFrame() + return self._instruments_cache + + def get_index_instruments(self) -> pl.DataFrame: + """返回缓存的指数 instruments DataFrame。如无缓存则懒加载。""" + if self._index_instruments_cache is None: + self._refresh_index_instruments() + if self._index_instruments_cache is None: + 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() + if df.is_empty() or "symbol" not in df.columns: + return set() + return set(df["symbol"].cast(pl.Utf8).to_list()) + + def enriched_latest_date(self) -> date | None: + """返回缓存中的 enriched 最新日期。""" + return self._enriched_cache_date + + # ================================================================ + # 热路径: Polars 查询 (Chart / Screener / Signals / Intraday) + # ================================================================ + + def get_daily( + self, + symbol: str, + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + """单股日K查询 — 从14列parquet读取后即时计算指标。""" + from datetime import timedelta + + # 扩展范围用于指标预热 (MA60 需要 ~60 交易日 ≈ 120 日历日) + warmup_start = start - timedelta(days=150) + + # 扫描14列 parquet + df = self._scan_daily_symbol(symbol, warmup_start, end, None) + if not df.is_empty(): + df = self._compute_enriched_range(df) + + # 尝试用缓存数据覆盖最新日 (盘中更准确) + cached, cache_date = self.get_enriched_latest() + if not df.is_empty() and cached is not None and not cached.is_empty() and cache_date: + if start <= cache_date <= end: + cached_part = self._filter_cached(cached, symbol, None) + if not cached_part.is_empty(): + df = df.filter(pl.col("date") != cache_date) + common_cols = [c for c in df.columns if c in cached_part.columns] + df = pl.concat([df.select(common_cols), cached_part.select(common_cols)]) + + # 裁剪到请求范围 + if not df.is_empty(): + 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_batch( + self, + symbols: list[str], + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + """批量日K查询。""" + cached, cache_date = self.get_enriched_latest() + if cached is not None and not cached.is_empty() and cache_date: + if start >= cache_date: + return self._filter_cached_batch(cached, symbols, columns) + + # 回退 scan_parquet + return self._scan_daily_batch(symbols, start, end, columns) + + def get_index_daily( + self, + symbol: str, + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + """指数日K查询 — 从独立指数 enriched parquet 读取后即时计算通用指标。""" + from datetime import timedelta + + warmup_start = start - timedelta(days=150) + 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_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, + trade_date: date, + ) -> pl.DataFrame: + """分钟K查询 — Polars scan_parquet + predicate pushdown。""" + try: + return pl.scan_parquet(self._minute_glob).filter( + (pl.col("symbol") == symbol) + & (pl.col("datetime").dt.date() == trade_date) + ).sort("datetime").collect() + except Exception as e: # noqa: BLE001 + logger.warning("分钟K查询失败: %s", e) + return pl.DataFrame() + + # ================================================================ + # Polars 查询内部方法 + # ================================================================ + + def _compute_enriched_range(self, df: pl.DataFrame) -> pl.DataFrame: + """对14列enriched数据即时计算完整指标+信号。输入应含足够预热行数。""" + from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals, filter_halt_days + if df.is_empty() or df.height < 2: + return df + # 兜底过滤历史脏数据中的停牌日 (close 可能被填充为前收盘价) + df = filter_halt_days(df) + if df.is_empty() or df.height < 2: + return df + try: + df = compute_indicators(df) + df = compute_signals(df) + instruments = self.get_instruments() + df = compute_limit_signals(df, instruments) + except Exception as e: # noqa: BLE001 + logger.warning("on-demand compute failed: %s", e) + return df + + def _compute_index_enriched_range(self, df: pl.DataFrame) -> pl.DataFrame: + """指数只计算通用技术指标和通用信号,跳过涨跌停/股本/市值逻辑。""" + from app.indicators.pipeline import compute_indicators, compute_signals + if df.is_empty() or df.height < 2: + return df + try: + df = compute_indicators(df) + df = compute_signals(df) + except Exception as e: # noqa: BLE001 + logger.warning("index on-demand compute failed: %s", e) + return df + + def _filter_cached(self, cached: pl.DataFrame, symbol: str, columns: list[str] | None) -> pl.DataFrame: + df = cached.filter(pl.col("symbol") == symbol) + 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 _filter_cached_batch(self, cached: pl.DataFrame, symbols: list[str], columns: list[str] | None) -> pl.DataFrame: + df = cached.filter(pl.col("symbol").is_in(symbols)) + if columns and not df.is_empty(): + existing = [c for c in columns if c in df.columns] + df = df.select(existing) + return df.sort(["symbol", "date"]) + + def _scan_daily_symbol(self, symbol: str, start: date, end: date, columns: list[str] | None) -> pl.DataFrame: + try: + lf = pl.scan_parquet(self._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.warning("日K查询失败: %s", e) + return pl.DataFrame() + + def _scan_daily_batch(self, symbols: list[str], start: date, end: date, columns: list[str] | None) -> pl.DataFrame: + try: + lf = pl.scan_parquet(self._enriched_glob, + cast_options=pl.ScanCastOptions(integer_cast="allow-float")).filter( + (pl.col("symbol").is_in(symbols)) + & (pl.col("date") >= start) + & (pl.col("date") <= end) + ).sort(["symbol", "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.warning("日K批量查询失败: %s", e) + return pl.DataFrame() + + def _scan_index_daily_symbol(self, symbol: str, start: date, end: date, columns: list[str] | None) -> pl.DataFrame: + try: + lf = pl.scan_parquet(self._index_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.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, + cache_date: date, + symbol: str, + start: date, + end: date, + columns: list[str] | None, + ) -> pl.DataFrame: + """合并缓存部分 + scan 历史部分。 + + 历史部分用 strict < cache_date, 避免与缓存重复。 + 两部分 schema 可能不一致 (增量 vs 全量), concat 前对齐列。 + """ + hist = self._scan_daily_symbol(symbol, start, cache_date, columns) + cached_part = self._filter_cached(cached, symbol, columns) + if hist.is_empty(): + return cached_part + if cached_part.is_empty(): + return hist + # 去重: 历史部分可能包含 cache_date, 去掉后再合并 + hist = hist.filter(pl.col("date") < cache_date) + # 对齐列: 取交集, 统一类型 + common_cols = [c for c in hist.columns if c in cached_part.columns] + hist = hist.select(common_cols) + cached_part = cached_part.select(common_cols) + # 统一类型: 历史可能是 Float64, 缓存可能是 Int64, 统一为 cast + for c in common_cols: + if hist[c].dtype != cached_part[c].dtype: + # 统一到更宽的类型 + target = hist[c].dtype if hist.height > cached_part.height else cached_part[c].dtype + hist = hist.with_columns(pl.col(c).cast(target)) + cached_part = cached_part.with_columns(pl.col(c).cast(target)) + return pl.concat([hist, cached_part]) + + # ================================================================ + # DuckDB 查询 (冷路径: 统计/元数据/自定义SQL) + # ================================================================ + + def latest_minute_date(self, symbol: str) -> date | None: + try: + with self._lock: + row = self.db.execute( + "SELECT max(CAST(datetime AS DATE)) FROM kline_minute WHERE symbol = ?", + [symbol], + ).fetchone() + if row and row[0]: + return row[0] if isinstance(row[0], date) else date.fromisoformat(str(row[0])) + except duckdb.CatalogException: + pass + return None + + def earliest_daily_date(self) -> date | None: + """本地日K数据的最早日期。""" + try: + with self._lock: + res = self.db.execute( + "SELECT min(date) FROM kline_daily", + ).fetchone() + if res and res[0]: + d = res[0] + return d if isinstance(d, date) else date.fromisoformat(str(d)) + except Exception: + return None + return None + + def earliest_minute_date(self) -> date | None: + """本地分钟K数据的最早日期。""" + try: + with self._lock: + res = self.db.execute( + "SELECT min(CAST(datetime AS DATE)) FROM kline_minute", + ).fetchone() + if res and res[0]: + d = res[0] + return d if isinstance(d, date) else date.fromisoformat(str(d)) + except Exception: + return None + return None + + def latest_daily_date(self) -> date | None: + """本地日K数据的最新日期。""" + try: + with self._lock: + res = self.db.execute( + "SELECT max(date) FROM kline_daily", + ).fetchone() + if res and res[0]: + d = res[0] + return d if isinstance(d, date) else date.fromisoformat(str(d)) + except Exception: + return None + return None + + def _latest_enriched_date_duckdb(self) -> date | None: + try: + with self._lock: + res = self.db.execute( + "SELECT max(date) FROM kline_enriched", + ).fetchone() + if res and res[0]: + d = res[0] + return d if isinstance(d, date) else date.fromisoformat(str(d)) + except Exception: # noqa: BLE001 + return None + return None + + # ================================================================ + # 写入 (Pipeline / Sync) + # ================================================================ + + def append_daily(self, df: pl.DataFrame) -> None: + """按日分区写入日K数据 (merge-upsert)。""" + if df.is_empty(): + return + self._write_daily_partition(df, "kline_daily") + + def append_enriched(self, df: pl.DataFrame) -> None: + """按日分区写入 enriched 数据 (merge-upsert)。磁盘仅写入 14 列存储列。""" + 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_daily_enriched") + + def append_index_daily(self, df: pl.DataFrame) -> None: + """按日分区写入指数日K数据 (merge-upsert)。""" + if df.is_empty(): + return + self._write_daily_partition(df, "kline_index_daily") + + def append_index_enriched(self, df: pl.DataFrame) -> None: + """按日分区写入指数 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_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: + return + out = self.store.data_dir / "instruments_index" / "instruments_index.parquet" + 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() + statements = [ + f"""CREATE OR REPLACE VIEW kline_index_daily AS + 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/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。""" + base = self.store.data_dir / table + for date_df in df.partition_by("date"): + dt = date_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) + 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 = 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 + 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" + out.parent.mkdir(parents=True, exist_ok=True) + df.sort(["symbol", "date"]).write_parquet(out) + + def flush_live_enriched(self, df: pl.DataFrame) -> None: + """覆写当天 kline_daily_enriched 分区 (实时 enriched 落盘, 非merge)。 + + 内存缓存保留完整指标列供各服务使用,磁盘仅写入 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 + dt = df["date"][0] + 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 / 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) + df_storage.write_parquet(out) diff --git a/serve/backend/app/tickflow/scheduler.py b/serve/backend/app/tickflow/scheduler.py new file mode 100644 index 0000000..588b67c --- /dev/null +++ b/serve/backend/app/tickflow/scheduler.py @@ -0,0 +1,66 @@ +"""请求调度器(§5.6)。 + +按 capability 分别维护令牌桶。Phase 0:基础实现;Phase 1 接入批量合并、优先级队列。 +""" +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass + +from .capabilities import Cap, CapabilitySet + + +@dataclass +class _Bucket: + capacity: int # 每分钟令牌数 + tokens: float + last_refill: float # 单位:秒 + + def consume(self, n: int = 1) -> float: + """尝试消费 n 个令牌,返回需要等待的秒数(0 表示无需等待)。""" + now = time.monotonic() + elapsed = now - self.last_refill + # 60s 内补满 capacity,匀速补 + refill = elapsed * (self.capacity / 60.0) + self.tokens = min(self.capacity, self.tokens + refill) + self.last_refill = now + + if self.tokens >= n: + self.tokens -= n + return 0.0 + deficit = n - self.tokens + # 还需多少秒才能补齐 + wait = deficit / (self.capacity / 60.0) + # 不预扣,留给下一次再竞争(避免饿死优先级高的请求) + return wait + + +class Scheduler: + """每个 capability 一个桶。""" + + def __init__(self, capset: CapabilitySet) -> None: + self._capset = capset + self._buckets: dict[Cap, _Bucket] = {} + self._locks: dict[Cap, asyncio.Lock] = {} + for cap, lim in capset.all().items(): + if lim.rpm: + self._buckets[cap] = _Bucket( + capacity=lim.rpm, + tokens=lim.rpm, + last_refill=time.monotonic(), + ) + self._locks[cap] = asyncio.Lock() + + async def acquire(self, cap: Cap, n: int = 1) -> None: + """阻塞直到拿到 n 个令牌。无桶 = 不限流(由调用方保证)。""" + bucket = self._buckets.get(cap) + if bucket is None: + return + lock = self._locks[cap] + async with lock: + while True: + wait = bucket.consume(n) + if wait == 0: + return + await asyncio.sleep(wait) diff --git a/serve/backend/pyproject.toml b/serve/backend/pyproject.toml new file mode 100644 index 0000000..62e3677 --- /dev/null +++ b/serve/backend/pyproject.toml @@ -0,0 +1,81 @@ +[project] +name = "tickflow-stock-panel-backend" +version = "0.1.70" +description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" +readme = "../README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +dependencies = [ + # Web + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "pydantic>=2.7", + "pydantic-settings>=2.4", + "python-multipart>=0.0.6", + "sse-starlette>=2.0", + # Data + "polars>=1.0", + "duckdb>=1.0", + "pyarrow>=16.0", + "pandas>=2.2", # 仅在 BacktestService 边界使用,见 §7.4 / ADR-19 + "fastexcel>=0.10", # Polars 读取 xlsx/xls + # TickFlow 官方 SDK + "tickflow[all]>=0.1.23", + # Scheduling + "apscheduler>=3.10", + # Config + "pyyaml>=6.0", + "python-dotenv>=1.0", + # 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` +backtest = [ + "vectorbt>=0.26", +] + +# 桌面客户端依赖: pywebview 桌面窗口。打包用 (PyInstaller), 生产/Docker 不需要。 +# 启用:`uv sync --extra desktop` +desktop = [ + "pywebview>=5.0", +] + +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "ruff>=0.5", + "mypy>=1.10", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "UP", "B", "SIM", "RUF"] +ignore = ["E501"] # 长行交给 fmt + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/serve/backend/scripts/__init__.py b/serve/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serve/backend/scripts/cleanup_halt_days.py b/serve/backend/scripts/cleanup_halt_days.py new file mode 100644 index 0000000..43174f8 --- /dev/null +++ b/serve/backend/scripts/cleanup_halt_days.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +"""一次性清理脚本:移除已入库的停牌脏数据。 + +背景:历史停牌过滤条件为 "OHLC 全零",会漏过 close 被数据源填充为 +前收盘价的停牌记录(如 *ST 撤销风险警示的停牌日),导致日 K 图出现 +开盘价为 0 的异常蜡烛。停牌过滤已改用 "open==0 且 high==0",本脚本 +负责清理既有脏数据,可重复执行(幂等)。 + +用法(从 backend/ 目录运行): + .venv/bin/python -m scripts.cleanup_halt_days # 清理 + dry-run 关闭 + .venv/bin/python -m scripts.cleanup_halt_days --dry-run # 仅扫描,不写盘 +""" +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +import polars as pl + +logger = logging.getLogger(__name__) + +# kline_daily 原始表与 enriched 表的脏数据都在这些分区里 +HALT_TABLES = ["kline_daily", "kline_daily_enriched"] +DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" +HALT_PRED = (pl.col("open") == 0) & (pl.col("high") == 0) + + +def _scan_dirty(table: str) -> pl.DataFrame: + glob = str(DATA_DIR / table / "**" / "*.parquet") + cast = pl.ScanCastOptions(integer_cast="allow-float") + return ( + pl.scan_parquet(glob, hive_partitioning=True, cast_options=cast) + .filter(HALT_PRED) + .select("symbol", "date") + .collect() + ) + + +def _clean_table(table: str, dry_run: bool) -> int: + """清理单张表所有脏分区,返回被删除的行数。""" + dirty = _scan_dirty(table) + if dirty.is_empty(): + logger.info("[%s] 无脏数据", table) + return 0 + + removed = 0 + base = DATA_DIR / table + for dt in dirty["date"].unique().sort(): + part = base / f"date={dt}" / "part.parquet" + if not part.exists(): + logger.warning("[%s] 分区文件不存在: %s", table, part) + continue + df = pl.read_parquet(part) + before = df.height + cleaned = df.filter(~HALT_PRED) + after = cleaned.height + diff = before - after + if diff == 0: + continue + removed += diff + if dry_run: + logger.info("[%s %s] 将删除 %d 行 (停牌), 剩余 %d 行 [dry-run]", + table, dt, diff, after) + continue + if after == 0: + part.unlink() + logger.info("[%s %s] 删除 %d 行后分区为空, 已移除文件", table, dt, diff) + else: + cleaned.write_parquet(part) + logger.info("[%s %s] 删除 %d 行 (停牌), 剩余 %d 行已重写", + table, dt, diff, after) + return removed + + +def main() -> None: + ap = argparse.ArgumentParser(description="清理已入库的停牌脏数据") + ap.add_argument("--dry-run", action="store_true", help="仅扫描不写盘") + args = ap.parse_args() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", + ) + + total = 0 + for table in HALT_TABLES: + total += _clean_table(table, args.dry_run) + + mode = "dry-run 扫描" if args.dry_run else "已清理" + logger.info("完成: 共 %s %d 行停牌脏数据", mode, total) + + +if __name__ == "__main__": + main() diff --git a/serve/backend/tests/backtest/test_engine_portfolio.py b/serve/backend/tests/backtest/test_engine_portfolio.py new file mode 100644 index 0000000..2c0ede5 --- /dev/null +++ b/serve/backend/tests/backtest/test_engine_portfolio.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from datetime import date, timedelta + +import polars as pl + +from app.backtest.engine import BacktestEngine, MatcherConfig + + +def _panel(symbols: list[str], days: int = 4, price: float = 10.0, overrides: dict[tuple[str, int], dict] | None = None) -> pl.DataFrame: + overrides = overrides or {} + start = date(2024, 1, 1) + rows = [] + for sym in symbols: + for i in range(days): + patch = overrides.get((sym, i), {}) + rows.append({ + "symbol": sym, + "name": sym, + "date": start + timedelta(days=i), + "open": patch.get("open", price), + "high": patch.get("high", price), + "low": patch.get("low", price), + "close": patch.get("close", price), + "volume": patch.get("volume", 100_000), + "score": patch.get("score", {"A": 4, "B": 3, "C": 2, "D": 1}.get(sym, 0)), + "signal_limit_up": patch.get("signal_limit_up", False), + "signal_limit_down": patch.get("signal_limit_down", False), + }) + return pl.DataFrame(rows).sort(["symbol", "date"]) + + +def _mask(panel: pl.DataFrame, marks: set[tuple[str, int]]) -> pl.Series: + values = [] + base = date(2024, 1, 1) + for row in panel.select(["symbol", "date"]).iter_rows(named=True): + day = (row["date"] - base).days + values.append((row["symbol"], day) in marks) + return pl.Series(values, dtype=pl.Boolean) + + +def _engine() -> BacktestEngine: + return BacktestEngine(repo=None) # simulate_portfolio 不访问 repo + + +def test_max_exposure_sets_target_position_and_caps_count(): + panel = _panel(["A", "B", "C", "D"], days=3) + entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0), ("D", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=3, + max_exposure_pct=0.6, + initial_capital=100_000, + ), + ) + + assert len(result.trades) == 3 + assert {t.symbol for t in result.trades} == {"A", "B", "C"} + assert all(abs(t.position_pct - 0.2) < 0.001 for t in result.trades) + assert result.stats["max_exposure"] <= 0.61 + + +def test_one_price_limit_up_blocks_buy(): + panel = _panel( + ["A"], + days=3, + overrides={ + ("A", 1): {"open": 11, "high": 11, "low": 11, "close": 11, "signal_limit_up": True}, + }, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_positions=1, initial_capital=100_000), + ) + + assert result.trades == [] + assert result.stats["execution"]["buy_limit_up"] == 1 + + +def test_failed_open_exit_keeps_slot_and_blocks_replacement_buy(): + panel = _panel( + ["A", "B", "C", "D"], + days=4, + overrides={ + ("A", 2): {"open": 9, "high": 9, "low": 9, "close": 9, "signal_limit_down": True}, + }, + ) + entries = _mask(panel, { + ("A", 0), ("B", 0), ("C", 0), + ("D", 1), + }) + exits = _mask(panel, {("A", 1)}) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=3, + max_exposure_pct=0.6, + initial_capital=100_000, + ), + ) + + assert "D" not in {t.symbol for t in result.trades} + assert result.stats["execution"]["sell_limit_down"] == 1 + assert result.stats["execution"]["pending_exit"] == 1 + assert result.stats["execution"]["buy_no_slot"] >= 1 + a_trade = next(t for t in result.trades if t.symbol == "A") + assert a_trade.blocked_exit_days == 1 + assert a_trade.exit_reason == "signal" + + +def test_trailing_stop_uses_high_water_mark(): + panel = _panel( + ["A"], + days=5, + overrides={ + ("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12}, + ("A", 3): {"open": 12, "high": 12, "low": 11.3, "close": 11.3}, + }, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=1, + initial_capital=100_000, + trailing_stop_pct=0.05, + ), + ) + + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.exit_reason == "trailing_stop" + assert trade.exit_price == 11.4 + + +def test_trailing_take_profit_requires_activation(): + panel = _panel( + ["A"], + days=5, + overrides={ + ("A", 2): {"open": 10, "high": 10.8, "low": 10.4, "close": 10.8}, + ("A", 3): {"open": 10.8, "high": 10.8, "low": 10.4, "close": 10.4}, + }, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=1, + initial_capital=100_000, + trailing_take_profit_activate_pct=0.10, + trailing_take_profit_drawdown_pct=0.03, + ), + ) + + assert result.trades[0].exit_reason == "end" + + +def test_trailing_take_profit_exits_after_activation(): + panel = _panel( + ["A"], + days=5, + overrides={ + ("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12}, + ("A", 3): {"open": 12, "high": 12, "low": 11.5, "close": 11.5}, + }, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=1, + initial_capital=100_000, + trailing_take_profit_activate_pct=0.10, + trailing_take_profit_drawdown_pct=0.03, + ), + ) + + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.exit_reason == "trailing_take_profit" + assert trade.exit_price == 11.7 + + +def test_score_filter_uses_signal_day_score_range(): + panel = _panel( + ["A", "B", "C"], + days=3, + overrides={ + ("A", 0): {"score": 70}, + ("B", 0): {"score": 80}, + ("C", 0): {"score": 90}, + ("A", 1): {"score": 100}, + ("B", 1): {"score": 1}, + ("C", 1): {"score": 1}, + }, + ) + entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=3, + initial_capital=100_000, + score_min=71, + score_max=85, + ), + ) + + assert {t.symbol for t in result.trades} == {"B"} + assert result.trades[0].entry_score == 80 + assert result.stats["execution"]["buy_score_filter"] == 2 + + +def test_independent_candidates_allow_overlapping_same_symbol_trades(): + panel = _panel( + ["A"], + days=5, + overrides={ + ("A", 0): {"close": 10}, + ("A", 1): {"close": 11}, + ("A", 2): {"close": 12}, + ("A", 3): {"close": 13}, + ("A", 4): {"close": 14}, + }, + ) + entries = _mask(panel, {("A", 0), ("A", 1)}) + exits = _mask(panel, set()) + + result = _engine().simulate_independent_candidates( + panel, + entries, + exits, + MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, max_hold_days=2), + ) + + assert result.stats["full_kind"] == "candidate_execution" + assert result.stats["n_candidates"] == 2 + assert len(result.trades) == 2 + assert [t.entry_date for t in result.trades] == ["2024-01-01", "2024-01-02"] + assert [t.exit_date for t in result.trades] == ["2024-01-03", "2024-01-04"] + assert all(t.exit_reason == "max_hold" for t in result.trades) + + +def test_independent_candidates_apply_stop_loss(): + panel = _panel( + ["A"], + days=4, + overrides={ + ("A", 0): {"close": 10, "low": 10}, + ("A", 1): {"open": 10, "high": 10, "low": 8.9, "close": 9}, + }, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_independent_candidates( + panel, + entries, + exits, + MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, stop_loss_pct=0.1), + ) + + assert len(result.trades) == 1 + assert result.trades[0].exit_reason == "stop_loss" + assert result.trades[0].exit_price == 9.0 + + +def test_signal_exit_takes_priority_over_max_hold(): + """同一日既有卖点信号又到期 → 应按 signal 平仓 (卖点优先于 max_hold 兜底)。""" + panel = _panel( + ["A"], + days=4, + overrides={ + # day1 次日开盘买入 (open_t+1), 价 10 + ("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10}, + # day2 持有 (hold_days 计到 1) + ("A", 2): {"open": 11, "high": 11, "low": 11, "close": 11}, + # day3: 既到期 (hold_days=2 >= max_hold_days=2) 又有卖点信号 → signal 优先 + ("A", 3): {"open": 12, "high": 12, "low": 12, "close": 12}, + }, + ) + entries = _mask(panel, {("A", 0)}) # day0 收盘确认 → day1 开盘买 + exits = _mask(panel, {("A", 2)}) # day2 收盘确认卖点 → day3 开盘卖 + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=1, + max_hold_days=2, + initial_capital=100_000, + ), + ) + + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.exit_reason == "signal" + assert trade.exit_price == 12.0 # 卖点用 day3 开盘 (exit_fill 跟随 matching=open_t+1) + + +def test_stop_loss_triggers_even_when_expired_in_open_mode(): + """open_t+1 模式下仓位到期且当日破止损 → 应按 stop_loss 平仓 (风控优先于 max_hold)。""" + panel = _panel( + ["A"], + days=4, + overrides={ + ("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10}, + # day3 开盘跳空跌破止损 (-10%): open=8.9 < 9.0 止损线, low=8.5 + ("A", 3): {"open": 8.9, "high": 8.9, "low": 8.5, "close": 8.7}, + }, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=1, + max_hold_days=2, + stop_loss_pct=0.1, + initial_capital=100_000, + ), + ) + + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.exit_reason == "stop_loss" + # 风控盘中触发: 开盘价 8.9 <= 止损线 9.0 → 按开盘价 8.9 成交 + assert trade.exit_price == 8.9 + + +def test_default_fill_is_buy_open_sell_close(): + """拆分口径: 建仓=次日开盘, 清仓=收盘。entry_price 用次日 open, exit_price 用收盘价。""" + panel = _panel( + ["A"], + days=4, + overrides={ + # day1: 次日开盘买入, 开盘 10 + ("A", 1): {"open": 10, "high": 10.5, "low": 9.5, "close": 10.2}, + # day2: 到期 (max_hold_days=1), 收盘卖 + ("A", 2): {"open": 11, "high": 11, "low": 10, "close": 10.8}, + }, + ) + entries = _mask(panel, {("A", 0)}) # day0 收盘确认 + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + entry_fill="open_t+1", + exit_fill="close_t", + fees_pct=0, + slippage_bps=0, + max_positions=1, + max_hold_days=1, + initial_capital=100_000, + ), + ) + + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.entry_price == 10.0 # 次日开盘 + assert trade.exit_price == 10.8 # 到期日收盘 + assert trade.exit_reason == "max_hold" diff --git a/serve/backend/tests/backtest/test_full_simulation_tail.py b/serve/backend/tests/backtest/test_full_simulation_tail.py new file mode 100644 index 0000000..f5352b9 --- /dev/null +++ b/serve/backend/tests/backtest/test_full_simulation_tail.py @@ -0,0 +1,59 @@ +"""全量模拟 (full mode) 尾部执行回归测试。""" +from __future__ import annotations + +from datetime import date, timedelta + +import polars as pl + +from app.backtest.engine import BacktestEngine, MatcherConfig + + +def _panel_with_tail(symbols: list[str], n_data_days: int) -> pl.DataFrame: + start = date(2024, 1, 1) + rows = [] + for sym in symbols: + for i in range(n_data_days): + px = 10.0 + i + rows.append({ + "symbol": sym, + "date": start + timedelta(days=i), + "open": px, + "high": px, + "low": px, + "close": px, + "volume": 100_000, + "signal_limit_up": False, + "signal_limit_down": False, + }) + return pl.DataFrame(rows).sort(["symbol", "date"]) + + +def test_full_simulation_executes_signal_at_tail(): + """信号集中在正式区间最后一天时, tail 数据应允许次日开盘买入并按策略退出。""" + n_days = 6 + panel = _panel_with_tail(["A"], n_days + 3) + + start = date(2024, 1, 1) + end = start + timedelta(days=n_days - 1) + entry_vals = [] + for row in panel.select(["symbol", "date"]).iter_rows(named=True): + entry_vals.append(row["date"] == end) + entry_mask = pl.Series(entry_vals, dtype=pl.Boolean) + exit_mask = pl.Series([False] * len(panel), dtype=pl.Boolean) + + result = BacktestEngine(repo=None).simulate_independent_candidates( # type: ignore[arg-type] + panel, + entry_mask, + exit_mask, + MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_hold_days=2), + ) + + assert not result.stats.get("error"), f"unexpected error: {result.stats.get('error')}" + assert result.stats.get("full_kind") == "candidate_execution" + assert result.stats.get("n_candidates") == 1 + assert result.stats.get("n_trades") == 1 + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.entry_signal_date == str(end) + assert trade.entry_date == str(end + timedelta(days=1)) + assert trade.exit_reason == "max_hold" diff --git a/serve/backend/tests/backtest/test_strategy_backtest_correctness.py b/serve/backend/tests/backtest/test_strategy_backtest_correctness.py new file mode 100644 index 0000000..6b55ba8 --- /dev/null +++ b/serve/backend/tests/backtest/test_strategy_backtest_correctness.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from datetime import date, timedelta +from types import SimpleNamespace + +import polars as pl + +from app.backtest.engine import BacktestEngine, SimResult +from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService +from app.strategy.engine import StrategyDef + + +def _strategy(**kwargs) -> StrategyDef: + defaults = dict( + meta={"id": "test", "name": "test", "scoring": {}, "params": [], "limit": 100}, + basic_filter={"enabled": True, "amount_min": 100.0}, + entry_signals=[], + exit_signals=[], + stop_loss=None, + trailing_stop=None, + trailing_take_profit_activate=None, + trailing_take_profit_drawdown=None, + max_hold_days=None, + alerts=[], + filter_fn=lambda df, params: pl.lit(True), + filter_history_fn=None, + lookback_days=1, + source="custom", + file_path=None, + ) + defaults.update(kwargs) + return StrategyDef(**defaults) + + +class _StrategyEngineStub: + def __init__(self, strategy: StrategyDef) -> None: + self.strategy = strategy + + def get(self, strategy_id: str) -> StrategyDef: + return self.strategy + + +class _RepoStub: + def get_index_daily(self, *args, **kwargs) -> pl.DataFrame: + return pl.DataFrame() + + +class _EngineStub: + def __init__(self, panel: pl.DataFrame) -> None: + self.panel = panel + self.repo = _RepoStub() + self.load_args = None + self.sim_panel: pl.DataFrame | None = None + self.sim_entries: pl.Series | None = None + + def load_panel(self, symbols, start: date, end: date) -> pl.DataFrame: + self.load_args = (symbols, start, end) + return self.panel + + def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None) -> SimResult: + self.sim_panel = panel + self.sim_entries = entries + return SimResult( + equity_curve=[{"date": "2024-01-01", "value": config.initial_capital}], + drawdown_curve=[{"date": "2024-01-01", "value": 0.0}], + trades=[], + per_symbol_stats=[], + stats={"total_return": 0.0, "n_trades": 0}, + ) + + +def test_basic_filter_only_limits_entries_not_panel_rows(): + start = date(2024, 1, 1) + rows = [] + for i, amount in enumerate([1000.0, 0.0, 1000.0]): + rows.append({ + "symbol": "A", + "name": "A", + "date": start + timedelta(days=i), + "open": 10.0 + i, + "high": 10.0 + i, + "low": 10.0 + i, + "close": 10.0 + i, + "volume": 100_000, + "amount": amount, + "signal_limit_up": False, + "signal_limit_down": False, + }) + panel = pl.DataFrame(rows).sort(["symbol", "date"]) + engine = _EngineStub(panel) + service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(_strategy())) + + result = service.run(StrategyBacktestConfig( + strategy_id="test", + symbols=None, + start=start, + end=start + timedelta(days=2), + matching="close_t", + mode="position", + )) + + assert result.error is None + assert engine.sim_panel is not None + assert engine.sim_panel.height == 3 + assert engine.sim_panel.filter(pl.col("amount") == 0.0).height == 1 + assert engine.sim_entries is not None + assert engine.sim_entries.to_list() == [True, False, True] + assert engine.load_args is not None + assert engine.load_args[1] < start # warmup 只用于计算, 不参与正式交易 + + +def test_score_normalizes_inside_strategy_candidate_universe(): + panel = pl.DataFrame({ + "symbol": ["A", "B", "C"], + "date": [date(2024, 1, 1)] * 3, + "factor": [10.0, 20.0, 1000.0], + }) + universe = pl.Series([True, True, False], dtype=pl.Boolean) + strategy = SimpleNamespace(meta={"scoring": {"factor": 1.0}, "order_by": "score", "descending": True}) + + scored = StrategyBacktestService._apply_score(panel, strategy, None, universe_mask=universe) + scores = dict(zip(scored["symbol"].to_list(), scored["score"].to_list())) + + assert scores["A"] == 0.0 + assert scores["B"] == 100.0 + assert scores["C"] == 0.0 + + +def test_full_mode_executes_every_candidate_with_strategy_rules(): + start = date(2024, 1, 1) + panel = pl.DataFrame([ + {"symbol": "A", "name": "A", "date": start, "open": 10.0, "high": 10.0, "low": 10.0, "close": 10.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False}, + {"symbol": "A", "name": "A", "date": start + timedelta(days=1), "open": 11.0, "high": 11.0, "low": 11.0, "close": 11.0, "volume": 1, "amount": 0.0, "signal_limit_up": False, "signal_limit_down": False}, + {"symbol": "A", "name": "A", "date": start + timedelta(days=2), "open": 20.0, "high": 20.0, "low": 20.0, "close": 20.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False}, + ]).sort(["symbol", "date"]) + + engine = BacktestEngine(repo=None) # type: ignore[arg-type] + engine.load_panel = lambda symbols, s, e: panel # type: ignore[method-assign] + strategy = _strategy( + filter_fn=lambda df, params: pl.col("date") == start, + max_hold_days=1, + ) + service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(strategy)) + + result = service.run(StrategyBacktestConfig( + strategy_id="test", + symbols=None, + start=start, + end=start, + mode="full", + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + holding_days=1, + )) + + assert result.error is None + assert result.stats["full_kind"] == "candidate_execution" + assert result.stats["n_candidates"] == 1 + assert result.stats["n_trades"] == 1 + assert result.trades[0]["entry_date"] == str(start + timedelta(days=1)) + assert result.trades[0]["exit_reason"] == "max_hold" + assert result.stats["avg_return"] == round(20 / 11 - 1, 4) diff --git a/serve/backend/tests/test_ai_provider.py b/serve/backend/tests/test_ai_provider.py new file mode 100644 index 0000000..3db10c7 --- /dev/null +++ b/serve/backend/tests/test_ai_provider.py @@ -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" diff --git a/serve/backend/uv.lock b/serve/backend/uv.lock new file mode 100644 index 0000000..394230b --- /dev/null +++ b/serve/backend/uv.lock @@ -0,0 +1,2917 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "dateparser" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "duckdb" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fc/a8a89c6c73f31c2b58c6abbc2f543e0b736042dd5ef7cc1784c24ec31428/duckdb-1.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:341a2672e2551ba51c95c1898f0ade983e76675e79038ccb16342c3d6cfb82d7", size = 32583465, upload-time = "2026-05-20T11:54:13.132Z" }, + { url = "https://files.pythonhosted.org/packages/63/f1/3423a2f523dd034e505d4a5dd8e210ae577212e152598dc13b6a5e736e1b/duckdb-1.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c9e8fa408705081160ede7ead238d16e73a36b8561b700f2bf2d650ae48e7b92", size = 17278520, upload-time = "2026-05-20T11:54:16.368Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1a/7bf5ba1b7ea520557e6b2dbee1c85abab016bdac0c1779d9d0ef76c87300/duckdb-1.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70a18f932cf6d87bd0e554613657a515c1443a1724aacfc7ec5137dd28698b03", size = 15424794, upload-time = "2026-05-20T11:54:19.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/ce4b1e386e45fab0268edbf1b85bace20e9437589e9edb2bd5f9a226fa44/duckdb-1.5.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80eb4d0fb59869cb2c7d7ef494c07fb92014fe8e77d96c170cd1ebc1488a708", size = 19306666, upload-time = "2026-05-20T11:54:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/651f8453f26931e8061b7e27b3090f868868185814ecb9216d0bd71ec8ef/duckdb-1.5.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3248b49cd835ea322574bc6aac0ae7a83be85547f49d4f5f5777cb380ee6627f", size = 21418306, upload-time = "2026-05-20T11:54:25.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/64/e1ffebf010b1631a6fef8d1508f46d4eab3e97c18729af986bb796fa8452/duckdb-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f4eff89c12c3a362efa012262e57b7b4ab904a7f79bad9178fe365510077abe8", size = 13101423, upload-time = "2026-05-20T11:54:28.107Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/b1d4e34f9658cc0e13d7aae581ab82643f50a548d5aee8767f0c587cc3a4/duckdb-1.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:75d13308c9da3ee431d1e72b8ab720aa74a1b3e9159d4124cb62435924496334", size = 13951740, upload-time = "2026-05-20T11:54:30.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494, upload-time = "2026-05-20T11:54:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121, upload-time = "2026-05-20T11:54:36.928Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283, upload-time = "2026-05-20T11:54:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762, upload-time = "2026-05-20T11:54:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290, upload-time = "2026-05-20T11:54:45.272Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308, upload-time = "2026-05-20T11:54:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187, upload-time = "2026-05-20T11:54:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, + { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "fastexcel" +version = "0.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/96/24314765a3204081a55ecc96e83dfdbd2ef82aa80a521ac3005b2c91e941/fastexcel-0.20.2.tar.gz", hash = "sha256:a4be10eb0f4dd819d2b36950b3f26bf87260b53e54150ac02b78e524c7063902", size = 60862, upload-time = "2026-05-04T12:28:36.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/29/acc5b2eb95fb4eaeb3486b6f034f2d9754dcc0cc0b5dbc79b2fa9a31f66b/fastexcel-0.20.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2a240b9f4b6af42cafc247c1b8c956cf32f5393a3956ad0ed49e07b2b5bf0e09", size = 3432179, upload-time = "2026-05-04T12:28:24.611Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2b/f78a2df2d93ec4a23d7fc94ed967bf004d5115e1f4d2da389bb888d2cc32/fastexcel-0.20.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:6c7e58cf83c873743c18006e3866d5c2b69c34df7a4a253a72e81169853ddf0e", size = 3260476, upload-time = "2026-05-04T12:28:26.191Z" }, + { url = "https://files.pythonhosted.org/packages/36/9c/a8f45e5c195a70a408893b348595bae4c113091dddba7d1b029e9c980b8e/fastexcel-0.20.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab3cc439c18c4ee14c4ec2b321e1d927cae84f557525f390a3c28517f6e721", size = 3625132, upload-time = "2026-05-04T12:28:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/b4/01/da0ae9b3ee86daca5ac9f0a7b05477cf0c1624321d95f1e52492a772dba5/fastexcel-0.20.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15d41bcc4433bb3ab778426ecaeaf3a5f38d949555c631a83e958c57bb0a116e", size = 3789901, upload-time = "2026-05-04T12:28:13.884Z" }, + { url = "https://files.pythonhosted.org/packages/c5/15/80c614c8b07c631c7c2acd015e7f2e1cd683cd04279e5615b39c36a0e43b/fastexcel-0.20.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:441f9b7e97b4fd1c9487e5c0a0d9cad7aa4097a39e65fcd494551f5ec3e5d02f", size = 3792388, upload-time = "2026-05-04T12:28:15.448Z" }, + { url = "https://files.pythonhosted.org/packages/c0/80/4548305ce4d70a0ecf38205988e5caeb9347dae7e5d06bcd2b939586fc52/fastexcel-0.20.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b15f21baef3e4914071e34f176d8692dd79e3464c10f8373047aab1384cfaae", size = 3960316, upload-time = "2026-05-04T12:28:16.821Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/7de6b49406fda63c9fee817d5fc70fe3f52c24431605f1de2f7b0f3a0938/fastexcel-0.20.2-cp310-abi3-win_amd64.whl", hash = "sha256:ce28748390ae6a7d286ba29d8ee2badcfb6478d32ed700db6c2030de32a16e7b", size = 3275637, upload-time = "2026-05-04T12:28:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3b/c251f38596d630e87280c191f53803e7409e7723e95e53a477f6760c7298/fastexcel-0.20.2-cp310-abi3-win_arm64.whl", hash = "sha256:c3e58f5053bc05b0ee8fa735056cea7af6e1c41c39fc4b2b06cc86c1025d140b", size = 2990952, upload-time = "2026-05-04T12:28:32.434Z" }, + { url = "https://files.pythonhosted.org/packages/37/d3/fcc1ba752415327f0fd5a1b52060e66f1c0bf8b7b90bbba781b91750ce7e/fastexcel-0.20.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e5b677d669cc7c099998ef27d9442b78d7190916d3b40418bda619cdc4ebae2c", size = 3427401, upload-time = "2026-05-04T12:28:27.741Z" }, + { url = "https://files.pythonhosted.org/packages/80/bc/e492c343c096e93e269c157fc11def297430047f1ee09db4f8a39bcdc1a0/fastexcel-0.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9692ac135ede4d6fc45b25cc22f1c6bd1eb8750bda14097b7f319450c33d3124", size = 3253915, upload-time = "2026-05-04T12:28:29.053Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ef/bd7b98149b50e01fdaa3b146b025ce50b8e983f35522f8e69bd94d2711a4/fastexcel-0.20.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:675d7eab2110bd8d5520b98d5eb3f3e15173b69a5f64b43412f917e9717e9aa0", size = 3621364, upload-time = "2026-05-04T12:28:18.457Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e0/f67911db6515df326900e8ac1ae17cc08233ca5fb8f71c633f57a50cf786/fastexcel-0.20.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db049d51e5323459f534ce9a65869f997bebd4dde75ef1d3b3af53dd64b8b9ca", size = 3784924, upload-time = "2026-05-04T12:28:19.955Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/fabe2d1de28e89a90bb2c884c37d862b5d85063fd41fffcb56b2980d0b51/fastexcel-0.20.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5b23f69898f6a32c2f279d8f72eb8ac93c336bf7f453a9b8930cab9dc494520", size = 3791264, upload-time = "2026-05-04T12:28:21.612Z" }, + { url = "https://files.pythonhosted.org/packages/f7/34/b3ed819dd6d35d71c6640ff857c6362d42f19e8b15354e64b06f355df3a4/fastexcel-0.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c3058d034e75967b6f01e0c82767adeb40cbf4533b6bc54e3b93f77f5f80dec4", size = 3953234, upload-time = "2026-05-04T12:28:23.224Z" }, + { url = "https://files.pythonhosted.org/packages/3a/51/1d9a72473cc213d5647d8cf7c2393ef995b3453ac5e4b544ba652d1428dd/fastexcel-0.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d71e2a1fce006cb8bc1ae06da6ff5e5fe83e6496be5aef01b623b15aa0d0f82d", size = 3271344, upload-time = "2026-05-04T12:28:34.443Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipython" +version = "9.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/a0/6198c56d42ef2f3c6ed0c42ba30dbcefdc86a91262d7d449010770ae085b/narwhals-2.21.2.tar.gz", hash = "sha256:5c5b2d0b47aef7c73ea412cfcbcd467f2f2d5be73e3c2ab19d78f4a97718790a", size = 632176, upload-time = "2026-05-16T08:49:08.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl", hash = "sha256:7bb57c3700486039215455b9bf2d64261915cc0fd845cc30272d631df696b251", size = 451201, upload-time = "2026-05-16T08:49:05.536Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "openai" +version = "2.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/8c/bc9bc948058348ed43117cecc3007cd608f395915dae8a00974579a5dab1/polars-1.40.1.tar.gz", hash = "sha256:ab2694134b137596b5a59bfd7b4c54ebbc9b59f9403127f18e32d363777552e8", size = 733574, upload-time = "2026-04-22T19:15:55.507Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ba/26d40f039be9f552b5fd7365a621bdfc0f8e912ef77094ae4693491b0bae/polars_runtime_32-1.40.1.tar.gz", hash = "sha256:37f3065615d1bf90d03b5326222df4c5c1f8a5d33e50470aa588e3465e6eb814", size = 2935843, upload-time = "2026-04-22T19:15:57.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/46/22c8af5eed68ac2eeb556e0fa3ca8a7b798e984ceff4450888f3b5ac61fd/polars_runtime_32-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b748ef652270cc49e9e69f99a035e0eb4d5f856d42bcd6ac4d9d80a40142aa1e", size = 52098755, upload-time = "2026-04-22T19:14:28.555Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/48599a38009ca60ff82a6f38c8a621ce3c0286aa7397c7d79e741bd9060e/polars_runtime_32-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d249b3743e05986060cec0a7aaa542d020df6c6b876e556023a310efd581f9be", size = 46367542, upload-time = "2026-04-22T19:14:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/43/e9/384bc069367a1a36ee31c13782c178dbd039b2b873b772d4a0fc23a2373d/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5987b30e7aa1059d069498496e8dda35afd592b0ac3d46ed87e3ff8df1ad652c", size = 50252104, upload-time = "2026-04-22T19:14:35.945Z" }, + { url = "https://files.pythonhosted.org/packages/15/ef/7d57ceb0651af74194e97ed6583e148d352f03d696090221b8059cdfc90b/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d7f42a8b3f16fc66002cc0f6516f7dd7653396886ae0ed362ab95c0b3408b59", size = 56250788, upload-time = "2026-04-22T19:14:39.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/0f/e4b3ffc748827a14a474ec9c42e45c066050e440fec57e914091d9adda75/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f7becc237a7ec9d9a10878dc8e54b73bbf4e2d94a2991c37d7a0b38590d8f9", size = 50432590, upload-time = "2026-04-22T19:14:43.388Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0b/b8d95fbed869fa4caabe9c400e4210374913b376e925e96fdcfa9be6416b/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:992d14cf191dde043d36fbdbc98a65e43fbc7e9a5024cecd45f838ac4988c1ee", size = 54155564, upload-time = "2026-04-22T19:14:47.239Z" }, + { url = "https://files.pythonhosted.org/packages/06/d9/d091d8fb5cbed5e9536adfed955c4c89987a4cc3b8e73ae4532402b91c74/polars_runtime_32-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:f78bb2abd00101cbb23cc0cb068f7e36e081057a15d2ec2dde3dda280709f030", size = 51829755, upload-time = "2026-04-22T19:14:50.85Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, + { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, + { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, + { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +] + +[[package]] +name = "schedule" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tickflow" +version = "0.1.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/56/d911f7d03363a06f69838878df4f92dd01235c899431197f94fb4c0e36ad/tickflow-0.1.24.tar.gz", hash = "sha256:13f6464a9dd1bdf98a312bc8c313fcc24ad0ad83c16d4f83b76bee83df6e11df", size = 36867, upload-time = "2026-06-20T02:39:35.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/9d/6c03706054f3bcca8a7113a60258e1a52a762077a555d52a5a84e4f6895f/tickflow-0.1.24-py3-none-any.whl", hash = "sha256:e898867b0e3e668618135c78e3a367542f81b7a289567335d298c707452e5f42", size = 43031, upload-time = "2026-06-20T02:39:34.307Z" }, +] + +[package.optional-dependencies] +all = [ + { name = "pandas" }, + { name = "tqdm" }, + { name = "websockets" }, +] + +[[package]] +name = "tickflow-stock-panel-backend" +version = "0.1.66" +source = { editable = "." } +dependencies = [ + { name = "apscheduler" }, + { name = "duckdb" }, + { name = "fastapi" }, + { name = "fastexcel" }, + { name = "httpx" }, + { name = "openai" }, + { name = "pandas" }, + { name = "platformdirs" }, + { name = "plyer" }, + { name = "polars" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { 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 = [ + { name = "apscheduler", specifier = ">=3.10" }, + { name = "duckdb", specifier = ">=1.0" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "fastexcel", specifier = ">=0.10" }, + { name = "httpx", specifier = ">=0.27" }, + { 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" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { 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 = ["legacy-cpu", "backtest", "desktop", "dev"] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "vectorbt" +version = "0.28.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dateparser" }, + { name = "dill" }, + { name = "imageio" }, + { name = "ipywidgets" }, + { name = "matplotlib" }, + { name = "mypy-extensions" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "plotly" }, + { name = "pytz" }, + { name = "requests" }, + { name = "schedule" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/a7/15e6af76aa8dafd18c7fae2068de889a385fd13eb804aacb8e0310765f1b/vectorbt-0.28.2.tar.gz", hash = "sha256:e1a5b7a11c0e2b5b271f18093cb7d1ea075d94d711388c0f423355e83c63c104", size = 487377, upload-time = "2025-12-12T16:18:12.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/b9/250f7a1d033618bd0e43ae40bc180aa88895c907876ca39e219a45caecca/vectorbt-0.28.2-py3-none-any.whl", hash = "sha256:93e5fb20d2ff072b7fed78603b516eb64f967c9bf9420ce8ba28329af0410e7d", size = 527808, upload-time = "2025-12-12T16:18:10.624Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +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" }, +] diff --git a/serve/dev.ps1 b/serve/dev.ps1 new file mode 100644 index 0000000..c9bc16a --- /dev/null +++ b/serve/dev.ps1 @@ -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 +} diff --git a/serve/dev.sh b/serve/dev.sh new file mode 100755 index 0000000..4304b36 --- /dev/null +++ b/serve/dev.sh @@ -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 diff --git a/serve/docker-compose.yml b/serve/docker-compose.yml new file mode 100644 index 0000000..0399a4e --- /dev/null +++ b/serve/docker-compose.yml @@ -0,0 +1,18 @@ +# Phase 0 单 service:FastAPI 启动后既跑 API 又托管前端 dist。 +# 见 ADR-17 / §8.1。 +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + BACKEND_EXTRAS: ${BACKEND_EXTRAS:-} + container_name: TickFlow_Stock_Panel + ports: + - "${PORT:-3018}:3018" + env_file: + - .env + volumes: + - ./data:/app/data + - ./tiers.yaml:/app/tiers.yaml:ro + restart: unless-stopped diff --git a/serve/docs/deploy-password.md b/serve/docs/deploy-password.md new file mode 100644 index 0000000..c828b31 --- /dev/null +++ b/serve/docs/deploy-password.md @@ -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 部署尤其方便。 diff --git a/serve/docs/strategy-builder-step1.md b/serve/docs/strategy-builder-step1.md new file mode 100644 index 0000000..a7510b8 --- /dev/null +++ b/serve/docs/strategy-builder-step1.md @@ -0,0 +1,205 @@ +# 步骤 1:根据规则生成完整策略 + +你是A股量化策略工程师。用户提供策略信息,你输出完整的 `.py` 策略文件。 + +## 核心约束 + +- **只创建这一个 .py 文件,不要修改任何现有文件,不要跨文件引用** +- 只 import polars as pl,不 import 其他模块 +- 贴合用户需求优先:不要为了套模板而歪曲策略含义 + +## 选择策略模式 + +**先分析用户规则,判断使用哪种模式:** + +### 模式 A:单日过滤(filter) +所有条件都是当日指标的比较,不需要回溯历史。例如: +- "收盘价 > ma5 或 ma10" +- "RSI < 30" +- "放量(量比 > 2)" + +### 模式 B:历史窗口(filter_history) +规则涉及以下任何时序/回溯逻辑时使用: +- "最近 N 天内出现过涨停/金叉/某信号" +- "涨停后的第 X 天" +- "上次涨停价"、"前高"、"前低" +- "连续 N 天阴跌/阳线" +- 任何需要多天数据才能判断的条件 + +## 你必须完成的全部内容 + +输出完整的 Python 策略文件,包含: + +1. **META**:id(name, description, tags, params, scoring, basic_filter, limit 等) +2. **ENTRY_SIGNALS / EXIT_SIGNALS**:根据策略逻辑自行选择合适的信号列(参考下方可用信号表),不要照抄示例 +3. **STOP_LOSS / MAX_HOLD_DAYS**:根据策略类型合理设定,做多止损一般为 -5%~-8%,短线持有 5~20 天 +4. **ALERTS**:列出需要监控提醒的条件 +5. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整 +6. **filter() 或 filter_history()**:核心筛选逻辑 + +## 性能原则 + +- 优先用 Polars 表达式、`with_columns`、`over("symbol")`、`group_by`、`join`、`filter` +- 只有复杂状态机难以用表达式描述时,才用 `partition_by("symbol")` + `to_dicts()` + +--- + +## 模式 A 框架(单日过滤) + +```python +"""策略简短描述""" +import polars as pl + +META = { + "id": "ai_xxxxxxxxxxxx", # 使用用户提供的 strategy_id + "name": "用户给的名称", + "description": "用户给的描述", + "tags": ["根据策略添加标签"], + "basic_filter": { + "price_min": 3, # 根据策略调整 + "price_max": 200, + "market_cap_min": 10e8, + "amount_min": 0.5e8, + "exclude_st": True, + "exclude_new_days": 30, + }, + "params": [ + # 只把用户可能调节的阈值放这里;每个参数含 id/label/type/default/min/max/step + ], + "scoring": { + # 根据策略核心逻辑定制权重,总和 = 1.0 + }, + "order_by": "score", + "descending": True, + "limit": 100, +} + +# 根据策略逻辑选择合适的信号,见下方可用信号表 +ENTRY_SIGNALS = [] +EXIT_SIGNALS = [] + +# 根据策略类型设定 +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 20 + +ALERTS = [] + +RULES = """ +1. 规则一 +2. 规则二 +3. 规则三 +""" + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + """策略核心过滤逻辑,返回 Polars 布尔表达式。""" + # 用 params.get("param_id", 默认值) 读取参数 + return pl.col("<字段>") > pl.col("<字段>") # 替换为实际逻辑 +``` + +## 模式 B 框架(历史窗口) + +```python +"""策略简短描述""" +import polars as pl + +META = { + "id": "ai_xxxxxxxxxxxx", + "name": "用户给的名称", + "description": "用户给的描述", + "tags": ["根据策略添加标签"], + "basic_filter": { + "price_min": 3, + "price_max": 200, + "market_cap_min": 10e8, + "amount_min": 0.5e8, + "exclude_st": True, + "exclude_new_days": 30, + }, + "params": [ + # 只把用户可能调节的阈值放这里 + ], + "scoring": { + # 根据策略核心逻辑定制权重,总和 = 1.0 + }, + "order_by": "score", + "descending": True, + "limit": 100, +} + +LOOKBACK_DAYS = 8 # 根据策略需要的最大回看天数设置 + +ENTRY_SIGNALS = [] +EXIT_SIGNALS = [] + +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 20 + +ALERTS = [] + +RULES = """ +1. 规则一(包含时序逻辑) +2. 规则二 +3. 规则三 +""" + +def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: + if df.is_empty() or "date" not in df.columns: + return df + + # 用 shift/over 回溯历史数据,或用 group_by 计算窗口聚合 + # 重要: 返回所有匹配行,不要只过滤 latest,否则回测只有最后一天有信号 + hist = ( + df.sort(["symbol", "date"]) + .with_columns([ + pl.col("close").shift(1).over("symbol").alias("_prev_close"), + # ... 根据策略需要添加更多回溯列 + ]) + ) + return hist.filter( + # 在此编写筛选条件 + ) +``` + +--- + +## 可用指标列(参考) + +见 [strategy-guide.md](./strategy-guide.md) 第 3 节。 + +## 可用信号列(参考) + +以下信号列已预计算,**根据策略含义自行选择匹配的**,不要全部照搬: + +| 列名 | 含义 | 方向 | +|------|------|------| +| 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 | 跌停翘板 | 买入 | + +**选信号原则**:选和策略逻辑直接相关的,不要凑数。监控类策略两类都选。 + +--- + +## 规则 + +1. 用户可能调节的阈值才放 `params`;公式常数、固定窗口边界不必参数化 +2. 信号列使用 `.fill_null(False)` 处理空值 +3. `filter()` 只返回 `pl.Expr`,`filter_history()` 返回筛选后的 `DataFrame` +4. scoring 权重总和 = 1.0 +5. **必须生成 RULES**:用中文逐条列出核心逻辑(至少 3 条),准确完整 +6. **贴合用户需求**:不为了用已有字段而改变用户本意。用户说"前高"就自己算前高 +7. **输出前自我检查**:确认 RULES 完整、语法正确、括号匹配、引号闭合 +8. **优先 Polars**:不要默认生成逐行/逐股 Python 循环 +9. 直接输出 Python 代码,不要解释文字 diff --git a/serve/docs/strategy-builder-step2.md b/serve/docs/strategy-builder-step2.md new file mode 100644 index 0000000..551ef72 --- /dev/null +++ b/serve/docs/strategy-builder-step2.md @@ -0,0 +1,34 @@ +# 步骤 2:修改策略任意部分 + +你是A股量化策略工程师。根据用户指令修改策略代码的任意部分。 + +## 输入格式 + +分两部分提供: +1. 当前策略的完整 Python 代码 +2. 用户的修改指令(自然语言) + +## 输出要求 + +只输出修改后的完整 Python 代码,不要解释。 + +## 你应该做的事 + +- 增/删/改参数 → 更新 META["params"],同步修改 filter() +- 调整信号 → 更新 ENTRY_SIGNALS / EXIT_SIGNALS +- 修改止损/持有 → 更新 STOP_LOSS / MAX_HOLD_DAYS +- 增减告警 → 更新 ALERTS +- 调整评分 → 更新 META["scoring"],权重总和保持 100 +- 修改筛选逻辑 → 更新 filter();如果新增/删除了历史回溯逻辑,同步改为或移除 filter_history() 与 LOOKBACK_DAYS + +## 规则 + +1. 保持策略文件结构完整,不丢失任何已有字段(包括 RULES) +2. 删除参数后 filter() 中用原 default 值代替 +3. 新增参数要有 type、label、default、min、max、step +4. 删除信号时 ENTRY_SIGNALS / EXIT_SIGNALS 至少保留一个 +5. 如果修改了筛选逻辑,同步更新 RULES 中的对应条目 +6. 用户可能调节的阈值才需要放入 META["params"];公式常数、固定窗口边界不必强行参数化 +7. 优先使用 Polars 表达式、窗口函数、聚合和 join,不要默认改成逐行/逐股 Python 循环 +8. **输出前自我检查**:完整通读修改后的代码,确认 Python 语法正确、括号匹配、引号闭合、缩进一致。有错误直接修正再输出。 +9. 直接输出完整 Python 代码 diff --git a/serve/docs/strategy-example.md b/serve/docs/strategy-example.md new file mode 100644 index 0000000..7e0cca2 --- /dev/null +++ b/serve/docs/strategy-example.md @@ -0,0 +1,153 @@ +# 两步创建示例:强势反包策略 + +本文演示从零创建一个自定义策略的完整流程。 + +--- + +## Step 1:填写规则 + +用户在创建对话框填写: + +| 字段 | 填写内容 | +|------|---------| +| 名称 | **强势反包** | +| 描述 | 筛选前日阴线下跌、今日放量阳线反包的短线强势股 | +| 方向 | 做多 | +| 规则 | 前一交易日为明显阴线且跌幅不低于2%,今日阳线收盘反包前一日实体,收盘价接近或高于前一日高点,成交量较前一日放大1.2倍以上,当前 close > ma5 或 close > ma10;使用 filter_history,并优先用 Polars shift/with_columns/filter 实现。 | + +点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分、告警): + +```python +"""强势反包 — 前日阴线下跌 + 今日放量阳线反包""" +import polars as pl + +META = { + "id": "strong_reversal", + "name": "强势反包", + "description": "筛选前日阴线下跌、今日放量阳线反包的短线强势股", + "tags": ["反包", "短线", "放量"], + "basic_filter": { + "price_min": 3, + "price_max": 200, + "market_cap_min": 10e8, + "amount_min": 0.5e8, + "exclude_st": True, + "exclude_new_days": 30, + }, + "params": [ + { + "id": "prev_down_pct", + "label": "前日最大跌幅", + "type": "float", + "default": -0.02, + "min": -0.10, + "max": -0.005, + "step": 0.005, + }, + { + "id": "volume_ratio", + "label": "成交量放大倍数", + "type": "float", + "default": 1.2, + "min": 1.0, + "max": 5.0, + "step": 0.1, + }, + { + "id": "reversal_tolerance", + "label": "反包容忍误差", + "type": "float", + "default": 0.005, + "min": 0.0, + "max": 0.03, + "step": 0.005, + }, + ], + "scoring": { + "change_pct": 0.4, + "vol_ratio_5d": 0.3, + "momentum_5d": 0.3, + }, + "order_by": "score", + "descending": True, + "limit": 100, +} + +LOOKBACK_DAYS = 2 + +ENTRY_SIGNALS = ["signal_broken_board_recovery"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 10 +ALERTS = [ + {"field": "signal_broken_board_recovery", "message": "反包信号"}, +] + +RULES = """ +1. 前一交易日为阴线,且跌幅不小于设定阈值 +2. 今日为阳线,收盘价收复前一日开盘价并接近或突破前一日高点 +3. 今日成交量较前一日明显放大,且收盘价站上 MA5 或 MA10 +""" + + +def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: + if df.is_empty() or "date" not in df.columns: + return df + + down_pct = float(params.get("prev_down_pct", -0.02)) + vol_ratio = float(params.get("volume_ratio", 1.2)) + tolerance = float(params.get("reversal_tolerance", 0.005)) + latest = df["date"].max() + hist = ( + df.sort(["symbol", "date"]) + .with_columns([ + pl.col("open").shift(1).over("symbol").alias("_prev_open"), + pl.col("high").shift(1).over("symbol").alias("_prev_high"), + pl.col("close").shift(1).over("symbol").alias("_prev_close"), + pl.col("volume").shift(1).over("symbol").alias("_prev_volume"), + pl.col("change_pct").shift(1).over("symbol").alias("_prev_change_pct"), + ]) + ) + return hist.filter(pl.col("date") == latest).filter( + (pl.col("_prev_close") < pl.col("_prev_open")) + & (pl.col("_prev_change_pct") <= down_pct) + & (pl.col("close") > pl.col("open")) + & (pl.col("close") > pl.col("_prev_open")) + & (pl.col("close") >= pl.col("_prev_high") * (1 - tolerance)) + & (pl.col("volume") >= pl.col("_prev_volume") * vol_ratio) + & ((pl.col("close") > pl.col("ma5")) | (pl.col("close") > pl.col("ma10"))) + ) +``` + +--- + +## Step 2:预览 + 指令修改 + +进入第二步,显示完整的策略预览和指令输入框。 + +用户如果觉得反包条件太严格,可以输入「把前日跌幅放宽到 -1.5%,反包前高允许 1% 误差」→ 点 AI 修改。AI 更新 `params` 默认值和 `filter_history()` 逻辑。 + +确认无误后点「保存策略」→ 策略池中出现。 + +--- + +## 后续使用 + +打开策略配置,**基础参数**和**策略参数**分别独立: + +``` +┌─ 配置:强势反包 ──────────────────────────┐ +│ 名称 [强势反包 ] 显示上限 [30]│ +│ │ +│ 📊 基础参数 [启用 ●] │ +│ 价格 [3]~[200]元 排除ST、新股 │ +│ 最低成交额 [5000万] │ +│ │ +│ ⚙ 策略参数 │ +│ 前日最大跌幅 [-0.02] │ +│ 成交量放大倍数 [1.2] │ +│ 反包容忍误差 [0.005] │ +│ │ +│ ⭐ 评分权重 📈 交易参数 │ +└────────────────────────────────────────────┘ +``` diff --git a/serve/docs/strategy-guide.md b/serve/docs/strategy-guide.md new file mode 100644 index 0000000..cd5b684 --- /dev/null +++ b/serve/docs/strategy-guide.md @@ -0,0 +1,347 @@ +# 策略开发指南 + +本文档是策略开发的完整参考。人类开发者参考它编写策略,AI 读取它生成策略代码。 + +## 1. 策略文件格式 + +每个策略是一个 Python 文件,放在以下目录: + +- 内置策略: `backend/app/strategy/builtin/` +- 自定义策略: `data/strategies/custom/`,建议文件名和 ID 使用 `custom_时间戳` +- AI 生成策略: `data/strategies/ai/`,文件名和 ID 使用 `ai_时间戳` + +## 2. 文件结构模板 + +```python +"""策略简短描述""" +import polars as pl + +META = { + "id": "strategy_id", # 英文ID, 唯一, 文件名同名;自定义策略建议 custom_时间戳 + "name": "策略中文名", # 显示名称 + "description": "策略详细描述", # 一句话说明策略逻辑 + "tags": ["标签1", "标签2"], # 分类标签 + + # 基础过滤参数 (Stage 1, 引擎统一处理) + "basic_filter": { + "price_min": 5, # 最低价格 + "price_max": 200, # 最高价格 + "market_cap_min": 20e8, # 最小总市值 (元) + "amount_min": 1e8, # 最小成交额 (元) + "exclude_st": True, # 排除 ST/*ST/退市 + "exclude_new_days": 60, # 排除上市N天内新股 + }, + + # 策略参数 (只把用户可能调节的阈值放这里,公式常数不必参数化) + # 每个参数含 id/label/type/default/min/max/step;select 类型用 options + "params": [ + ], + + # 评分权重 (用于排序, 根据策略核心逻辑定制, 权重总和 = 1.0) + "scoring": { + }, + + "order_by": "score", # 排序字段, 通常用 "score" + "descending": True, # True = 从高到低 + "limit": 100, # 最多返回条数 +} + +# 买入信号 (回测 + 监控用, 根据策略逻辑选择合适的信号列) +ENTRY_SIGNALS = [] + +# 卖出信号 +EXIT_SIGNALS = [] + +# 止损 (负数, 根据策略类型合理设定, 如做多短线 -0.05~-0.08) +STOP_LOSS = -0.05 + +# 最长持有天数 (短线 5~20, 中线 20~60) +MAX_HOLD_DAYS = 20 + +# 提醒条件 (监控用) +ALERTS = [] + + +# 策略规则(人类可读,逐条编号,至少 3 条) +RULES = """ +1. 规则描述一 +2. 规则描述二 +3. 规则描述三 +""" + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + """策略核心过滤逻辑。 + + df: Stage 1 基础过滤后的 enriched 数据 + params: META.params 中定义的参数值 (用户可在前端覆盖) + + 返回: Polars 布尔表达式 (pl.Expr) + """ + # 用 params.get("param_id", 默认值) 读取参数 + return ( + (pl.col("close") > pl.col("ma5")) + & (pl.col("rsi_14") < 30) + ) +``` + +### 历史窗口策略(filter_history) + +普通 `filter()` 只接收当前日期的单日数据。当策略需要以下逻辑时,必须使用 `filter_history()`: + +- "最近 N 天内出现过某个事件"(如涨停、金叉) +- "某个事件发生后的第 X 天"(如涨停后放量下跌) +- "前高 / 前低 / 上次某事件的价格"等需要回溯历史的自定义字段 +- 任何需要多日数据才能计算的时序逻辑 + +**不需要** `filter_history()` 的场景:只用当日指标列做比较(如 close > ma60、rsi_14 < 30)。 + +```python +LOOKBACK_DAYS = 8 # 回看交易日数,根据策略需要设置 + +def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: + """df 包含目标日期之前 LOOKBACK_DAYS 个交易日的数据(所有股票混合)。 + 每行包含 symbol, date 及所有指标列/信号列。 + + 返回值: 筛选后的 DataFrame。 + 重要: 返回所有匹配的行,不要只过滤最新日期,否则回测只有最后一天有信号。 + """ + if df.is_empty() or "date" not in df.columns: + return df + + down_pct = float(params.get("prev_down_pct", -0.02)) + vol_ratio = float(params.get("volume_ratio", 1.2)) + tolerance = float(params.get("reversal_tolerance", 0.005)) + + # 示例: 前日明显阴线下跌,今日放量阳线反包前日实体 + hist = ( + df.sort(["symbol", "date"]) + .with_columns([ + pl.col("open").shift(1).over("symbol").alias("_prev_open"), + pl.col("high").shift(1).over("symbol").alias("_prev_high"), + pl.col("close").shift(1).over("symbol").alias("_prev_close"), + pl.col("volume").shift(1).over("symbol").alias("_prev_volume"), + pl.col("change_pct").shift(1).over("symbol").alias("_prev_change_pct"), + ]) + ) + + return hist.filter( + (pl.col("_prev_close") < pl.col("_prev_open")) + & (pl.col("_prev_change_pct") <= down_pct) + & (pl.col("close") > pl.col("open")) + & (pl.col("close") > pl.col("_prev_open")) + & (pl.col("close") >= pl.col("_prev_high") * (1 - tolerance)) + & (pl.col("volume") >= pl.col("_prev_volume") * vol_ratio) + & ((pl.col("close") > pl.col("ma5")) | (pl.col("close") > pl.col("ma10"))) + ) +``` + +**关键要点:** +- `LOOKBACK_DAYS` 决定引擎加载多少天的数据,设为策略逻辑需要的最大回看天数 +- 优先使用 Polars 的 `with_columns`、`over("symbol")`、`group_by`、`join`、`filter` 实现历史逻辑,避免把数据转成 Python list/dict 循环 +- 只有遇到表达式难以描述的复杂状态机时,才使用 `partition_by("symbol")` + `to_dicts()` 逐股票分析 +- **返回所有匹配行,不要过滤 `latest`**;选股引擎会自动取最新日,回测引擎需要全区间命中 +- 未声明 `filter_history()` 的策略走普通 `filter()` 路径,不受影响 + +## 3. 常用指标列(参考,可直接使用) + +以下列在数据中已预计算,可直接引用。**但如果这些列无法满足策略需求,可以不用,自行在 `filter_history()` 中基于 enriched 表的数据(已复权,含所有指标列和信号列)计算任何需要的字段。** + +### 通用列 + +| 列名 | 类型 | 说明 | +|------|------|------| +| symbol | string | 股票代码 (如 600519.SH) | +| date | date | 交易日期 | + +### 价格相关 + +| 列名 | 类型 | 说明 | +|------|------|------| +| open, high, low, close | float | OHLCV 开高低收 (前复权) | +| raw_close, raw_high, raw_low | float | 原始未复权价 | +| prev_close | float | 昨收价 | +| change_pct | float | 涨跌幅 (如 0.032 = +3.2%) | +| change_amount | float | 涨跌额 | +| amount | float | 成交额 | +| amplitude | float | 振幅 | + +### 均线 + +| 列名 | 说明 | +|------|------| +| ma5, ma10, ma20, ma30, ma60 | 简单移动均线 | +| ema5, ema10, ema20, ema30, ema60 | 指数移动均线 | + +### 技术指标 + +| 列名 | 说明 | +|------|------| +| macd_dif | MACD DIF 线 | +| macd_dea | MACD DEA 线 | +| macd_hist | MACD 柱状 | +| boll_upper, boll_lower | 布林带上/下轨 | +| kdj_k, kdj_d, kdj_j | KDJ 指标 | +| rsi_6, rsi_14, rsi_24 | RSI 相对强弱 | +| atr_14 | 平均真实波幅 | + +### 量能 + +| 列名 | 说明 | +|------|------| +| volume | 成交量 | +| vol_ma5, vol_ma10 | 成交量均线 | +| vol_ratio_5d | 5日量比 | +| turnover_rate | 换手率 | + +### 动量与波动 + +| 列名 | 说明 | +|------|------| +| momentum_5d / 10d / 20d / 30d / 60d | N日涨幅 | +| annual_vol_20d | 20日年化波动率 | +| high_60d, low_60d | 60日最高/最低价 | + +### 涨跌停 + +| 列名 | 说明 | +|------|------| +| consecutive_limit_ups | 连续涨停天数 | +| consecutive_limit_downs | 连续跌停天数 | + +### 运行时附加列(由引擎从 instruments 表 JOIN) + +| 列名 | 说明 | +|------|------| +| name | 股票名称 | +| total_shares | 总股本 | +| float_shares | 流通股本 | + +(`total_shares` 和 `float_shares` 用于 `basic_filter` 中计算市值:`close * total_shares`) + +## 4. 常用信号列(参考) + +信号列是布尔值,**必须**使用 `.fill_null(False)` 处理空值。同样仅供参考,根据策略含义自行选择匹配的。 + +| 列名 | 方向 | 说明 | +|------|------|------| +| 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 | 买入 | 涨停 (依赖 instruments 表,部分环境不生成) | +| signal_limit_down | 卖出 | 跌停 (依赖 instruments 表,部分环境不生成) | +| signal_limit_down_recovery | 买入 | 跌停翘板 (依赖 instruments 表,部分环境不生成) | +| signal_broken_limit_up | 卖出 | 炸板 (依赖 instruments 表,部分环境不生成) | + +> **注意**:涨跌停类信号需要 instruments 表(板块代码)才能计算。如果策略只用涨停判断,优先用 `consecutive_limit_ups >= 1`(稳定列,始终可用)。 + +此外,用户自定义信号(`data/user_data/custom_signals/`)以 `csg_` 前缀注入,也可在 filter() 中引用。 + +## 5. 不可用的数据(重要) + +以下数据**不在** enriched DataFrame 中,策略代码中**不能**直接引用: + +| 数据 | 说明 | +|------|------| +| 财务数据 (PE/PB/ROE/净利润/营收/资产负债等) | 存储在独立 financials 表,未 JOIN | +| 扩展数据 (概念/行业/人气排名/资金流向等) | 存储在 ext_data 目录,未 JOIN | +| 盘中实时数据 (分时价/五档盘口等) | 仅前端轮询使用 | + +如需财务或扩展数据作为筛选条件,需先在系统层面完成 JOIN 再提供给策略(当前未实现)。 + +## 6. 规则 + +1. `filter()` 必须返回 `pl.Expr` (用 `&` `|` 组合布尔表达式);`filter_history()` 返回筛选后的 `DataFrame` +2. 信号列使用 `.fill_null(False)` 处理空值 +3. 用户可能调节的数值阈值通过 `params` 暴露;公式常数、固定窗口边界、一次性内部变量不必强行参数化 +4. `scoring` 权重总和必须为 1.0 +5. 遵循 A 股 T+1 规则 (当日买入次日才能卖出) +6. 只允许 `import polars as pl`,禁止 import 其他模块 +7. 禁止使用 `open()`, `exec()`, `eval()`, `os`, `sys`, `subprocess` +8. **贴合用户需求优先**:第3/4节的指标列和信号列仅供参考,能用则用;如果用户需求需要自定义计算(如"前高""上次涨停价""N日内某个事件后X天"),直接在 `filter_history()` 中自行设计和计算,不需要局限于已有列 +9. `filter_history()` 中优先用 Polars 向量化语法;仅在复杂状态机无法清晰表达时,才用 `partition_by("symbol")` 逐股票分析 + +## 7. 策略示例 + +### 强势反包 + +```python +"""强势反包 — 前日阴线下跌 + 今日放量阳线反包""" +import polars as pl + +META = { + "id": "strong_reversal", + "name": "强势反包", + "description": "前一日明显阴线下跌,今日放量阳线收复前一日阴线实体", + "tags": ["反包", "短线", "放量"], + "basic_filter": { + "price_min": 3, "price_max": 200, + "market_cap_min": 10e8, "amount_min": 0.5e8, + "exclude_st": True, "exclude_new_days": 30, + }, + "params": [ + {"id": "prev_down_pct", "label": "前日最大跌幅", "type": "float", + "default": -0.02, "min": -0.10, "max": -0.005, "step": 0.005}, + {"id": "volume_ratio", "label": "成交量放大倍数", "type": "float", + "default": 1.2, "min": 1.0, "max": 5.0, "step": 0.1}, + {"id": "reversal_tolerance", "label": "反包容忍误差", "type": "float", + "default": 0.005, "min": 0.0, "max": 0.03, "step": 0.005}, + ], + "scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3}, + "order_by": "score", "descending": True, "limit": 100, +} + +LOOKBACK_DAYS = 2 + +ENTRY_SIGNALS = ["signal_broken_board_recovery"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 10 +ALERTS = [{"field": "signal_broken_board_recovery", "message": "反包信号"}] + +RULES = """ +1. 前一交易日为阴线,且跌幅不小于设定阈值 +2. 今日为阳线,收盘价收复前一日开盘价并接近或突破前一日高点 +3. 今日成交量较前一日明显放大,且收盘价站上 MA5 或 MA10 +""" + +def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: + if df.is_empty() or "date" not in df.columns: + return df + + down_pct = float(params.get("prev_down_pct", -0.02)) + vol_ratio = float(params.get("volume_ratio", 1.2)) + tolerance = float(params.get("reversal_tolerance", 0.005)) + latest = df["date"].max() + hist = ( + df.sort(["symbol", "date"]) + .with_columns([ + pl.col("open").shift(1).over("symbol").alias("_prev_open"), + pl.col("high").shift(1).over("symbol").alias("_prev_high"), + pl.col("close").shift(1).over("symbol").alias("_prev_close"), + pl.col("volume").shift(1).over("symbol").alias("_prev_volume"), + pl.col("change_pct").shift(1).over("symbol").alias("_prev_change_pct"), + ]) + ) + return hist.filter( + (pl.col("_prev_close") < pl.col("_prev_open")) + & (pl.col("_prev_change_pct") <= down_pct) + & (pl.col("close") > pl.col("open")) + & (pl.col("close") > pl.col("_prev_open")) + & (pl.col("close") >= pl.col("_prev_high") * (1 - tolerance)) + & (pl.col("volume") >= pl.col("_prev_volume") * vol_ratio) + & ((pl.col("close") > pl.col("ma5")) | (pl.col("close") > pl.col("ma10"))) + ) +``` + +## 8. 完整示例 + +见 [strategy-example.md](./strategy-example.md) — 从零创建强势反包策略的三步完整演示。 diff --git a/serve/frontend/.gitignore b/serve/frontend/.gitignore new file mode 100644 index 0000000..dfa9518 --- /dev/null +++ b/serve/frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.vite +*.log +.DS_Store diff --git a/serve/frontend/index.html b/serve/frontend/index.html new file mode 100644 index 0000000..7f38b92 --- /dev/null +++ b/serve/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + TickFlow Stock Panel · Quant Terminal + + + + + + + +
+ + + diff --git a/serve/frontend/package.json b/serve/frontend/package.json new file mode 100644 index 0000000..20ff333 --- /dev/null +++ b/serve/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "tickflow-stock-panel-frontend", + "private": true, + "version": "0.1.70", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.55.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "echarts": "^5.5.0", + "echarts-for-react": "^3.0.2", + "framer-motion": "^11.5.0", + "lightweight-charts": "^4.2.0", + "lucide-react": "^0.439.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "tailwind-merge": "^2.5.2" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.45", + "tailwindcss": "^3.4.10", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.5.4", + "vite": "^5.4.3" + }, + "packageManager": "pnpm@9.10.0" +} diff --git a/serve/frontend/pnpm-lock.yaml b/serve/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..ed7d8c7 --- /dev/null +++ b/serve/frontend/pnpm-lock.yaml @@ -0,0 +1,1917 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.55.0 + version: 5.100.11(react@18.3.1) + class-variance-authority: + specifier: ^0.7.0 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + echarts: + specifier: ^5.5.0 + version: 5.6.0 + echarts-for-react: + specifier: ^3.0.2 + version: 3.0.6(echarts@5.6.0)(react@18.3.1) + framer-motion: + specifier: ^11.5.0 + version: 11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lightweight-charts: + specifier: ^4.2.0 + version: 4.2.3 + lucide-react: + specifier: ^0.439.0 + version: 0.439.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.26.0 + version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwind-merge: + specifier: ^2.5.2 + version: 2.6.1 + devDependencies: + '@types/node': + specifier: ^25.9.3 + version: 25.9.3 + '@types/react': + specifier: ^18.3.5 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.7.0(vite@5.4.21(@types/node@25.9.3)) + autoprefixer: + specifier: ^10.4.20 + version: 10.5.0(postcss@8.5.15) + postcss: + specifier: ^8.4.45 + version: 8.5.15 + tailwindcss: + specifier: ^3.4.10 + version: 3.4.19 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19) + typescript: + specifier: ^5.5.4 + version: 5.9.3 + vite: + specifier: ^5.4.3 + version: 5.4.21(@types/node@25.9.3) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@tanstack/query-core@5.100.11': + resolution: {integrity: sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==} + + '@tanstack/react-query@5.100.11': + resolution: {integrity: sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==} + peerDependencies: + react: ^18 || ^19 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.29': + resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.31: + resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + echarts-for-react@3.0.6: + resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==} + peerDependencies: + echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + react: ^15.0.0 || >=16.0.0 + + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + + electron-to-chromium@1.5.361: + resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fancy-canvas@2.1.0: + resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@11.18.2: + resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightweight-charts@4.2.3: + resolution: {integrity: sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.439.0: + resolution: {integrity: sha512-PafSWvDTpxdtNEndS2HIHxcNAbd54OaqSYJO90/b63rab2HWYqDbH194j0i82ZFdWOAcf0AHinRykXRRK2PJbw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + motion-dom@11.18.1: + resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} + + motion-utils@11.18.1: + resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + size-sensor@1.0.3: + resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@2.6.1: + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@remix-run/router@1.23.2': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@tanstack/query-core@5.100.11': {} + + '@tanstack/react-query@5.100.11(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.100.11 + react: 18.3.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.29)': + dependencies: + '@types/react': 18.3.29 + + '@types/react@18.3.29': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@25.9.3) + transitivePeerDependencies: + - supports-color + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.31: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.31 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.361 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001793: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + commander@4.1.1: {} + + convert-source-map@2.0.0: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + echarts-for-react@3.0.6(echarts@5.6.0)(react@18.3.1): + dependencies: + echarts: 5.6.0 + fast-deep-equal: 3.1.3 + react: 18.3.1 + size-sensor: 1.0.3 + + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + + electron-to-chromium@1.5.361: {} + + es-errors@1.3.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + fancy-canvas@2.1.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fraction.js@5.3.4: {} + + framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 11.18.1 + motion-utils: 11.18.1 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightweight-charts@4.2.3: + dependencies: + fancy-canvas: 2.1.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.439.0(react@18.3.1): + dependencies: + react: 18.3.1 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + motion-dom@11.18.1: + dependencies: + motion-utils: 11.18.1 + + motion-utils@11.18.1: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + node-releases@2.0.46: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.15 + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-refresh@0.17.0: {} + + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.3(react@18.3.1) + + react-router@6.30.3(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + size-sensor@1.0.3: {} + + source-map-js@1.2.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@2.6.1: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + dependencies: + tailwindcss: 3.4.19 + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-import: 15.1.0(postcss@8.5.15) + postcss-js: 4.1.0(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) + postcss-nested: 6.2.0(postcss@8.5.15) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-interface-checker@0.1.13: {} + + tslib@2.3.0: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@7.24.6: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite@5.4.21(@types/node@25.9.3): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 25.9.3 + fsevents: 2.3.3 + + yallist@3.1.1: {} + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/serve/frontend/postcss.config.js b/serve/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/serve/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/serve/frontend/public/favicon.svg b/serve/frontend/public/favicon.svg new file mode 100644 index 0000000..504f453 --- /dev/null +++ b/serve/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/serve/frontend/src/components/AlertToast.tsx b/serve/frontend/src/components/AlertToast.tsx new file mode 100644 index 0000000..bb9fc1a --- /dev/null +++ b/serve/frontend/src/components/AlertToast.tsx @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { Bell, TrendingUp, TrendingDown, X } from 'lucide-react' +import type { AlertEvent } from '@/lib/api' +import { fmtPct, fmtPrice } from '@/lib/format' +import { cn } from '@/lib/cn' +import { playNotificationSound } from '@/lib/notificationSound' + +// ===== 全局状态 (模块级, 仿 Toast.tsx 模式) ===== +type Item = { id: number; alert: AlertEvent } +let _id = 0 +let _queue: Item[] = [] +const AUTO_DISMISS = 5000 // 5 秒自动消失 +const _listeners: Set<(items: Item[]) => void> = new Set() + +/** 从 localStorage 读取配置 */ +function getEnabled(): boolean { + try { + const v = localStorage.getItem('alert_toast_enabled') + return v === null ? true : v === '1' // 默认开启 + } catch { return true } +} + +function getMaxVisible(): number { + try { + const v = parseInt(localStorage.getItem('alert_toast_max') || '', 10) + return v >= 1 && v <= 10 ? v : 3 // 默认 3, 范围 1-10 + } catch { return 3 } +} + +/** 通知外部配置变更后刷新 (设置页改了配置后调用) */ +export function refreshAlertToastConfig() { + _emit() +} + +function _emit() { _listeners.forEach(fn => fn([..._queue])) } + +/** 推入单条监控告警通知 (兼容入口, 不发声 — 发声由批量入口统一处理) */ +export function pushAlertToast(alert: AlertEvent) { + pushAlertToasts([alert]) +} + +/** + * 批量推入监控告警通知 (一轮 SSE 多只新命中时调用)。 + * - 每条都弹 Toast (受 maxVisible 上限, 超出丢最旧) + * - 整批只播放一声通知音, 避免短时连续响多声刷屏 + */ +export function pushAlertToasts(alerts: AlertEvent[]) { + if (alerts.length === 0) return + if (!getEnabled()) return // 开关关闭: 不弹 + const maxVisible = getMaxVisible() + const newItems = alerts.map(alert => ({ id: ++_id, alert })) + _queue = [..._queue, ...newItems] + // 超出上限: 丢弃最旧的 + if (_queue.length > maxVisible) { + _queue = _queue.slice(-maxVisible) + } + _emit() + for (const item of newItems) { + setTimeout(() => dismiss(item.id), AUTO_DISMISS) + } + playNotificationSound() // 整批只响一声 +} + +/** 手动关闭 */ +export function dismiss(id: number) { + _queue = _queue.filter(t => t.id !== id) + _emit() +} + +// ===== 配色 ===== +const SEVERITY_BAR: Record = { + info: 'bg-accent', warn: 'bg-warning', critical: 'bg-danger', +} +const SOURCE_BADGE: Record = { + strategy: { label: '策略', cls: 'bg-amber-400/15 text-amber-400' }, + signal: { label: '信号', cls: 'bg-accent/15 text-accent' }, + price: { label: '价格', cls: 'bg-emerald-400/15 text-emerald-400' }, + market: { label: '异动', cls: 'bg-purple-500/15 text-purple-400' }, + new_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' }, + dropped: { label: '移出', cls: 'bg-danger/15 text-danger' }, +} + +// ===== 容器 — 挂在 Layout ===== +export function AlertToastContainer() { + const [items, setItems] = useState([]) + const navigate = useNavigate() + + const sub = useCallback(() => { + _listeners.add(setItems) + return () => { _listeners.delete(setItems) } + }, []) + useEffect(sub, [sub]) + + // 点击通知 → 跳转监控中心 + 关闭当前通知 + const handleClick = (id: number) => { + dismiss(id) + navigate('/monitor') + } + + if (!items.length) return null + + return ( +
+ + {items + .filter(item => !(item.alert.source === 'strategy' && !item.alert.symbol)) + .map(item => { + const ev = item.alert + const sev = SEVERITY_BAR[ev.severity ?? 'info'] ?? SEVERITY_BAR.info + const badgeKey = (ev.source === 'strategy' && ev.type) ? ev.type : ev.source + const badge = SOURCE_BADGE[badgeKey] ?? { label: badgeKey, cls: 'bg-elevated text-muted' } + const pct = ev.change_pct ?? 0 + const isStrategy = ev.source === 'strategy' + const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null + const sname = sm ? sm[1] : '' + const isNew = ev.type === 'new_entry' + return ( + handleClick(item.id)} + className="pointer-events-auto relative overflow-hidden rounded-xl border border-border/60 bg-surface/95 backdrop-blur-md shadow-2xl pl-3 pr-2 py-2.5 cursor-pointer hover:border-accent/40 hover:shadow-accent/10 transition-all" + > + {/* 左侧色条 */} +
+ + {/* 顶行: 分类标签 + 代码/名称 + 涨跌幅 + 关闭 */} +
+ + {badge.label} + + {ev.symbol && {ev.symbol}} + {ev.name && {ev.name}} + {ev.change_pct != null && ( + = 0 ? 'text-danger' : 'text-bear')}> + {pct >= 0 ? : } + {fmtPct(pct)} + + )} + +
+ + {/* 底行: 策略类型走新格式, 其他走旧格式 */} + {isStrategy ? ( +
+ + + {isNew ? '进入' : '移出'} + + 策略 + 「{sname}」 + + {ev.price != null && {fmtPrice(ev.price)}} +
+ ) : ( +
+ + {/* message 已含「条件摘要 · 现价 · 涨跌幅」(后端生成), 直接展示避免重复 */} + {ev.message && {ev.message}} +
+ )} + + ) + })} + +
+ ) +} diff --git a/serve/frontend/src/components/CandlestickChart.tsx b/serve/frontend/src/components/CandlestickChart.tsx new file mode 100644 index 0000000..8652938 --- /dev/null +++ b/serve/frontend/src/components/CandlestickChart.tsx @@ -0,0 +1,154 @@ +import { useEffect, useRef } from 'react' +import { + createChart, + CrosshairMode, + type IChartApi, + type ISeriesApi, + type CandlestickData, + type HistogramData, +} from 'lightweight-charts' + +export interface OHLC { + date: string + open: number + high: number + low: number + close: number + volume?: number +} + +export function fmtBigNum(v: number): string { + if (v >= 1_000_000_000_000) return `${(v / 1_000_000_000_000).toFixed(2)}万亿` + if (v >= 100_000_000) return `${(v / 100_000_000).toFixed(2)}亿` + if (v >= 10_000) return `${(v / 10_000).toFixed(0)}万` + return v.toFixed(0) +} + +const THEME = { + background: 'transparent', + textColor: '#A1A1AA', + gridColor: 'rgba(255,255,255,0.04)', + borderColor: '#27272A', + bull: '#F04438', + bear: '#12B76A', + volBull: 'rgba(240,68,56,0.4)', + volBear: 'rgba(18,183,106,0.4)', +} + +interface Props { + data: OHLC[] + height?: number +} + +export function CandlestickChart({ data, height = 480 }: Props) { + const containerRef = useRef(null) + const chartRef = useRef(null) + const candleRef = useRef | null>(null) + const volRef = useRef | null>(null) + + useEffect(() => { + if (!containerRef.current) return + const el = containerRef.current + + const chart = createChart(el, { + width: el.clientWidth, + height, + layout: { + background: { color: THEME.background }, + textColor: THEME.textColor, + fontFamily: 'JetBrains Mono, monospace', + fontSize: 11, + }, + grid: { + vertLines: { color: THEME.gridColor }, + horzLines: { color: THEME.gridColor }, + }, + crosshair: { + mode: CrosshairMode.Normal, + vertLine: { labelVisible: false }, + horzLine: { labelVisible: false }, + }, + rightPriceScale: { borderColor: THEME.borderColor }, + timeScale: { + borderColor: THEME.borderColor, + timeVisible: false, + secondsVisible: false, + }, + }) + + // Candlestick — occupies top 80% via default price scale + const candle = chart.addCandlestickSeries({ + upColor: THEME.bull, + downColor: THEME.bear, + borderUpColor: THEME.bull, + borderDownColor: THEME.bear, + wickUpColor: THEME.bull, + wickDownColor: THEME.bear, + lastValueVisible: false, + priceLineVisible: false, + }) + + // Volume — separate price scale, squeezed to bottom 20% + const volume = chart.addHistogramSeries({ + priceFormat: { type: 'volume' }, + priceScaleId: 'volume', + lastValueVisible: false, + priceLineVisible: false, + }) + chart.priceScale('volume').applyOptions({ + scaleMargins: { top: 0.8, bottom: 0 }, + }) + + chartRef.current = chart + candleRef.current = candle + volRef.current = volume + + const ro = new ResizeObserver(() => { + chart.applyOptions({ width: el.clientWidth }) + }) + ro.observe(el) + + return () => { + ro.disconnect() + chart.remove() + chartRef.current = null + candleRef.current = null + volRef.current = null + } + }, [height]) + + useEffect(() => { + if (!chartRef.current || !candleRef.current || !volRef.current || data.length === 0) return + + candleRef.current.setData( + data.map(d => ({ + time: d.date as any, + open: d.open, + high: d.high, + low: d.low, + close: d.close, + })) as CandlestickData[], + ) + + volRef.current.setData( + data.map(d => ({ + time: d.date as any, + value: d.volume ?? 0, + color: d.close >= d.open ? THEME.volBull : THEME.volBear, + })) as HistogramData[], + ) + + const ts = chartRef.current.timeScale() + if (data.length > 60) { + const startIdx = data.length - 60 + ts.setVisibleRange({ + from: data[startIdx].date as any, + to: data[data.length - 1].date as any, + }) + } else { + ts.fitContent() + } + }, [data]) + + return
+} diff --git a/serve/frontend/src/components/ColumnCustomizer.tsx b/serve/frontend/src/components/ColumnCustomizer.tsx new file mode 100644 index 0000000..814dcd7 --- /dev/null +++ b/serve/frontend/src/components/ColumnCustomizer.tsx @@ -0,0 +1,24 @@ +import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' +import { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns' + +interface ColumnCustomizerProps { + columns: ColumnConfig[] + onChange: (columns: ColumnConfig[]) => void + open: boolean + onClose: () => void +} + +export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCustomizerProps) { + return ( + + ) +} diff --git a/serve/frontend/src/components/DatePicker.tsx b/serve/frontend/src/components/DatePicker.tsx new file mode 100644 index 0000000..ef4fe81 --- /dev/null +++ b/serve/frontend/src/components/DatePicker.tsx @@ -0,0 +1,235 @@ +import { useState, useRef, useEffect } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react' + +interface DatePickerProps { + value: string // YYYY-MM-DD + onChange: (v: string) => void + min?: string + max?: string + placeholder?: string + className?: string + buttonClassName?: string + align?: 'left' | 'right' +} + +const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'] + +function pad(n: number) { return String(n).padStart(2, '0') } +function toDateStr(y: number, m: number, d: number) { + return `${y}-${pad(m + 1)}-${pad(d)}` +} +function todayStr() { + const date = new Date() + return toDateStr(date.getFullYear(), date.getMonth(), date.getDate()) +} +function viewDate(value: string, min?: string, max?: string) { + const source = value || max || min || todayStr() + return { + year: Number(source.slice(0, 4)), + month: Number(source.slice(5, 7)) - 1, + } +} + +export function DatePicker({ + value, + onChange, + min, + max, + placeholder = '选择日期', + className = '', + buttonClassName = '', + align = 'right', +}: DatePickerProps) { + const [open, setOpen] = useState(false) + const [showYearPicker, setShowYearPicker] = useState(false) + const ref = useRef(null) + + // 当前显示的月份 + const [viewYear, setViewYear] = useState(() => viewDate(value, min, max).year) + const [viewMonth, setViewMonth] = useState(() => viewDate(value, min, max).month) + + // 当 value 外部变化时同步 view + useEffect(() => { + const next = viewDate(value, min, max) + setViewYear(next.year) + setViewMonth(next.month) + }, [value, min, max]) + + // 点击外部关闭 + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + const prevMonth = () => { + if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1) } + else setViewMonth(viewMonth - 1) + } + const nextMonth = () => { + if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1) } + else setViewMonth(viewMonth + 1) + } + + // 构建日历格子: 周一为第一天 + const firstDay = new Date(viewYear, viewMonth, 1).getDay() + const offset = firstDay === 0 ? 6 : firstDay - 1 // 周一=0 + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate() + const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate() + + const cells: { day: number; cur: boolean; dateStr: string; disabled: boolean }[] = [] + + // 上月尾部 + for (let i = offset - 1; i >= 0; i--) { + const d = prevMonthDays - i + const m = viewMonth === 0 ? 11 : viewMonth - 1 + const y = viewMonth === 0 ? viewYear - 1 : viewYear + const ds = toDateStr(y, m, d) + cells.push({ day: d, cur: false, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max }) + } + // 当月 + for (let d = 1; d <= daysInMonth; d++) { + const ds = toDateStr(viewYear, viewMonth, d) + cells.push({ day: d, cur: true, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max }) + } + // 下月头部 — 补齐到 6 行 × 7 = 42 + const remain = 42 - cells.length + for (let d = 1; d <= remain; d++) { + const m = viewMonth === 11 ? 0 : viewMonth + 1 + const y = viewMonth === 11 ? viewYear + 1 : viewYear + const ds = toDateStr(y, m, d) + cells.push({ day: d, cur: false, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max }) + } + + const displayLabel = value || placeholder + const today = todayStr() + + return ( +
+ {/* 触发按钮 */} + + + {/* 弹出日历 */} + + {open && ( + + {/* 月份导航 */} +
+ + + +
+ + {showYearPicker ? ( + /* 年份选择网格 */ +
+ {Array.from({ length: 12 }, (_, i) => viewYear - 5 + i).map(y => { + const isSelected = y === Number(value.slice(0, 4)) + const isThisYear = y === new Date().getFullYear() + return ( + + ) + })} +
+ ) : ( + <> + {/* 星期头 */} +
+ {WEEKDAYS.map((w) => ( +
{w}
+ ))} +
+ + {/* 日期格子 */} +
+ {cells.map((c, i) => { + const isSelected = c.dateStr === value + const isToday = c.dateStr === today + return ( + + ) + })} +
+ + )} +
+ )} +
+
+ ) +} diff --git a/serve/frontend/src/components/EChartsCandlestick.tsx b/serve/frontend/src/components/EChartsCandlestick.tsx new file mode 100644 index 0000000..f7bd977 --- /dev/null +++ b/serve/frontend/src/components/EChartsCandlestick.tsx @@ -0,0 +1,1099 @@ +import { useEffect, useRef, useCallback, useMemo } from 'react' +import * as echarts from 'echarts' +import type { ECharts, EChartsOption } from 'echarts' + +export interface OHLC { + date: string + open: number + high: number + low: number + close: number + volume?: number + ma5?: number | null + ma10?: number | null + ma20?: number | null + ma60?: number | null + macd_dif?: number | null + macd_dea?: number | null + macd_hist?: number | null + rsi_6?: number | null + rsi_14?: number | null + rsi_24?: number | null + kdj_k?: number | null + kdj_d?: number | null + kdj_j?: number | null + boll_upper?: number | null + boll_lower?: number | null +} + +export interface ChartMarker { + date: string + kind: 'buy' | 'sell' | 'neutral' + label?: string + /** 若为 true,标记放在蜡烛上方(如涨停连板标签)。 */ + above?: boolean + /** 自定义标签颜色,覆盖默认的 kind 对应色。 */ + color?: string +} + +export interface ChartRange { + start: string + end: string + label?: string + color?: string +} + +export interface ChartPriceLine { + value: number + label?: string + color?: string + start?: string + end?: string +} + +export interface StockInfo { + name?: string + total_shares?: number + float_shares?: number + /** 扩展数据(key: configId__fieldName),来自 klineDaily 的 ext_columns */ + ext?: Record +} + +/** 子图定义 */ +export interface SubChartDef { + key: string + label: string + /** 子图固定高度 px */ + height: number + /** 构建 series 数组 */ + buildSeries: (data: OHLC[]) => any[] + /** 构建信息栏文字 (当前数据行 -> 显示内容) */ + buildInfo: (d: OHLC | null) => { label: string; color: string; value: string }[] + /** Y 轴特殊配置 */ + yAxisConfig?: Record +} + +// ===== 成交量 N 日均量 ===== +function volMaN(data: OHLC[], n: number): (number | null)[] { + const result: (number | null)[] = [] + for (let i = 0; i < data.length; i++) { + if (i < n - 1) { result.push(null); continue } + let sum = 0 + for (let j = i - n + 1; j <= i; j++) sum += data[j].volume ?? 0 + result.push(sum / n) + } + return result +} + +function fmtVol(v: number | null | undefined): string { + if (v == null) return '—' + if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿' + if (v >= 1e4) return (v / 1e4).toFixed(0) + '万' + return v.toFixed(0) +} + +export const SUB_CHARTS: SubChartDef[] = [ + { + key: 'vol', + label: '成交量', + height: 84, + yAxisConfig: { min: 0 }, + buildSeries: (data) => { + const ma5Data = volMaN(data, 5) + const ma10Data = volMaN(data, 10) + return [ + { + name: '成交量', + type: 'bar', + data: data.map(d => ({ + value: d.volume ?? 0, + itemStyle: { + color: d.close >= d.open ? 'rgba(240,68,56,0.6)' : 'rgba(18,183,106,0.6)', + }, + })), + barWidth: '60%', + animation: false, + }, + { + name: 'VOL5', + type: 'line', + data: ma5Data, + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#FACC15' }, + itemStyle: { color: '#FACC15' }, + }, + { + name: 'VOL10', + type: 'line', + data: ma10Data, + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#8B5CF6' }, + itemStyle: { color: '#8B5CF6' }, + }, + ] + }, + buildInfo: (d) => { + if (!d) return [] + return [ + { label: '量', color: d.close >= d.open ? '#C74040' : '#2D9B65', value: fmtVol(d.volume) }, + ] + }, + }, + { + key: 'macd', + label: 'MACD', + height: 72, + buildSeries: (data) => [ + { + name: 'DIF', + type: 'line', + data: data.map(d => d.macd_dif != null ? Number(d.macd_dif) : '-'), + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#FACC15' }, + itemStyle: { color: '#FACC15' }, + }, + { + name: 'DEA', + type: 'line', + data: data.map(d => d.macd_dea != null ? Number(d.macd_dea) : '-'), + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#8B5CF6' }, + itemStyle: { color: '#8B5CF6' }, + }, + { + name: 'MACD', + type: 'bar', + data: data.map(d => { + const v = d.macd_hist + if (v == null) return '-' + return { + value: Number(v), + itemStyle: { color: Number(v) >= 0 ? 'rgba(240,68,56,0.6)' : 'rgba(18,183,106,0.6)' }, + } + }), + barWidth: '40%', + animation: false, + }, + ], + buildInfo: (d) => { + if (!d) return [] + return [ + { label: 'DIF', color: '#FACC15', value: d.macd_dif != null ? d.macd_dif.toFixed(3) : '—' }, + { label: 'DEA', color: '#8B5CF6', value: d.macd_dea != null ? d.macd_dea.toFixed(3) : '—' }, + { label: 'MACD', color: d.macd_hist != null && d.macd_hist >= 0 ? '#C74040' : '#2D9B65', value: d.macd_hist != null ? d.macd_hist.toFixed(3) : '—' }, + ] + }, + }, + { + key: 'rsi', + label: 'RSI', + height: 72, + yAxisConfig: { min: 0, max: 100 }, + buildSeries: (data) => [ + { + name: 'RSI6', + type: 'line', + data: data.map(d => d.rsi_6 != null ? Number(d.rsi_6) : '-'), + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#FACC15' }, + itemStyle: { color: '#FACC15' }, + }, + { + name: 'RSI14', + type: 'line', + data: data.map(d => d.rsi_14 != null ? Number(d.rsi_14) : '-'), + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#3B82F6' }, + itemStyle: { color: '#3B82F6' }, + }, + { + name: 'RSI24', + type: 'line', + data: data.map(d => d.rsi_24 != null ? Number(d.rsi_24) : '-'), + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#8B5CF6' }, + itemStyle: { color: '#8B5CF6' }, + }, + ], + buildInfo: (d) => { + if (!d) return [] + return [ + { label: 'RSI6', color: '#FACC15', value: d.rsi_6 != null ? d.rsi_6.toFixed(1) : '—' }, + { label: 'RSI14', color: '#3B82F6', value: d.rsi_14 != null ? d.rsi_14.toFixed(1) : '—' }, + { label: 'RSI24', color: '#8B5CF6', value: d.rsi_24 != null ? d.rsi_24.toFixed(1) : '—' }, + ] + }, + }, + { + key: 'kdj', + label: 'KDJ', + height: 72, + buildSeries: (data) => [ + { + name: 'K', + type: 'line', + data: data.map(d => d.kdj_k != null ? Number(d.kdj_k) : '-'), + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#FACC15' }, + itemStyle: { color: '#FACC15' }, + }, + { + name: 'D', + type: 'line', + data: data.map(d => d.kdj_d != null ? Number(d.kdj_d) : '-'), + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#3B82F6' }, + itemStyle: { color: '#3B82F6' }, + }, + { + name: 'J', + type: 'line', + data: data.map(d => d.kdj_j != null ? Number(d.kdj_j) : '-'), + smooth: true, symbol: 'none', animation: false, + lineStyle: { width: 1, color: '#8B5CF6' }, + itemStyle: { color: '#8B5CF6' }, + }, + ], + buildInfo: (d) => { + if (!d) return [] + return [ + { label: 'K', color: '#FACC15', value: d.kdj_k != null ? d.kdj_k.toFixed(1) : '—' }, + { label: 'D', color: '#3B82F6', value: d.kdj_d != null ? d.kdj_d.toFixed(1) : '—' }, + { label: 'J', color: '#8B5CF6', value: d.kdj_j != null ? d.kdj_j.toFixed(1) : '—' }, + ] + }, + }, +] + +/** 向后兼容的 INDICATORS 导出 (不含 vol) */ +export const INDICATORS = SUB_CHARTS.filter(s => s.key !== 'vol') + +/** 主图叠加指标 (画在 K 线上方, 不占副图空间) */ +export const OVERLAY_INDICATORS: { key: string; label: string }[] = [ + { key: 'boll', label: 'BOLL' }, +] + +interface Props { + data: OHLC[] + markers?: ChartMarker[] + ranges?: ChartRange[] + priceLines?: ChartPriceLine[] + height?: number + showMA?: boolean + showInfoBar?: boolean + showMarkers?: boolean + onToggleMarkers?: () => void + stockInfo?: StockInfo + symbol?: string + linkedPrice?: number | null + onDateClick?: (date: string) => void + /** 默认可见蜡烛根数, 默认 60 */ + visibleBars?: number + /** 已激活的子图 key 列表 (含 vol, 按点击顺序) */ + activeIndicators?: string[] +} + +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', +} + +/** 可见蜡烛超过此数量时,涨停/炸板标签切换为小圆点。 */ +const COMPACT_THRESHOLD = 60 + +/** 子图上方信息栏高度 (px) */ +const INFO_BAR_H = 16 +/** 子图之间的间距 (px) */ +const SUB_GAP_PX = 4 + +function buildSubInfoGraphics( + data: OHLC[], + infoIdx: number, + activeIndicators: string[], + subStartTop: number, +): any[] { + const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null + const graphics: any[] = [] + let curTop = subStartTop + + activeIndicators.forEach((key) => { + const def = SUB_CHARTS.find(s => s.key === key) + if (!def) return + + const items = def.buildInfo(d) + if (def.key === 'vol' && d) { + const calcVolMa = (n: number) => { + if (infoIdx < n - 1) return null + let sum = 0 + for (let j = infoIdx - n + 1; j <= infoIdx; j++) sum += data[j].volume ?? 0 + return sum / n + } + const vol5 = calcVolMa(5) + const vol10 = calcVolMa(10) + items.push({ label: 'VOL5', color: '#FACC15', value: fmtVol(vol5) }) + items.push({ label: 'VOL10', color: '#8B5CF6', value: fmtVol(vol10) }) + } + + // 每个元素加固定 id,确保 ECharts 增量更新时能正确匹配 + graphics.push({ + id: `sub-sep-${key}`, + type: 'line', + shape: { x1: 0, y1: curTop, x2: 2000, y2: curTop }, + style: { stroke: 'rgba(255,255,255,0.08)', lineWidth: 1 }, + silent: true, z: 0, + }) + graphics.push({ + id: `sub-label-${key}`, + type: 'text', + style: { + text: def.label, + x: 4, y: curTop + 4, + fill: '#8E8E96', + fontSize: 10, fontFamily: 'JetBrains Mono, monospace', + fontWeight: 'bold', + }, + silent: true, z: 10, + }) + + const richTextParts: string[] = [] + const rich: Record = {} + items.forEach((item, idx) => { + const styleKey = `s${idx}` + richTextParts.push(`{${styleKey}|${item.label}:${item.value}}`) + rich[styleKey] = { + fill: item.color, + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + } + }) + graphics.push({ + id: `sub-val-${key}`, + type: 'text', + right: 24, + style: { + text: richTextParts.join(`{gap| }`), + y: curTop + 3, + rich: { + gap: { fill: 'transparent', fontSize: 10 }, + ...rich, + }, + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + textAlign: 'right', + textVerticalAlign: 'top', + }, + silent: true, z: 10, + }) + + curTop += INFO_BAR_H + def.height + SUB_GAP_PX + }) + + return graphics +} + +function buildOption( + data: OHLC[], + dates: string[], + dateIndexMap: Map, + markers: ChartMarker[] | undefined, + ranges: ChartRange[] | undefined, + priceLines: ChartPriceLine[] | undefined, + showMA: boolean, + compact: boolean, + activeIndicators: string[], + containerHeight: number, + infoIdx: number, + linkedPrice: number | null | undefined, +): EChartsOption { + const candleData = data.map(d => [d.open, d.close, d.low, d.high]) + + const hasMA = showMA && data.some(d => d.ma5 != null || d.ma10 != null || d.ma20 != null || d.ma60 != null) + + const markPointData: any[] = [] + if (markers && markers.length > 0) { + for (const m of markers) { + const idx = dateIndexMap.get(m.date) + if (idx == null) continue + const d = data[idx] + const isBuy = m.kind === 'buy' + const isSell = m.kind === 'sell' + + if (m.above) { + const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text) + if (compact) { + markPointData.push({ + name: m.date, coord: [m.date, d.high], + symbol: 'circle', symbolSize: 4, symbolOffset: [0, -10], + itemStyle: { color: dotColor, cursor: 'pointer' }, + label: { show: false }, z: 100, zlevel: 10, + }) + } else { + markPointData.push({ + name: m.date, coord: [m.date, d.high], + symbol: 'circle', symbolSize: 12, symbolOffset: [0, -2], + itemStyle: { color: 'transparent' }, + label: { + show: true, formatter: m.label ?? '', position: 'top', distance: 0, + color: dotColor, fontSize: 10, fontWeight: 'normal', + fontFamily: 'JetBrains Mono, monospace', + }, + z: 100, zlevel: 10, + }) + } + } else { + markPointData.push({ + name: m.label ?? '', + coord: [m.date, isBuy ? d.low : d.high], + symbol: 'arrow', symbolSize: 12, + symbolRotate: isBuy ? 0 : 180, + symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'], + 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, + fontFamily: 'JetBrains Mono, monospace', + }, + }) + } + } + } + + // ====== 布局计算 ====== + const left = 60 + const right = 20 + const topPad = 8 + const candleBottomPad = 22 + + let subTotalH = 0 + const activeSubDefs: SubChartDef[] = [] + activeIndicators.forEach(key => { + const def = SUB_CHARTS.find(s => s.key === key) + if (!def) return + activeSubDefs.push(def) + subTotalH += INFO_BAR_H + def.height + }) + if (activeSubDefs.length > 0) subTotalH += activeSubDefs.length * SUB_GAP_PX + + const candleAvail = Math.max(containerHeight - topPad - candleBottomPad - subTotalH, 100) + + const grids: any[] = [] + const xAxes: any[] = [] + const yAxes: any[] = [] + const series: any[] = [] + const xAxisIndices: number[] = [] + + // ===== grid 0: K线主图 ===== + 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' }, + axisTick: { show: false }, + splitLine: { show: false }, + }) + yAxes.push({ + scale: true, + // 上下各留 3% 边距: 防止最高/最低点的蜡烛贴边, 涨停/炸板标签被遮挡 + 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' }, + }) + xAxisIndices.push(0) + + const markAreaData = (ranges ?? []) + .filter(r => dateIndexMap.has(r.start) && dateIndexMap.has(r.end)) + .map(r => ([ + { + name: r.label ?? '', + xAxis: r.start, + itemStyle: { color: r.color ?? 'rgba(59,130,246,0.08)' }, + label: { + show: !!r.label, + position: 'insideTop', + distance: 8, + color: '#DBEAFE', + backgroundColor: 'rgba(15,23,42,0.72)', + borderColor: 'rgba(59,130,246,0.35)', + borderWidth: 1, + borderRadius: 4, + padding: [2, 6], + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + }, + }, + { xAxis: r.end }, + ])) + + const markLineData: any[] = (priceLines ?? []) + .filter(line => Number.isFinite(line.value)) + .map(line => { + const lineStyle = { + color: line.color ?? THEME.text, + type: 'dashed' as const, + width: 1, + opacity: 0.92, + } + const label = { + show: !!line.label, + formatter: line.label ?? '', + position: 'insideEndTop' as const, + color: line.color ?? THEME.text, + backgroundColor: 'rgba(15,23,42,0.72)', + borderRadius: 4, + padding: [2, 6], + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + } + if (line.start && line.end && dateIndexMap.has(line.start) && dateIndexMap.has(line.end)) { + return [ + { xAxis: line.start, yAxis: line.value }, + { xAxis: line.end, yAxis: line.value, lineStyle, label, symbol: 'none' }, + ] + } + return { yAxis: line.value, lineStyle, label, symbol: 'none' } + }) + + if (linkedPrice != null) { + markLineData.push({ + yAxis: linkedPrice, + lineStyle: { color: '#3B82F6', type: 'dashed', width: 1, opacity: 0.7 }, + label: { + show: true, + formatter: linkedPrice.toFixed(2), + position: 'insideEndTop', + color: '#3B82F6', + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + backgroundColor: 'rgba(24,24,27,0.85)', + borderColor: '#3B82F6', + borderWidth: 1, + padding: [1, 4], + borderRadius: 2, + }, + symbol: 'none', + }) + } + + series.push({ + name: 'K', type: 'candlestick', data: candleData, + animation: false, + itemStyle: { + color: THEME.bull, color0: THEME.bear, + borderColor: THEME.bull, borderColor0: THEME.bear, + cursor: 'pointer', + }, + markPoint: markPointData.length > 0 ? { data: markPointData, animation: false } : undefined, + markArea: markAreaData.length > 0 ? { silent: true, data: markAreaData } : undefined, + markLine: markLineData.length > 0 ? { silent: true, symbol: 'none', data: markLineData, animation: false } : undefined, + }) + + if (hasMA) { + const maLine = (key: keyof OHLC, color: string, name: string) => ({ + name, type: 'line', + data: data.map(d => (d[key] != null ? Number(d[key]) : '-')), + smooth: true, symbol: 'none', animation: false, + 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')) + } + + // BOLL 布林带 — 需在 activeIndicators 中激活 + const showBOLL = activeIndicators.includes('boll') && data.some(d => d.boll_upper != null || d.boll_lower != null) + if (showBOLL) { + const bollLine = (key: keyof OHLC, color: string, name: string) => ({ + name, type: 'line', + data: data.map(d => (d[key] != null ? Number(d[key]) : '-')), + smooth: true, symbol: 'none', animation: false, + silent: true, + lineStyle: { width: 1, color, type: 'dashed' as const }, itemStyle: { color }, + }) + series.push(bollLine('boll_upper', '#E879F9', 'BOLL上')) + series.push(bollLine('boll_lower', '#E879F9', 'BOLL下')) + } + + // ===== 子图区域 ===== + let curTop = topPad + candleAvail + candleBottomPad + + activeSubDefs.forEach((def, i) => { + const gridIdx = i + 1 + const xAxisIdx = i + 1 + const yAxisIdx = i + 1 + + const chartTop = curTop + INFO_BAR_H + grids.push({ + left, right, + top: chartTop, + height: def.height, + show: true, + borderColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + }) + + xAxes.push({ + type: 'category', gridIndex: gridIdx, data: dates, boundaryGap: true, + axisLine: { show: false }, axisLabel: { show: false }, + axisTick: { show: false }, splitLine: { show: false }, + axisPointer: { label: { show: false } }, + }) + + const isFixedRange = !!def.yAxisConfig + yAxes.push({ + scale: !isFixedRange, + ...(isFixedRange ? def.yAxisConfig : {}), + gridIndex: gridIdx, + splitNumber: 2, + axisLine: { show: false }, axisTick: { show: false }, + splitLine: { lineStyle: { color: THEME.grid } }, + axisLabel: { + show: true, color: THEME.text, fontSize: 9, + fontFamily: 'JetBrains Mono, monospace', + }, + }) + + xAxisIndices.push(xAxisIdx) + + const subSeries = def.buildSeries(data) + subSeries.forEach((s: any) => { + series.push({ ...s, xAxisIndex: xAxisIdx, yAxisIndex: yAxisIdx }) + }) + + curTop += INFO_BAR_H + def.height + SUB_GAP_PX + }) + + // 子图信息栏 graphic + const subStartTop = topPad + candleAvail + candleBottomPad + const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop) + + return { + animation: false, + backgroundColor: THEME.bg, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross', crossStyle: { color: '#555' } }, + backgroundColor: 'transparent', + borderWidth: 0, + textStyle: { fontSize: 0 }, + formatter: () => '', + }, + axisPointer: { + link: [{ xAxisIndex: 'all' }], + label: { + backgroundColor: '#333', + fontFamily: 'JetBrains Mono, monospace', + fontSize: 10, + }, + }, + graphic: infoGraphics.length > 0 ? infoGraphics : undefined, + grid: grids, + xAxis: xAxes, + yAxis: yAxes, + dataZoom: [ + { + type: 'inside', + xAxisIndex: xAxisIndices, + start: 0, + end: 100, + moveOnMouseMove: true, + zoomOnMouseWheel: true, + }, + ], + series, + } +} + + +export function EChartsCandlestick({ + data, + markers, + ranges, + priceLines, + height = 480, + showMA = true, + showInfoBar = true, + showMarkers: showMarkersProp = true, + onToggleMarkers: _onToggleMarkers, + stockInfo, + symbol: _symbol, + linkedPrice, + onDateClick, + visibleBars = 60, + activeIndicators = [], +}: Props) { + const containerRef = useRef(null) + const chartRef = useRef(null) + const dataRef = useRef(data) + dataRef.current = data + const onDateClickRef = useRef(onDateClick) + onDateClickRef.current = onDateClick + + // --- 全部用 ref,避免高频交互触发 React 重渲染 --- + const infoIdxRef = useRef(data.length - 1) + const compactRef = useRef(false) + const userZoomRef = useRef<{ start: number; end: number } | null>(null) + + // 需要在闭包中访问最新值的变量 — 先声明占位,后面赋值 + const activeIndicatorsRef = useRef(activeIndicators) + activeIndicatorsRef.current = activeIndicators + const chartHeightRef = useRef(300) + const subTotalHRef = useRef(0) + const getInfoBarHTMLRef = useRef<() => string>(() => '') + + // 强制刷新信息栏 DOM 的回调 + const infoBarRef = useRef(null) + const triggerInfoBarUpdate = useRef(() => { + const idx = infoIdxRef.current + const curData = dataRef.current + const d = idx >= 0 && idx < curData.length ? curData[idx] : null + if (!d) return + const chart = chartRef.current + if (!chart) return + const subStartTop = chartHeightRef.current - subTotalHRef.current + const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop) + if (infoGraphics.length > 0) { + chart.setOption({ graphic: infoGraphics }, { lazyUpdate: true }) + } + }).current + + // 计算子图总高度 + const activeSubDefs = activeIndicators + .map(key => SUB_CHARTS.find(s => s.key === key)) + .filter((d): d is SubChartDef => !!d) + + let subTotalH = 0 + activeSubDefs.forEach(def => { subTotalH += INFO_BAR_H + def.height }) + if (activeSubDefs.length > 0) subTotalH += activeSubDefs.length * SUB_GAP_PX + + const mainInfoBarH = showInfoBar ? 40 : 0 + const minCandleH = 120 + + const chartHeight = Math.max(height - mainInfoBarH, 8 + minCandleH + 14 + subTotalH) + chartHeightRef.current = chartHeight + subTotalHRef.current = subTotalH + + // 预计算 date→index Map (O(1) 查找) + const dates = useMemo(() => data.map(d => d.date), [data]) + const dateIndexMap = useMemo(() => { + const m = new Map() + dates.forEach((d, i) => m.set(d, i)) + return m + }, [dates]) + + // 计算 dataZoom 初始范围 + const initialZoom = useMemo(() => ({ + start: Math.max(0, 100 - (visibleBars / Math.max(data.length, 1)) * 100), + end: 100, + }), [visibleBars, data.length]) + + // ===== 信息栏 HTML 内容 (基于 infoIdxRef.current) ===== + const getInfoBarHTML = useCallback(() => { + let idx = infoIdxRef.current + let d = idx >= 0 && idx < data.length ? data[idx] : null + // fallback: 如果当前 idx 无数据,取最后一根 K 线 + if (!d && data.length > 0) { + idx = data.length - 1 + d = data[idx] + } + if (!d) return '' + 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 floatShares = stockInfo?.float_shares + const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null + + let html = `
` + html += `${d.date}` + html += `` + html += `${d.open.toFixed(2)}` + html += `` + html += `${d.high.toFixed(2)}` + html += `` + html += `${d.low.toFixed(2)}` + html += `` + html += `${d.close.toFixed(2)}` + // 涨跌幅 (收盘后, 换手前; 和收间隔一些距离) + if (prev) { + const chgPct = (chg / prev.close * 100) + html += `${isUp ? '+' : ''}${chgPct.toFixed(2)}%` + } + if (turnoverRate != null) { + html += `换手` + html += `${turnoverRate.toFixed(2)}%` + } + html += `
` + + // 第二行: MA + BOLL + if (showMA) { + html += `
` + if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` + if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` + if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` + if (d.ma60 != null) html += `MA60:${Number(d.ma60).toFixed(2)}` + if (d.boll_upper != null && activeIndicators.includes('boll')) { + html += `BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}` + } + html += `
` + } + + return html + }, [data, stockInfo, showMA, activeIndicators]) + getInfoBarHTMLRef.current = getInfoBarHTML + + // data 变化时重置 infoIdx + useEffect(() => { + infoIdxRef.current = data.length - 1 + compactRef.current = false + userZoomRef.current = null + }, [data.length]) + + // ===== 初始化 chart (只在 chartHeight 变化时重建) ===== + useEffect(() => { + const el = containerRef.current + if (!el) return + + const chart = echarts.init(el, undefined, { renderer: 'canvas' }) + chartRef.current = chart + + // 鼠标移动 → 只更新 ref + DOM,不触发 React re-render + // 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏 + chart.on('updateAxisPointer', (event: any) => { + const axesInfo = event.axesInfo + if (!axesInfo) return // 鼠标移出图表区域,保持当前显示 + for (const info of Object.values(axesInfo)) { + const val = (info as any)?.value + if (val == null) continue + const d = dataRef.current + const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val) + if (idx >= 0 && idx < d.length) { + if (infoIdxRef.current === idx) return + infoIdxRef.current = idx + + // 直接更新信息栏 DOM (通过 ref 读取最新的生成函数) + const infoEl = infoBarRef.current + if (infoEl) { + const html = getInfoBarHTMLRef.current() + if (html) infoEl.innerHTML = html // 只在有内容时更新 + } + + // 更新子图 graphic + triggerInfoBarUpdate() + return + } + } + // 没有找到有效数据 — 不做任何操作,保持上次显示 + }) + + chart.on('click', (params: any) => { + if (params.componentType === 'markPoint' && params.name) { + onDateClickRef.current?.(params.name) + return + } + if (params.seriesName !== 'K' || params.dataIndex == null) return + const d = dataRef.current + const idx = params.dataIndex + if (idx >= 0 && idx < d.length) { + onDateClickRef.current?.(d[idx].date) + } + }) + + // dataZoom → 只更新 ref,不触发 React re-render + // compact 变化时需要增量更新 markPoint + chart.on('dataZoom', () => { + const opt = chart.getOption() as any + const zoom = opt?.dataZoom?.[0] + if (!zoom) return + userZoomRef.current = { start: zoom.start, end: zoom.end } + + const d = dataRef.current + const total = d.length + const visibleCount = Math.round(total * (zoom.end - zoom.start) / 100) + const newCompact = visibleCount > COMPACT_THRESHOLD + if (newCompact !== compactRef.current) { + compactRef.current = newCompact + // compact 变了需要更新 markPoint,但只更新 markPoint series + // 通过 dispatch 自定义事件来增量更新 + updateMarkPoints() + } + }) + + const ro = new ResizeObserver(() => { chart.resize() }) + ro.observe(el) + + return () => { + chart.off('updateAxisPointer') + chart.off('click') + chart.off('dataZoom') + ro.disconnect() + chart.dispose() + chartRef.current = null + } + }, [chartHeight]) // eslint-disable-line react-hooks/exhaustive-deps + + // 增量更新 markPoint (compact 切换时) + function updateMarkPoints() { + const chart = chartRef.current + if (!chart) return + const mkrs = showMarkersProp ? markers : undefined + if (!mkrs || mkrs.length === 0) return + const compact = compactRef.current + const markPointData: any[] = [] + for (const m of mkrs) { + const idx = dateIndexMap.get(m.date) + if (idx == null) continue + const d = data[idx] + const isBuy = m.kind === 'buy' + const isSell = m.kind === 'sell' + if (m.above) { + const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text) + if (compact) { + markPointData.push({ + name: m.date, coord: [m.date, d.high], + symbol: 'circle', symbolSize: 4, symbolOffset: [0, -10], + itemStyle: { color: dotColor, cursor: 'pointer' }, + label: { show: false }, z: 100, zlevel: 10, + }) + } else { + markPointData.push({ + name: m.date, coord: [m.date, d.high], + symbol: 'circle', symbolSize: 12, symbolOffset: [0, -2], + itemStyle: { color: 'transparent' }, + label: { + show: true, formatter: m.label ?? '', position: 'top', distance: 0, + color: dotColor, fontSize: 10, fontWeight: 'normal', + fontFamily: 'JetBrains Mono, monospace', + }, + z: 100, zlevel: 10, + }) + } + } else { + markPointData.push({ + name: m.label ?? '', + coord: [m.date, isBuy ? d.low : d.high], + symbol: 'arrow', symbolSize: 12, + symbolRotate: isBuy ? 0 : 180, + symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'], + 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, + fontFamily: 'JetBrains Mono, monospace', + }, + }) + } + } + chart.setOption({ + series: [{ + name: 'K', + markPoint: markPointData.length > 0 ? { data: markPointData, animation: false } : undefined, + }] + }) + } + + // ===== 核心: 仅在数据/配置变更时全量 setOption ===== + useEffect(() => { + const chart = chartRef.current + if (!chart) return + + const option = buildOption( + data, dates, dateIndexMap, + showMarkersProp ? markers : undefined, + ranges, + priceLines, + showMA, compactRef.current, + activeIndicators, chartHeight, + infoIdxRef.current, + linkedPrice, + ) + + chart.setOption(option, true) + + // 恢复用户缩放位置 + const zoom = userZoomRef.current + if (zoom) { + chart.dispatchAction({ type: 'dataZoom', start: zoom.start, end: zoom.end }) + } else { + chart.dispatchAction({ type: 'dataZoom', start: initialZoom.start, end: initialZoom.end }) + } + + // 初始信息栏 + const infoEl = infoBarRef.current + if (infoEl) { + infoEl.innerHTML = getInfoBarHTML() + } + }, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML]) + + // 渲染信息栏容器 (内容由 JS 直接写入) + const initialHTML = useMemo(() => { + const idx = data.length - 1 + const d = idx >= 0 && idx < data.length ? data[idx] : null + if (!d) return '' + const floatShares = stockInfo?.float_shares + const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null + let html = `
` + html += `${d.date}` + html += `` + html += `${d.open.toFixed(2)}` + html += `` + html += `${d.high.toFixed(2)}` + html += `` + html += `${d.low.toFixed(2)}` + html += `` + const prevClose0 = data[idx-1]?.close ?? d.close + const clr0 = d.close >= prevClose0 ? THEME.bull : THEME.bear + html += `${d.close.toFixed(2)}` + // 涨跌幅 (收盘后, 换手前; 和收间隔一些距离) + if (idx > 0) { + const chgPct0 = ((d.close - prevClose0) / prevClose0 * 100) + html += `${chgPct0 >= 0 ? '+' : ''}${chgPct0.toFixed(2)}%` + } + if (turnoverRate != null) { + html += `换手` + html += `${turnoverRate.toFixed(2)}%` + } + html += `
` + if (showMA) { + html += `
` + if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` + if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` + if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` + if (d.ma60 != null) html += `MA60:${Number(d.ma60).toFixed(2)}` + if (d.boll_upper != null && activeIndicators.includes('boll')) { + html += `BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}` + } + html += `
` + } + return html + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+ {/* 主图信息栏 — 内容由 JS 直接操作 innerHTML */} + {showInfoBar && ( +
+ )} + + {/* ECharts canvas */} +
+
+ ) +} diff --git a/serve/frontend/src/components/EChartsIntraday.tsx b/serve/frontend/src/components/EChartsIntraday.tsx new file mode 100644 index 0000000..65bb6ca --- /dev/null +++ b/serve/frontend/src/components/EChartsIntraday.tsx @@ -0,0 +1,592 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import * as echarts from 'echarts' +import type { ECharts, EChartsOption } from 'echarts' +import type { MinuteKlineRow } from '@/lib/api' + +type YMode = 'adaptive' | 'limit' + +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 { + data: MinuteKlineRow[] + height?: number + prevClose?: number + date?: string + symbol?: string + onPriceHover?: (price: number | null) => void + showLimitLines?: boolean + showAvgLine?: boolean +} + +function fmtTime(dt: string): string { + const match = dt.match(/(\d{2}):(\d{2})/) + if (!match) return dt.slice(11, 16) + const h = (parseInt(match[1]) + 8) % 24 + return `${String(h).padStart(2, '0')}:${match[2]}` +} + +function computeAvgPrice(data: MinuteKlineRow[]): number[] { + // 分时均线 = 累计成交额 / 累计成交量(手→股) + const result: number[] = [] + let sumAmt = 0 + let sumVol = 0 + for (const d of data) { + sumAmt += d.amount + sumVol += d.volume * 100 + result.push(sumVol > 0 ? sumAmt / sumVol : d.close) + } + return result +} + +function fmtAmt(v: number): string { + if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(2)}亿` + if (v >= 10_000) return `${(v / 10_000).toFixed(0)}万` + return v.toFixed(0) +} + +function isValidPrice(v: number | null | undefined): v is number { + return typeof v === 'number' && Number.isFinite(v) && v > 0 +} + +/** 生成全天分时时间刻度 9:30 ~ 11:30, 13:00 ~ 15:00, 每分钟一个点 (共242个) */ +function generateFullDayTimes(): string[] { + const times: string[] = [] + // 上午 9:30 ~ 11:30 (121 分钟) + for (let h = 9; h <= 11; h++) { + const startM = h === 9 ? 30 : 0 + const endM = h === 11 ? 30 : 59 + for (let m = startM; m <= endM; m++) { + times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`) + } + } + // 下午 13:00 ~ 15:00 (121 分钟) + for (let h = 13; h <= 15; h++) { + const endM = h === 15 ? 0 : 59 + for (let m = 0; m <= endM; m++) { + times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`) + } + } + return times +} + +const FULL_DAY_TIMES = generateFullDayTimes() + +/** 根据 symbol 判断涨跌停幅度 (创业板/科创板 ±20%, 北交所 ±30%, 其余 ±10%) */ +function getLimitPct(symbol?: string): number { + if (!symbol) return 0.10 + if (symbol.endsWith('.BJ')) return 0.30 // 北交所 + if (symbol.startsWith('300') || symbol.startsWith('301')) return 0.20 // 创业板 + if (symbol.startsWith('688') || symbol.startsWith('689')) return 0.20 // 科创板 + return 0.10 +} + +/** 计算实际涨跌停价 (四舍五入到2位小数) 和实际涨跌停幅度 */ +function getLimitPrices(prevClose: number, symbol?: string): { + limitUp: number // 涨停价 (四舍五入) + limitDown: number // 跌停价 (四舍五入) + upPct: number // 实际涨停幅度 (如 9.97) + downPct: number // 实际跌停幅度 (如 -9.97) +} { + const pct = getLimitPct(symbol) + const rawUp = prevClose * (1 + pct) + const rawDown = prevClose * (1 - pct) + // A股涨跌停价四舍五入到分 (2位小数) + const limitUp = Math.round(rawUp * 100) / 100 + const limitDown = Math.round(rawDown * 100) / 100 + const upPct = (limitUp - prevClose) / prevClose * 100 + const downPct = (limitDown - prevClose) / prevClose * 100 + return { limitUp, limitDown, upPct, downPct } +} + +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)[] + const highs = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[] + const lows = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[] + const avgData = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[] + const volumes = new Array(FULL_DAY_TIMES.length).fill(null) as (any | null)[] + + const volNeutral = 'rgba(161,161,170,0.5)' + for (let i = 0; i < data.length; i++) { + const timeKey = fmtTime(data[i].datetime) + const idx = timeIndexMap.get(timeKey) + if (idx !== undefined) { + closes[idx] = data[i].close + highs[idx] = data[i].high + lows[idx] = data[i].low + avgData[idx] = avgPrices[i] + 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, + }, + } + } + } + + const areaStyle: any = { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: areaColor }, + { offset: 1, color: 'rgba(0,0,0,0)' }, + ], + }, + } + + const markLineData: any[] = [] + if (prevClose != null) { + markLineData.push({ + yAxis: prevClose, + lineStyle: { color: THEME.refLine, type: 'dashed', width: 1 }, + label: { show: false }, + symbol: 'none', + }) + } + + let yMin: number | undefined + let yMax: number | undefined + let maxDiff = 0 + if (isValidPrice(prevClose) && data.length > 0) { + const priceArrays = showAvgLine ? [closes, highs, lows, avgData] : [closes, highs, lows] + for (const arr of priceArrays) { + for (const v of arr) { + if (!isValidPrice(v)) continue + const diff = Math.abs(v - prevClose) + if (diff > maxDiff) maxDiff = diff + } + } + + if (showLimitLines && yMode === 'limit') { + const { limitUp, limitDown } = getLimitPrices(prevClose, symbol) + const limitDiffUp = limitUp - prevClose + const limitDiffDown = prevClose - limitDown + const limitDiff = Math.max(limitDiffUp, limitDiffDown) + // 涨跌停模式: Y 轴按实际涨跌停价 + maxDiff = limitDiff + yMin = prevClose - maxDiff + yMax = prevClose + maxDiff + // 加 markLine 标注涨停价和跌停价 (仅虚线, 不显示文字) + markLineData.push( + { + yAxis: limitUp, + lineStyle: { color: 'rgba(199,64,64,0.4)', type: 'dashed', width: 1 }, + label: { show: false }, + symbol: 'none', + }, + { + yAxis: limitDown, + lineStyle: { color: 'rgba(45,155,101,0.4)', type: 'dashed', width: 1 }, + label: { show: false }, + symbol: 'none', + }, + ) + } else { + // 自适应模式: Y 轴按实际涨跌幅对称, 但不超出实际涨跌停范围 + if (showLimitLines) { + const { limitUp, limitDown } = getLimitPrices(prevClose, symbol) + const limitDiff = Math.max(limitUp - prevClose, prevClose - limitDown) + maxDiff = Math.min(maxDiff, limitDiff) + } + if (!showLimitLines && maxDiff > 0) { + maxDiff *= 1.1 + } + // 至少保证一个可视范围 (防止数据平时 maxDiff=0)。指数不使用涨跌停范围,最小范围要更紧,否则低波动指数会被压成横线。 + const minDiff = showLimitLines ? prevClose * 0.01 : prevClose * 0.001 + if (maxDiff < minDiff) maxDiff = minDiff + yMin = prevClose - maxDiff + yMax = prevClose + maxDiff + } + } + + // x 轴标签: 9:30, 10:30, 11:30/13:00, 14:00, 15:00 + // 11:30(idx 120) 和 13:00(idx 121) 相邻会重叠, 合并为一个标签 + const xAxisLabelMap: Record = { + 0: '9:30', + 60: '10:30', + 120: '11:30/13:00', + 181: '14:00', + 241: '15:00', + } + const xAxisLabelFormatter = (_value: string, idx: number) => { + return xAxisLabelMap[idx] ?? '' + } + + return { + animation: false, + backgroundColor: 'transparent', + tooltip: { + trigger: 'axis', + backgroundColor: 'transparent', + borderWidth: 0, + textStyle: { fontSize: 0 }, + formatter: () => '', + axisPointer: { + type: 'cross', + label: { + show: true, + backgroundColor: 'rgba(39,39,42,0.9)', + borderColor: 'rgba(255,255,255,0.1)', + borderWidth: 1, + padding: [2, 5], + color: '#A1A1AA', + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + }, + 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: { + link: [{ xAxisIndex: 'all' }], + }, + grid: [ + { left: 60, right: 55, top: 24, bottom: '28%' }, + { left: 60, right: 55, top: '74%', bottom: 20 }, + ], + xAxis: [ + { + type: 'category', + data: FULL_DAY_TIMES, + boundaryGap: false, + axisPointer: { + show: true, + lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 }, + label: { + show: true, + backgroundColor: 'rgba(39,39,42,0.9)', + borderColor: 'rgba(255,255,255,0.1)', + borderWidth: 1, + padding: [2, 4], + color: '#A1A1AA', + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + formatter: (params: any) => { + return params.value ?? '' + }, + }, + }, + axisLine: { show: false }, + axisLabel: { + color: THEME.text, + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + formatter: xAxisLabelFormatter, + interval: 0, + }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { color: 'rgba(255,255,255,0.04)' }, + }, + }, + { + type: 'category', + gridIndex: 1, + data: FULL_DAY_TIMES, + boundaryGap: false, + axisLine: { show: false }, + axisLabel: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }, + ], + yAxis: [ + { + type: 'value', + min: yMin, + max: yMax, + interval: maxDiff || undefined, + splitArea: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: THEME.grid } }, + axisPointer: { + label: { + formatter: (params: any) => { + const v = params.value + return typeof v === 'number' ? v.toFixed(2) : '' + }, + }, + }, + axisLabel: { + color: THEME.text, + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + formatter: (v: number) => v.toFixed(2), + }, + }, + { + scale: true, + gridIndex: 1, + splitNumber: 2, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + }, + ...(isValidPrice(prevClose) && yMin != null && yMax != null ? [{ + type: 'value' as const, + position: 'right' as const, + gridIndex: 0, + min: yMin, + max: yMax, + interval: maxDiff || undefined, + splitArea: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisPointer: { + label: { + formatter: (params: any) => { + const v = params.value + if (typeof v !== 'number') return '' + const pct = (v - prevClose) / prevClose * 100 + if (Math.abs(pct) < 0.01) return '0.00%' + return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%' + }, + }, + }, + axisLabel: { + color: THEME.text, + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + formatter: (v: number) => { + const pct = (v - prevClose) / prevClose * 100 + if (Math.abs(pct) < 0.01) return '0.00%' + return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%' + }, + }, + }] : []), + ], + series: [ + { + name: '价格', + type: 'line', + data: closes, + smooth: false, + symbol: 'none', + cursor: 'crosshair', + lineStyle: { width: 1.2, color: lineColor }, + areaStyle, + connectNulls: true, + markLine: markLineData.length > 0 ? { symbol: 'none', data: markLineData, animation: false, silent: true } : undefined, + }, + ...(showAvgLine ? [{ + name: '均价', + type: 'line' as const, + data: avgData, + smooth: false, + symbol: 'none', + cursor: 'crosshair', + lineStyle: { width: 1, color: THEME.avgLine }, + connectNulls: true, + }] : []), + { + name: '成交量', + type: 'bar', + data: volumes, + xAxisIndex: 1, + yAxisIndex: 1, + cursor: 'crosshair', + }, + ], + } +} + +export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, onPriceHover, showLimitLines = true, showAvgLine = true }: Props) { + const containerRef = useRef(null) + const chartRef = useRef(null) + const roRef = useRef(null) + const moRef = useRef(null) + const dataRef = useRef(data) + dataRef.current = data + const onPriceHoverRef = useRef(onPriceHover) + onPriceHoverRef.current = onPriceHover + // 全日索引 → 数据数组索引 的映射 (ref 避免重建 chart) + const fullDayToDataIdx = useRef>(new Map()) + + const [infoIdx, setInfoIdx] = useState(data.length - 1) + const [yMode, setYMode] = useState('adaptive') + const avgPrices = useMemo(() => computeAvgPrice(data), [data]) + + // 分时线颜色:基于最新价 vs 昨收 + const lastClose = data.length > 0 ? data[data.length - 1].close : null + const lineIsUp = lastClose != null && prevClose != null ? lastClose > prevClose : true + const lineIsFlat = lastClose != null && prevClose != null ? lastClose === prevClose : false + const lineColor = lineIsFlat ? '#A1A1AA' : lineIsUp ? '#C74040' : '#2D9B65' + const areaFill = lineIsFlat ? 'rgba(180,180,190,0.40)' : lineIsUp ? 'rgba(199,64,64,0.40)' : 'rgba(34,197,94,0.40)' + + useEffect(() => { + setInfoIdx(data.length - 1) + }, [data.length]) + + useEffect(() => { + const el = containerRef.current + if (!el) return + + let chart = chartRef.current + if (!chart) { + chart = echarts.init(el, undefined, { renderer: 'canvas' }) + chartRef.current = chart + // 强制 canvas 使用十字光标,覆盖 ECharts 默认的 pointer + const forceCursor = () => { + const canvases = el.querySelectorAll('canvas') + canvases.forEach(c => { c.style.setProperty('cursor', 'crosshair', 'important') }) + } + forceCursor() + // MutationObserver: ECharts 内部可能重建/修改 canvas 属性,持续强制 cursor + const mo = new MutationObserver(forceCursor) + mo.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }) + moRef.current = mo + roRef.current = new ResizeObserver(() => { + chart!.resize() + forceCursor() + }) + roRef.current.observe(el) + + chart.on('updateAxisPointer', (event: any) => { + const axesInfo = event.axesInfo + if (!axesInfo) return + for (const info of Object.values(axesInfo)) { + const val = (info as any)?.value + if (val == null) continue + const fullDayIdx = typeof val === 'number' ? val : -1 + if (fullDayIdx >= 0) { + const dataIdx = fullDayToDataIdx.current.get(fullDayIdx) ?? -1 + setInfoIdx(dataIdx) + const d = dataRef.current + if (dataIdx >= 0 && dataIdx < d.length) { + onPriceHoverRef.current?.(d[dataIdx].close) + } + return + } + } + }) + + chart.on('globalout', () => { + onPriceHoverRef.current?.(null) + }) + } + + if (data.length > 0) { + // 构建全日索引 → 数据索引 的映射 + const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i])) + const mapping = new Map() + for (let i = 0; i < data.length; i++) { + const timeKey = fmtTime(data[i].datetime) + const fullDayIdx = timeIndexMap.get(timeKey) + if (fullDayIdx !== undefined) { + mapping.set(fullDayIdx, i) + } + } + fullDayToDataIdx.current = mapping + + 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]) + + useEffect(() => { + return () => { + chartRef.current?.off('updateAxisPointer') + chartRef.current?.off('globalout') + moRef.current?.disconnect() + roRef.current?.disconnect() + chartRef.current?.dispose() + chartRef.current = null + moRef.current = null + roRef.current = null + } + }, []) + + const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null + const avg = d != null ? avgPrices[infoIdx] : null + const chg = d && prevClose != null ? d.close - prevClose : null + const isUp = chg != null ? chg > 0 : true + const isFlat = chg != null ? chg === 0 : false + const priceClr = isFlat ? '#A1A1AA' : isUp ? '#C74040' : '#2D9B65' + + return ( +
+ {/* 按钮行: 切换式按钮组, 居右 */} + {showLimitLines &&
+
+ +
+ +
+
} +
+ {/* 第一行: 日期 + OHLC */} +
+ {!d && } + {d && ( + <> + {date && {date}} + + {d.open.toFixed(2)} + + {d.high.toFixed(2)} + + {d.low.toFixed(2)} + + {d.close.toFixed(2)} + + )} +
+ {/* 第二行: 价格+均价+量+额 */} +
+ {d && ( + <> + + + {d.close.toFixed(2)} + + {showAvgLine && + + {avg?.toFixed(2)} + } + + {d.volume.toFixed(0)} + + {fmtAmt(d.amount)} + + )} +
+
+
+
+ ) +} diff --git a/serve/frontend/src/components/EmptyState.tsx b/serve/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..3132e90 --- /dev/null +++ b/serve/frontend/src/components/EmptyState.tsx @@ -0,0 +1,20 @@ +import { type LucideIcon, Construction } from 'lucide-react' + +interface Props { + icon?: LucideIcon + title: string + hint?: string +} + +// §6.0.5 四态评审 — empty 状态:图示 + 引导,而不是一句"暂无数据" +export function EmptyState({ icon: Icon = Construction, title, hint }: Props) { + return ( +
+
+ +

{title}

+ {hint &&

{hint}

} +
+
+ ) +} diff --git a/serve/frontend/src/components/EndpointTestDialog.tsx b/serve/frontend/src/components/EndpointTestDialog.tsx new file mode 100644 index 0000000..86bca85 --- /dev/null +++ b/serve/frontend/src/components/EndpointTestDialog.tsx @@ -0,0 +1,263 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { motion, AnimatePresence } from 'framer-motion' +import { Wifi, Play, Loader2, X, Check, Crown } from 'lucide-react' +import { api, type EndpointItem } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { EXPERT_RANK, tierRank } from '@/lib/capability-labels' + +interface EpResult { + ok: boolean + median_ms?: number | null + min_ms?: number | null + max_ms?: number | null + rounds?: number + success?: number + error?: string +} + +export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose }: { hasKey: boolean; tierLabel: string; currentEndpoint: string; onClose: () => void }) { + const qc = useQueryClient() + const [results, setResults] = useState>({}) + const [testing, setTesting] = useState>({}) + const [switching, setSwitching] = useState(null) + + // 动态加载端点清单 —— 前端无法跨域直连 tickflow.org,走后端代理 + const { data, isLoading } = useQuery({ + queryKey: QK.endpoints, + queryFn: api.listEndpoints, + staleTime: 5 * 60 * 1000, + }) + + const endpoints = data?.endpoints ?? [] + const isFallback = data?.source === 'fallback' + const testRounds = data?.testRounds + + async function testOne(url: string) { + setTesting(prev => ({ ...prev, [url]: true })) + setResults(prev => ({ ...prev, [url]: null })) + try { + const res = await api.testEndpoint(url, testRounds) + setResults(prev => ({ ...prev, [url]: res })) + } catch (e: any) { + setResults(prev => ({ ...prev, [url]: { ok: false, error: e?.message ?? '请求失败' } })) + } finally { + setTesting(prev => ({ ...prev, [url]: false })) + } + } + + async function testAll() { + setResults({}) + await Promise.all(endpoints.map(ep => testOne(ep.url))) + } + + const anyTesting = Object.values(testing).some(Boolean) + const isFree = !hasKey + // 专线端点需 Expert 及以上套餐;Free 模式必然不可用 + const canUsePremium = !isFree && tierRank(tierLabel) >= EXPERT_RANK + const currentLabel = endpoints.find(ep => ep.url === currentEndpoint)?.label ?? currentEndpoint + + async function applyEndpoint(url: string) { + setSwitching(url) + try { + await api.switchEndpoint(url) + await qc.invalidateQueries({ queryKey: QK.settings }) + onClose() + } catch { + // 错误由 query 处理 + } finally { + setSwitching(null) + } + } + + return ( + +
+ + + {/* 顶栏 */} +
+
+ + 端点测速 +
+
+ {isFree && ( + Free 模式 + )} + + +
+
+ + {/* 当前使用 */} +
+ + 当前使用 + {currentLabel} + {currentEndpoint.replace('https://', '')} +
+ + {/* Free 模式提示 —— 以下均为 Starter+ 付费端点 */} + {isFree && ( +
+ + 以下均为 Starter+ 付费端点,Free 模式不可使用。配置 API Key 后可自动切换并测速选优。 + +
+ )} + + {/* 端点列表 —— 可滚动区,顶栏/当前使用/底栏始终可见 */} +
+ {isLoading ? ( +
+ + 加载端点列表… +
+ ) : endpoints.length === 0 ? ( +
未能加载端点列表
+ ) : ( + endpoints.map(ep => ( + + )) + )} +
+ + {isFallback ? ( + 远程获取失败,显示内置列表 + ) : null} +
+
+
+ ) +} + +function EpRow({ ep, result, testing, isCurrent, isFree, canUsePremium, switching, onApply }: { + ep: EndpointItem + result: EpResult | null + testing?: boolean + isCurrent?: boolean + isFree?: boolean + canUsePremium?: boolean + switching: string | null + onApply: (url: string) => void +}) { + const isError = result && !result.ok + const canApply = result?.ok && !testing && !isCurrent + const isPremium = ep.premium === true + const median = result?.median_ms + + return ( +
+
+ {/* 第1行:label + 徽章(左) / 中位延迟(右) */} +
+
+ {ep.label} + {isPremium && ( + + + 专线 + + )} + {isCurrent && ( + 使用中 + )} +
+ {/* 中位延迟 —— 测试中/前/后都占位,避免高度跳动 */} + + {isFree ? ( + // Free 模式:普通付费端点需 Starter+,premium 端点需 Expert+ + + {isPremium ? 'Expert+' : 'Starter+'} + + ) : testing ? ( + 测试中… + ) : result && result.ok && median != null ? ( + + {median} ms + + ) : result && !result.ok ? ( + {result.error ?? '不可达'} + ) : ( + + )} + +
+ + {/* 第2行:description */} + {ep.description} + + {/* 第3行:URL(左) / min~max·成功率(右) —— 副信息始终占位,行数不变 */} +
+ {ep.url.replace('https://', '')} + + {result && result.ok && result.min_ms != null + ? `${result.min_ms}~${result.max_ms} · ${result.success}/${result.rounds}` + : '\u00A0'} + +
+
+ + {/* 应用按钮区域 —— Free 模式不可用任何付费端点;专线端点需 Expert+ */} + {isFree ? null : (isPremium && !canUsePremium) ? ( + // 专线端点:需 Expert 及以上套餐权限,当前套餐不足,不可应用 + + + Expert+ + + ) : canApply ? ( + + ) : null} +
+ ) +} diff --git a/serve/frontend/src/components/ExtDimensionAnalysis.tsx b/serve/frontend/src/components/ExtDimensionAnalysis.tsx new file mode 100644 index 0000000..30b04cf --- /dev/null +++ b/serve/frontend/src/components/ExtDimensionAnalysis.tsx @@ -0,0 +1,436 @@ +import { useMemo, useState, type ComponentType } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + BarChart3, + CalendarDays, + Database, + Layers3, + Search, + Tags, + TrendingUp, + Users, +} from 'lucide-react' +import { PageHeader } from '@/components/PageHeader' +import { api, type AnalysisColumn, type ExtDataConfig, type ExtDataField } from '@/lib/api' +import { QK } from '@/lib/queryKeys' + +interface DimensionAnalysisProps { + menuId?: string + title?: string + subtitle?: string + kindLabel?: string + emptyHint?: string + accentClass?: string + keywords?: string[] + fallbackFieldNames?: string[] +} + +interface DimensionGroup { + key: string + count: number + rows: Record[] + metrics: Record +} + +const PAGE_LIMIT = 5000 +const SEPARATORS = /[、,,;;|/\s]+/ + +function normalizeText(v: unknown): string { + if (v == null) return '' + return String(v).trim() +} + +function fieldLabel(fields: ExtDataField[], name: string): string { + return fields.find(f => f.name === name)?.label || name +} + +function isNumericField(f: ExtDataField) { + return f.dtype === 'int' || f.dtype === 'float' +} + +function scoreConfig(config: ExtDataConfig, keywords: string[]) { + const haystack = [ + config.id, + config.label, + config.description ?? '', + ...config.fields.flatMap(f => [f.name, f.label]), + ].join(' ').toLowerCase() + return keywords.reduce((n, k) => n + (haystack.includes(k.toLowerCase()) ? 1 : 0), 0) +} + +function pickDefaultConfig(configs: ExtDataConfig[], keywords: string[]) { + const ranked = [...configs].sort((a, b) => scoreConfig(b, keywords) - scoreConfig(a, keywords)) + return ranked[0]?.id ?? '' +} + +function pickDefaultDimensionField(config: ExtDataConfig | undefined, fallbackFieldNames: string[]) { + if (!config) return '' + const fields = config.fields.filter(f => f.name !== 'symbol' && f.name !== 'code') + for (const name of fallbackFieldNames) { + const matched = fields.find(f => f.name.toLowerCase().includes(name) || f.label.toLowerCase().includes(name)) + if (matched) return matched.name + } + return fields.find(f => !isNumericField(f))?.name ?? fields[0]?.name ?? '' +} + +function formatNumber(v: unknown, digits = 2) { + if (typeof v !== 'number' || !Number.isFinite(v)) return '—' + return v.toLocaleString('zh-CN', { maximumFractionDigits: digits }) +} + +function formatValue(v: unknown, col?: AnalysisColumn) { + if (v == null || v === '') return '—' + if (typeof v === 'boolean') return v ? '是' : '否' + if (typeof v !== 'number') return String(v) + + const digits = col?.precision ?? (col?.type === 'number' || col?.type === 'amount' || col?.type === 'percent' ? 2 : 2) + if (col?.type === 'percent') return `${formatNumber(v, digits)}%` + if (col?.type === 'amount' || col?.format === 'amount') return formatNumber(v, digits) + return formatNumber(v, digits) +} + +function splitDimensionValues(value: unknown) { + const text = normalizeText(value) + if (!text) return [] + return text.split(SEPARATORS).map(s => s.trim()).filter(Boolean) +} + +function StatCard({ label, value, hint, icon: Icon }: { + label: string + value: string | number + hint: string + icon: ComponentType<{ className?: string }> +}) { + return ( +
+
+ {label} + +
+
{value}
+
{hint}
+
+ ) +} + +function columnsFromFields(fields: ExtDataField[], dimensionField: string, rows: Record[]) { + const numericFields = fields.filter(f => isNumericField(f) && rows.some(r => typeof r[f.name] === 'number')).slice(0, 4) + return [ + ...fields.filter(f => ['symbol', 'code', 'name', '股票简称', '股票代码'].includes(f.name)), + ...fields.filter(f => f.name === dimensionField), + ...numericFields, + ] + .filter((f, i, arr) => arr.findIndex(x => x.name === f.name) === i) + .map(f => ({ + field: f.name, + label: f.label || f.name, + type: isNumericField(f) ? 'number' : 'string', + precision: f.dtype === 'float' ? 2 : null, + sortable: isNumericField(f), + visible: true, + })) +} + +function aggregate(rows: Record[], col: AnalysisColumn) { + if (col.field === '__count') return rows.length + const values = rows.map(r => r[col.field]).filter((v): v is number => typeof v === 'number' && Number.isFinite(v)) + if (!values.length) return null + switch (col.aggregate) { + case 'sum': return values.reduce((a, b) => a + b, 0) + case 'min': return Math.min(...values) + case 'max': return Math.max(...values) + case 'avg': + default: + return values.reduce((a, b) => a + b, 0) / values.length + } +} + +export function ExtDimensionAnalysis({ + menuId, + title, + subtitle, + kindLabel = '维度', + emptyHint = '还没有可用于分析的扩展数据。', + accentClass = 'bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.16),transparent_36%)]', + keywords = [], + fallbackFieldNames = [], +}: DimensionAnalysisProps) { + const configs = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) + const menuQuery = useQuery({ + queryKey: QK.analysisMenu(menuId ?? ''), + queryFn: () => api.analysisMenu(menuId!), + enabled: !!menuId, + }) + const menu = menuQuery.data + + const [selectedConfigId, setSelectedConfigId] = useState('') + const [dimensionField, setDimensionField] = useState('') + const [search, setSearch] = useState('') + const [selectedGroup, setSelectedGroup] = useState(null) + + const availableConfigs = configs.data?.items ?? [] + const activeConfigId = menu?.data_source || selectedConfigId || pickDefaultConfig(availableConfigs, keywords) + const activeConfig = availableConfigs.find(c => c.id === activeConfigId) + const activeDimensionField = menu?.dimension_field || dimensionField || pickDefaultDimensionField(activeConfig, fallbackFieldNames) + const activeTitle = menu?.label || title || '扩展分析' + const activeKindLabel = menu?.template === 'table' ? '数据' : kindLabel + + const configuredColumns = useMemo(() => { + const cols = menu?.detail_columns?.filter(c => c.visible !== false) ?? [] + return cols.length > 0 ? cols : null + }, [menu?.detail_columns]) + const requestedColumns = useMemo(() => { + const cols = [activeDimensionField, ...(configuredColumns?.map(c => c.field) ?? [])].filter(Boolean) + return Array.from(new Set(cols)) + }, [activeDimensionField, configuredColumns]) + const columnsKey = requestedColumns.join(',') + + const rowsQuery = useQuery({ + queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT, columnsKey), + queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT, columns: requestedColumns }), + enabled: !!activeConfigId, + }) + + const rows = rowsQuery.data?.rows ?? [] + const baseFields = activeConfig?.fields ?? rowsQuery.data?.fields ?? [] + const fields = rows.some(r => r.name != null) && !baseFields.some(f => f.name === 'name') + ? [...baseFields, { name: 'name', dtype: 'string', label: '名称' }] + : baseFields + const dimensionOptions = fields.filter(f => f.name !== 'symbol' && f.name !== 'code') + const displayColumns = configuredColumns ?? columnsFromFields(fields, activeDimensionField, rows) + const groupColumns = (menu?.group_columns?.length ? menu.group_columns : [ + { field: '__dimension', label: activeKindLabel }, + { field: '__count', label: '股票数', type: 'number' as const, sortable: true }, + ]).filter(c => c.visible !== false) + + const sortedRows = useMemo(() => { + if (!menu?.default_sort?.field) return rows + const factor = menu.default_sort.order === 'asc' ? 1 : -1 + return [...rows].sort((a, b) => { + const av = a[menu.default_sort!.field] + const bv = b[menu.default_sort!.field] + if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor + return String(av ?? '').localeCompare(String(bv ?? '')) * factor + }) + }, [rows, menu?.default_sort]) + + const groups = useMemo(() => { + if (!activeDimensionField || menu?.template === 'table' || menu?.template === 'ranking') return [] + const map = new Map[]>() + for (const row of sortedRows) { + const values = splitDimensionValues(row[activeDimensionField]) + for (const value of values) { + const item = map.get(value) ?? [] + item.push(row) + map.set(value, item) + } + } + return [...map.entries()] + .map(([key, itemRows]) => ({ + key, + count: itemRows.length, + rows: itemRows, + metrics: Object.fromEntries(groupColumns.map(col => [col.field, aggregate(itemRows, col)])), + })) + .sort((a, b) => b.count - a.count) + }, [sortedRows, activeDimensionField, groupColumns, menu?.template]) + + const filteredGroups = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return groups + return groups.filter(g => g.key.toLowerCase().includes(q)) + }, [groups, search]) + + const currentGroup = filteredGroups.find(g => g.key === selectedGroup) ?? filteredGroups[0] + const tableRows = menu?.template === 'table' || menu?.template === 'ranking' ? sortedRows : (currentGroup?.rows ?? []) + const coveredSymbols = useMemo(() => { + const set = new Set() + rows.forEach(r => { if (r.symbol) set.add(String(r.symbol)) }) + return set.size + }, [rows]) + const missingConfiguredColumns = displayColumns.filter(c => rows.length > 0 && !Object.prototype.hasOwnProperty.call(rows[0], c.field)) + + return ( + <> + + + +
+ } + /> + +
+
+
+
+ + 扩展数据驱动 · 菜单可配置 · 列动态渲染 +
+

{activeTitle}

+

+ 页面按分析菜单配置读取扩展数据源,使用分组字段生成榜单,并严格按照 detail_columns 渲染明细列。 +

+
+
+ +
+
+ + {configs.isLoading || menuQuery.isLoading ? ( +
加载配置中…
+ ) : menuQuery.isError ? ( +
分析菜单不存在或已删除。
+ ) : !activeConfig ? ( +
+ +
{emptyHint}
+
请先在“数据”页面新增扩展数据,并在“扩展分析”中创建分析菜单。
+
+ ) : ( + <> +
+ + + + +
+ + {missingConfiguredColumns.length > 0 && ( +
+ 菜单中有字段在当前数据中不存在:{missingConfiguredColumns.map(c => c.field).join('、')} +
+ )} + +
+ {menu?.template !== 'table' && menu?.template !== 'ranking' && ( +
+
+
+

分组榜单

+

按覆盖标的数量排序

+
+ Top {Math.min(filteredGroups.length, 100)} +
+
+
+ + { setSearch(e.target.value); setSelectedGroup(null) }} + placeholder={`搜索${activeKindLabel}`} + className="h-8 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/50" + /> +
+
+
+ {filteredGroups.length === 0 ? ( +
没有可展示的分组
+ ) : filteredGroups.slice(0, 100).map((group, i) => { + const active = currentGroup?.key === group.key + return ( + + ) + })} +
+
+ )} + +
+
+
+
+

{menu?.template === 'table' || menu?.template === 'ranking' ? '明细列表' : currentGroup?.key ?? `选择${activeKindLabel}`}

+ {tableRows.length} 条 +
+

列来自菜单 detail_columns:{displayColumns.map(f => f.label || f.field).join(' / ') || '暂无字段'}

+
+
+ + {rowsQuery.data?.date ?? '当前快照'} +
+
+ +
+ + + + {displayColumns.map(col => ( + + ))} + + + + {rowsQuery.isLoading ? ( + + ) : tableRows.length === 0 ? ( + + ) : tableRows.slice(0, 300).map((row, i) => ( + + {displayColumns.map(col => ( + + ))} + + ))} + +
{col.label || col.field}
加载数据中…
暂无明细数据
+ {formatValue(row[col.field], col)} +
+
+ + {tableRows.length > 300 && ( +
仅展示前 300 条明细,共 {tableRows.length} 条
+ )} +
+
+ + )} +
+ + ) +} diff --git a/serve/frontend/src/components/LastStockChip.tsx b/serve/frontend/src/components/LastStockChip.tsx new file mode 100644 index 0000000..dd29f58 --- /dev/null +++ b/serve/frontend/src/components/LastStockChip.tsx @@ -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 ( + + ) +} diff --git a/serve/frontend/src/components/Layout.tsx b/serve/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..e46dbbe --- /dev/null +++ b/serve/frontend/src/components/Layout.tsx @@ -0,0 +1,588 @@ +import { useEffect, useRef, useState } from 'react' +import { NavLink, Outlet, useNavigate } from 'react-router-dom' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { motion } from 'framer-motion' +import { useQuoteStream } from '@/lib/useQuoteStream' +import { ToastContainer } from '@/components/Toast' +import { AlertToastContainer } from '@/components/AlertToast' +import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost' +import { AiReportBubble } from '@/components/financials/AiReportBubble' +import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost' +import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble' +import { + useCapabilities, + useSettings, + usePreferences, + useQuoteStatus, + useVersion, +} from '@/lib/useSharedQueries' +import { + useToggleRealtimeQuotes, +} from '@/lib/useSharedMutations' +import { QK } from '@/lib/queryKeys' +import { tierRank } from '@/lib/capability-labels' +import { + Star, + ScanSearch, + History, + FileText, + Settings, + Key, + Database, + Loader2, + LayoutDashboard, + Tags, + TrendingUp, + Flame, + BarChart3, + Sparkles, + Layers3, + Landmark, + Cable, + RadioTower, + CheckCircle2, + BookOpenCheck, + ExternalLink, + X, +} from 'lucide-react' +import { Logo } from './Logo' +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: '上证指数' }, + { symbol: '399001.SZ', name: '深证成指' }, + { symbol: '399006.SZ', name: '创业板指' }, + { symbol: '000680.SH', name: '科创综指' }, +] as const + +type CoreIndex = (typeof CORE_INDEXES)[number] + +const nav = [ + { to: '/', label: '看板', icon: LayoutDashboard }, + { to: '/watchlist', label: '自选', icon: Star }, + { to: '/screener', label: '策略', icon: ScanSearch }, + { to: '/backtest', label: '回测', icon: History }, + { to: '/stock-analysis', label: '个股分析', icon: TrendingUp }, + { to: '/limit-ladder', label: '连板梯队', icon: Flame }, + { to: '/concept-analysis', label: '概念分析', icon: Layers3 }, + { to: '/industry-analysis', label: '行业分析', icon: Landmark }, + { to: '/financials', label: '财务分析', icon: FileText }, + { to: '/monitor', label: '监控中心', icon: RadioTower }, + { to: '/review', label: '复盘', icon: BookOpenCheck }, + { to: '/indices', label: '指数', icon: BarChart3 }, + { to: '/trading', label: '交易', icon: Cable }, + { to: '/data', label: '数据', icon: Database }, +] as const + +function fmtIndexValue(v: number | null | undefined) { + if (v == null || Number.isNaN(Number(v))) return '--' + return Number(v).toFixed(2) +} + +function fmtIndexPct(v: number | null | undefined) { + if (v == null || Number.isNaN(Number(v))) return '--' + return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%` +} + +function indexPctClass(v: number | null | undefined) { + if (v == null || Number.isNaN(Number(v))) return 'text-muted' + const n = Number(v) + if (n === 0) return 'text-foreground' + return n > 0 ? 'text-bull' : 'text-bear' +} + +/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */ +function MonitorBadge({ active }: { active: boolean }) { + const unread = useUnreadAlerts() + // 尊重用户设置: 可在菜单设置里关闭数字提示 + const badgeEnabled = (() => { + try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true } + })() + if (active || unread <= 0 || !badgeEnabled) return null + return ( + + {unread > 99 ? '99+' : unread} + + ) +} + +function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) { + if (items.length === 0) return null + const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q])) + return ( +
+ {items.map(item => { + const q = quoteBySymbol.get(item.symbol) + const value = q?.last_price ?? q?.close + const pct = q?.change_pct + return ( + +
+ {item.name} + {fmtIndexPct(pct)} +
+
+ {fmtIndexValue(value)} +
+
+ ) + })} +
+ ) +} + +// ===== 档位卡片 ===== +function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { + const base = label.split(' ')[0].split('+')[0].toLowerCase() + const isNone = base === 'none' + + const tierConfig: Record = { + none: { + desc: '未配置 Key · 仅历史日K', + tagBg: { background: 'rgba(113,113,122,0.15)' }, + dotStyle: { background: '#52525b' }, + labelTextStyle: { color: '#71717a' }, + }, + free: { + desc: '基础日K · 自选实时', + tagBg: { background: 'rgba(113,113,122,0.3)' }, + dotStyle: { background: '#71717a' }, + labelTextStyle: { color: '#a1a1aa' }, + }, + starter: { + desc: '批量同步 · 行情池', + tagBg: { background: 'rgba(59,130,246,0.2)' }, + dotStyle: { background: '#3b82f6' }, + labelTextStyle: { color: '#60a5fa' }, + }, + pro: { + desc: '分钟K · 实时行情 · 盘口', + tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' }, + dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, + labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, + }, + expert: { + desc: 'WebSocket · 财务数据', + tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' }, + dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' }, + labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, + }, + } + + const t = tierConfig[base] || tierConfig.none + // none 档显示英文「None」,无 label 时也显示「None」 + const displayLabel = isNone ? 'None' : (label || 'None') + + return ( + +
+
+
+
+ +
+
+
+ TickFlow + +
+
+ {isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc} +
+
+ + {displayLabel} + + +
+ +
+ + ) +} + +function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) { + return ( + +
+
+
+
+ +
+
+
+ AI 配置 + +
+
+ {configured ? (model || '已接入模型') : '接入策略生成模型'} +
+
+ +
+
+ + ) +} + +export function Layout() { + // ===== 共享 hooks (替代内联 useQuery) ===== + const { data: caps } = useCapabilities() + const { data: settingsState } = useSettings() + const { data: versionData } = useVersion() + const { data: prefs } = usePreferences() + // poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE) + const { data: quoteStatus } = useQuoteStatus({ poll: true }) + const { data: analysisMenus } = useQuery({ + queryKey: QK.analysisMenus, + queryFn: api.analysisMenus, + }) + + // 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈 + const { data: pipelineJobs } = useQuery({ + queryKey: QK.pipelineJobs, + queryFn: () => api.pipelineJobs(1), + refetchInterval: (query) => (query.state.data?.active_id ? 2000 : 15000), + refetchIntervalInBackground: true, + }) + const isDataSyncing = !!pipelineJobs?.active_id + + // 数据同步完成的"瞬时反馈": isDataSyncing 从 true→false 时显示绿色对勾, + // 闪烁约 3 秒后自动消失。 + const [dataSyncJustDone, setDataSyncJustDone] = useState(false) + const prevSyncingRef = useRef(false) + useEffect(() => { + // 仅在"刚结束"(true→false)且非首次挂载时触发 + if (prevSyncingRef.current && !isDataSyncing) { + setDataSyncJustDone(true) + const t = setTimeout(() => setDataSyncJustDone(false), 3000) + prevSyncingRef.current = isDataSyncing + return () => clearTimeout(t) + } + prevSyncingRef.current = isDataSyncing + }, [isDataSyncing]) + + const qc = useQueryClient() + const navigate = useNavigate() + const version = versionData?.version + const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false + // Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示) + const [dismissFreeHint, setDismissFreeHint] = useState(false) + const indicesPinned = prefs?.indices_nav_pinned ?? true + const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol) + const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol)) + // 卡片数据:固定显示时也拉取(即使实时行情关闭) + const showSidebarQuotes = indicesPinned || realtimeEnabled + const { data: sidebarIndexQuotes } = useQuery({ + queryKey: [...QK.indexQuotes, 'sidebar', sidebarIndexSymbols.join(',')] as const, + queryFn: () => api.indexQuotes(sidebarIndexes.map(p => p.symbol)), + enabled: showSidebarQuotes && sidebarIndexes.length > 0, + placeholderData: (prev) => prev, + }) + + // SSE: 行情更新时自动刷新相关 queries + 告警通知 + useQuoteStream(realtimeEnabled, prefs?.sse_refresh_pages) + + const toggleQuote = useToggleRealtimeQuotes() + const isRunning = quoteStatus?.running ?? false + const isTrading = quoteStatus?.is_trading_hours ?? false + const tier = tierRank(caps?.label ?? '') + const isNoneTier = tier < 0 + const isWatchlistMode = tier === 0 + const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场' + + // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒) + const alertsTotalQuery = useQuery({ + queryKey: ['alerts-total'], + queryFn: () => api.alertsList({ days: 7, limit: 1 }), + refetchInterval: 15000, + refetchIntervalInBackground: true, + select: (data) => data.total, + }) + // 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen) + const alertsTotal = alertsTotalQuery.data + useEffect(() => { + if (alertsTotal != null) setAlertTotal(alertsTotal) + }, [alertsTotal]) + + // 合并内置页面 + 可见的扩展分析菜单 + const 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 ?? [] + + const navItems = savedOrder.length > 0 + ? (() => { + const byTo = new Map(allNav.map(n => [n.to, n])) + const ordered = savedOrder + .map(id => byTo.get(id) ?? byTo.get(`/analysis/${id}`)) + .filter(Boolean) + const seen = new Set(ordered.map(n => n!.to)) + return [...ordered as typeof allNav, ...allNav.filter(n => !seen.has(n.to))] + })() + : allNav + + const hiddenIds = new Set(prefs?.nav_hidden ?? []) + const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, ''))) + + const handleToggle = async (enabled: boolean) => { + // 开启时重新校验档位 + if (enabled) { + const fresh = await qc.fetchQuery({ + queryKey: QK.capabilities, + queryFn: api.capabilities, + }) + const freshTier = tierRank(fresh.label ?? '') + if (freshTier < 0) return + if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) { + navigate('/watchlist') + return + } + } + await toggleQuote.mutateAsync(enabled) + // 仅在交易时段立即获取一次行情 + if (enabled && isTrading) { + api.intradayRefresh().catch(() => {}) + } + } + + return ( +
+ + + + + + + + + + + +
+ ) +} diff --git a/serve/frontend/src/components/ListColumnCustomizer.tsx b/serve/frontend/src/components/ListColumnCustomizer.tsx new file mode 100644 index 0000000..3580576 --- /dev/null +++ b/serve/frontend/src/components/ListColumnCustomizer.tsx @@ -0,0 +1,757 @@ +/** + * 通用列表列自定义组件。 + * + * 布局: + * - 上半区「已启用」:已启用的列,@dnd-kit 拖拽排序 + * - 下半区「内置列」:按业务分组折叠 + * - 底部「扩展数据列」:复用 ext_data schema,按需添加字段 + */ +import React, { useState, useCallback, useEffect, useMemo } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +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 { X, GripVertical, Plus, ChevronDown, ChevronRight, Database, Settings2, Search, Eye, EyeOff } from 'lucide-react' +import { api } from '@/lib/api' +import { useQuery } from '@tanstack/react-query' +import { QK } from '@/lib/queryKeys' +import type { ColumnConfig, ColumnGroup, ExtColumnDisplayConfig, CandleColumnConfig } from '@/lib/list-columns' +import { resolveCandleConfig } from '@/lib/list-columns' + +interface ListColumnCustomizerProps { + columns: ColumnConfig[] + groups: ColumnGroup[] + onChange: (columns: ColumnConfig[]) => void + open: boolean + onClose: () => void + title?: string + builtinSectionLabel?: string + extColumnAlign?: 'left' | 'center' | 'right' + extFieldFilter?: (field: { name: string; label: string; type: string }) => boolean + /** 是否显示扩展数据列区块(默认 true;信息条等无法渲染 ext 数据的场景设为 false)。 */ + showExtColumns?: boolean + /** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */ + showStandaloneToggle?: boolean +} + +function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: { + col: ColumnConfig + onRemove: (id: string) => void + onConfig: (id: string | null) => void + configOpen: boolean + extTableLabel: string + extConfig: React.ReactNode + candleConfig: React.ReactNode + strategiesConfig: React.ReactNode + showStandaloneToggle?: boolean + onToggleStandalone?: (id: string) => void +}) { + const { + attributes, listeners, setNodeRef, transform, transition, isDragging, + } = useSortable({ id: col.id }) + const isExt = col.source.type === 'ext' + const isCandle = col.source.type === 'builtin' && col.source.key === 'candle' + const isStrategies = col.source.type === 'builtin' && col.source.key === 'strategies' + const hasConfig = isExt || isCandle || isStrategies + + return ( + <> +
+ + + + + {isExt && col.source.type === 'ext' + ? `${col.label}(${extTableLabel})` + : col.label} + + {showStandaloneToggle && ( + + )} + {hasConfig && ( + + )} + +
+ {hasConfig && configOpen && (isExt ? extConfig : isCandle ? candlePanel : strategiesConfig)} + + ) +} + +export function ListColumnCustomizer({ + columns, + groups, + onChange, + open, + onClose, + title = '自定义列', + builtinSectionLabel = '内置列', + extColumnAlign = 'center', + extFieldFilter, + showExtColumns = true, + showStandaloneToggle = false, +}: ListColumnCustomizerProps) { + const extSchema = useQuery({ + queryKey: QK.extDataSchemaAll, + queryFn: api.extDataSchemaAll, + enabled: open && showExtColumns, + staleTime: 60_000, + }) + + const [searchQuery, setSearchQuery] = useState('') + const [expandedGroups, setExpandedGroups] = useState>(new Set()) + const [configOpenId, setConfigOpenId] = useState(null) + const [expandedTables, setExpandedTables] = useState>(new Set()) + + const colById = useMemo(() => new Map(columns.map(c => [c.id, c])), [columns]) + const keyToId = useMemo(() => { + const m = new Map() + for (const c of columns) { + if (c.source.type === 'builtin' || c.source.type === 'computed') m.set(c.source.key, c.id) + } + return m + }, [columns]) + + const activeCols = useMemo(() => columns.filter(c => !c.pinned && c.visible), [columns]) + + const toggleVisible = useCallback((colId: string) => { + onChange(columns.map(c => + c.id === colId && !c.pinned ? { ...c, visible: !c.visible } : c + )) + }, [columns, onChange]) + + const toggleStandalone = useCallback((colId: string) => { + onChange(columns.map(c => + c.id === colId ? { ...c, standalone: !c.standalone } : c + )) + }, [columns, onChange]) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const reorderCols = columns.filter(c => !c.pinned) + const pinnedCols = columns.filter(c => c.pinned) + const ids = reorderCols.map(c => c.id) + const oldIdx = ids.indexOf(active.id as string) + const newIdx = ids.indexOf(over.id as string) + if (oldIdx < 0 || newIdx < 0) return + const reordered = arrayMove(reorderCols, oldIdx, newIdx) + onChange([...pinnedCols, ...reordered]) + }, [columns, onChange]) + + const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string) => { + const colId = `ext:${configId}:${fieldName}` + if (columns.some(c => c.id === colId)) { + toggleVisible(colId) + return + } + const newCol: ColumnConfig = { + id: colId, + source: { type: 'ext', configId, fieldName, fieldLabel }, + label: fieldLabel || fieldName, + visible: true, + align: extColumnAlign, + } + const actionIdx = columns.findIndex(c => c.id === 'builtin:action') + if (actionIdx >= 0) { + const next = [...columns] + next.splice(actionIdx, 0, newCol) + onChange(next) + } else { + onChange([...columns, newCol]) + } + }, [columns, extColumnAlign, onChange, toggleVisible]) + + const hideColumn = useCallback((colId: string) => { + onChange(columns.map(c => c.id === colId ? { ...c, visible: false } : c)) + }, [columns, onChange]) + + const updateExtDisplay = useCallback((colId: string, patch: Partial) => { + onChange(columns.map(c => { + if (c.id !== colId) return c + return { ...c, extDisplay: { displayMode: 'tag', ...(c.extDisplay || {}), ...patch } } + })) + }, [columns, onChange]) + + const resetExtDisplay = useCallback((colId: string) => { + onChange(columns.map(c => { + if (c.id !== colId) return c + const { extDisplay, ...rest } = c + return rest + })) + }, [columns, onChange]) + + const updateCandleConfig = useCallback((colId: string, patch: Partial) => { + onChange(columns.map(c => { + if (c.id !== colId) return c + return { ...c, candleConfig: { ...c.candleConfig, ...patch } } + })) + }, [columns, onChange]) + + const resetCandleConfig = useCallback((colId: string) => { + onChange(columns.map(c => { + if (c.id !== colId) return c + const { candleConfig, ...rest } = c + return rest + })) + }, [columns, onChange]) + + const toggleGroup = useCallback((groupId: string) => { + setExpandedGroups(prev => { + const next = new Set(prev) + if (next.has(groupId)) next.delete(groupId) + else next.add(groupId) + return next + }) + }, []) + + const toggleTableExpand = useCallback((tableId: string) => { + setExpandedTables(prev => { + const next = new Set(prev) + if (next.has(tableId)) next.delete(tableId) + else next.add(tableId) + return next + }) + }, []) + + const extTables = extSchema.data?.items ?? [] + const extTableLabelMap = new Map(extTables.map(t => [t.id, t.label])) + + const query = searchQuery.trim().toLowerCase() + const filteredGroups = useMemo(() => { + if (!query) return groups + return groups.filter(g => + g.label.includes(query) || + g.keys.some(k => { + const id = keyToId.get(k) + return id && (colById.get(id)?.label ?? '').toLowerCase().includes(query) + }) + ) + }, [groups, query, keyToId, colById]) + + useEffect(() => { + if (!open) { setConfigOpenId(null); setSearchQuery('') } + }, [open]) + + useEffect(() => { + if (query) setExpandedGroups(new Set(filteredGroups.map(g => g.id))) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query]) + + const renderExtConfig = (col: ColumnConfig) => ( + +
+ + {(col.extDisplay?.displayMode ?? 'tag') === 'tag' && ( + + )} + + {(col.extDisplay?.displayMode ?? 'tag') === 'tag' && ( + + )} + {(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (col.extDisplay?.maxTags ?? 0) > 0 && ( +
+ 显示位置 +
+ {Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => { + const hidden = col.extDisplay?.hiddenIndices?.includes(i) + return ( + + ) + })} +
+
+ )} + {(col.extDisplay?.displayMode ?? 'tag') === 'tag' && ( + + )} + {col.extDisplay && ( +
+ +
+ )} +
+
+ ) + + // 策略列配置(精简版:仅显示数量/位置/排列方向,复用 extDisplay 存储) + const renderStrategiesConfig = (col: ColumnConfig) => ( + +
+ + {(col.extDisplay?.maxTags ?? 0) > 0 && ( +
+ 显示位置 +
+ {Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => { + const hidden = col.extDisplay?.hiddenIndices?.includes(i) + return ( + + ) + })} +
+
+ )} + + {col.extDisplay && ( +
+ +
+ )} +
+
+ ) + + const renderCandleConfig = (col: ColumnConfig) => { + const cfg = resolveCandleConfig(col.candleConfig) + // 数值输入:空字符串回退到默认值,便于清空重输 + const numInput = ( + field: keyof CandleColumnConfig, + label: string, + ) => ( + + ) + return ( + +
+ {numInput('days', '日k天数')} + {numInput('enabledWidth', '开启宽度')} + {numInput('enabledHeight', '开启高度')} + {numInput('disabledWidth', '收起宽度')} + {numInput('disabledHeight', '收起高度')} +
+ 宽度 40–300 / 高度 32–200 / 天数 1–60,越界自动钳制到边界 +
+ {col.candleConfig && ( +
+ +
+ )} +
+
+ ) + } + + const renderCheckbox = (checked: boolean) => ( + + + + + + ) + + const renderBuiltinRow = (col: ColumnConfig) => ( +
+ + {showStandaloneToggle && col.visible && ( + + )} +
+ ) + + const renderExtFieldRow = (configId: string, field: { name: string; label: string; type: string }) => { + const colId = `ext:${configId}:${field.name}` + const existing = columns.find(c => c.id === colId) + const checked = !!existing?.visible + return ( + + ) + } + + return ( + + {open && ( +
+ + +
+

{title}

+ +
+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="搜索列名..." + className="w-full h-8 pl-8 pr-3 rounded-lg bg-elevated border border-border text-xs text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 transition-colors" + /> +
+
+ +
+ {activeCols.length > 0 && ( +
+
+ + 已启用 + {activeCols.length} 列 · 拖拽排序 +
+ + c.id)} strategy={verticalListSortingStrategy}> + {activeCols.map(col => ( + + ))} + + +
+ )} + +
+ +
+
+ + {builtinSectionLabel} +
+ + {filteredGroups.map(group => { + const isExpanded = expandedGroups.has(group.id) + const groupCols = group.keys + .map(k => keyToId.get(k)) + .filter((id): id is string => !!id) + .map(id => colById.get(id)) + .filter((c): c is ColumnConfig => !!c) + if (groupCols.length === 0) return null + const visCount = groupCols.filter(c => c.visible).length + + return ( +
+ + + {isExpanded && ( + +
+ {groupCols.map(col => renderBuiltinRow(col))} +
+
+ )} +
+
+ ) + })} +
+ + {showExtColumns && extTables.length > 0 && ( +
+
+ + 扩展数据列 +
+
+ {extTables.map(table => { + const isExpanded = expandedTables.has(table.id) + const dataFields = table.columns + .filter(c => !['symbol', 'code', 'date'].includes(c.name)) + .filter(c => extFieldFilter ? extFieldFilter(c) : true) + const activeCount = dataFields.filter(f => { + const colId = `ext:${table.id}:${f.name}` + return columns.some(c => c.id === colId && c.visible) + }).length + return ( +
+ + + {isExpanded && ( + +
+ {dataFields.map(field => renderExtFieldRow(table.id, field))} +
+
+ )} +
+
+ ) + })} +
+
+ )} + + {showExtColumns && extTables.length === 0 && extSchema.isSuccess && ( +
+ 暂无扩展数据表,可在「数据」页面创建 +
+ )} +
+ +
+ )} + + ) +} diff --git a/serve/frontend/src/components/Logo.tsx b/serve/frontend/src/components/Logo.tsx new file mode 100644 index 0000000..cc4baff --- /dev/null +++ b/serve/frontend/src/components/Logo.tsx @@ -0,0 +1,59 @@ +// 原创 logo:方括号 [ ] 包裹一根带 wick 的 K 线 +// +// 概念: +// - 外层 brackets:终端 / 代码 / 引用边界 — 赛博 + quant 气质 +// - 中央 wick+body:一根标准 K 线 — 直接的金融指代 +// - body 偏上 + 下影长:bullish 站稳感 (上影短 / 下影长) +// +// 用 currentColor,继承父级 color 设定,方便切换品牌色。 +interface LogoProps { + className?: string + size?: number + style?: React.CSSProperties +} + +export function Logo({ className, size = 32, style }: LogoProps) { + return ( + + {/* 左方括号 */} + + {/* 右方括号 */} + + {/* K 线 wick(上下影线,半透明) */} + + {/* K 线 body — 偏上,上影短/下影长, bullish 站稳感 */} + + + ) +} diff --git a/serve/frontend/src/components/PageHeader.tsx b/serve/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..3b7b6ef --- /dev/null +++ b/serve/frontend/src/components/PageHeader.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/cn' + +interface Props { + title: string + subtitle?: string + /** 标题右侧、subtitle 之前的额外节点(如状态徽标) */ + titleExtra?: React.ReactNode + right?: React.ReactNode + className?: string +} + +export function PageHeader({ title, subtitle, titleExtra, right, className }: Props) { + return ( +
+
+

{title}

+ {titleExtra} + {subtitle && {subtitle}} +
+ {right} +
+ ) +} diff --git a/serve/frontend/src/components/RpsRotationDialog.tsx b/serve/frontend/src/components/RpsRotationDialog.tsx new file mode 100644 index 0000000..be669f8 --- /dev/null +++ b/serve/frontend/src/components/RpsRotationDialog.tsx @@ -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(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(null) + // AI 报告区滚动容器: 流式生成时自动滚到底部 + const analysisRef = useRef(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 ( + + + + ) + } + const [name, pct] = cell + const isSelected = selected === name + return ( + 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', + )} + > +
+ {name} + 0 ? 'text-bull' : pct < 0 ? 'text-bear' : 'text-muted', + )}>{fmtPct(pct)} +
+ + ) + }) + rows.push( + + + {displayIdx + 1} + + {cells} + , + ) + } + return rows + }, [visibleRange, getRowIndex, dates, columns, selected]) + + return ( + + { if (e.target === e.currentTarget) onClose() }} + > + + {/* 标题栏 */} +
+
+ + 概念涨幅轮动 + + {conceptCount > 0 ? `${dates.length} 天 · ${conceptCount} 个概念` : '暂无数据'} + +
+ +
+ + {/* 上半区: AI 轮动分析 */} +
+ {/* 标题栏: 标题 + meta 摘要 + focus 输入 + 触发按钮 */} +
+ + AI 轮动分析 + {analysisMeta?.summary && ( + {analysisMeta.summary} + )} +
+ 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" + /> + +
+
+ + {/* 报告内容区: 四态渲染 */} +
+ {analysisError ? ( +
+ + {analysisError} + +
+ ) : analysis || analyzing ? ( +
+ + {analyzing && ( + + )} +
+ ) : ( +
+ 点击「生成分析」,AI 将从主线研判 / 新晋强势 / 退潮预警 / 机构vs游资 等角度分析最近 {days} 天的概念轮动 +
+ )} +
+
+ + {/* 工具栏 */} +
+
+ 天数 + setDays(Number(e.target.value))} + className="w-24 accent-accent cursor-pointer" + /> + {days} +
+ + {selected && ( + + )} +
+ + {/* 下半区: 涨幅轮动矩阵(虚拟滚动) */} +
+ {isLoading ? ( +
+
+
+ ) : error ? ( +
+ 加载失败,请稍后重试 +
+ ) : rowCount === 0 ? ( +
+ 暂无概念数据,请先在「概念分析」页配置并获取概念数据源 +
+ ) : ( +
+ + {/* 表头: 日期列, 最新在最左 */} + + + + {dates.map(d => ( + + ))} + + {/* 选中概念追踪行: 在日期表头下方单独一行, 横向展示它在各日的排名+涨幅 */} + + {selected && selectedRow && ( + + + {selectedRow.map((cell, i) => ( + + ))} + + )} + + + + {/* 顶部占位: 把滚动位置撑起来 */} + {visibleRange.start > 0 && ( + + + )} + {renderRows} + {/* 底部占位 */} + {visibleRange.end < rowCount && ( + + + )} + +
+ # + + {shortDate(d)} +
+ + {selected} + + + {cell ? ( +
+ + #{cell.rank} + + 0 ? 'text-bull' : cell.pct < 0 ? 'text-bear' : 'text-muted', + )}> + {fmtPct(cell.pct)} + +
+ ) : ( + + )} +
+
+
+
+ )} +
+ + {/* 底部提示 */} +
+ + 每列各自按当日涨幅排序 · 点击单元格追踪概念在各日的排名变化 + +
+ + + + ) +} diff --git a/serve/frontend/src/components/SealedBadge.tsx b/serve/frontend/src/components/SealedBadge.tsx new file mode 100644 index 0000000..191ba6d --- /dev/null +++ b/serve/frontend/src/components/SealedBadge.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { motion, AnimatePresence } from 'framer-motion' +import { HelpCircle } from 'lucide-react' +import { api } from '@/lib/api' +import { toast } from '@/components/Toast' + +/** 单方向(涨停/跌停)的修正明细块 */ +function SealedDirBlock({ title, color, counts, rawTotal }: { + title: string + color: 'bull' | 'bear' + counts?: { real: number; fake: number; pending: number } + rawTotal?: number +}) { + const real = counts?.real ?? 0 + const fake = counts?.fake ?? 0 + // pending 从原始总数推算(后端 pending 含另一方向票, 不可用) + const pending = Math.max(0, (rawTotal ?? 0) - real - fake) + const original = rawTotal ?? (real + fake + pending) + const fixed = real + pending + return ( +
+
+ {title} + + {original} + + {fixed} + +
+
+ 真封 {real} + 假 {fake} + {pending > 0 && ( + 待 {pending} + )} +
+
+ ) +} + +/** 修正/降级 标识 + 问号弹窗(连板梯队/看板共用) */ +export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sealedCountsUp, sealedCountsDown, rawUp, rawDown, invalidateKeys = ['limit-ladder'] }: { + degraded: boolean + hasDepth: boolean + isHistorical: boolean + sealedReady: boolean | undefined + sealedCountsUp?: { real: number; fake: number; pending: number } + sealedCountsDown?: { real: number; fake: number; pending: number } + rawUp?: number + rawDown?: number + /** 修正后要刷新的 queryKey 前缀(默认连板梯队) */ + invalidateKeys?: string[] +}) { + const [showHint, setShowHint] = useState(false) + const navigate = useNavigate() + const qc = useQueryClient() + const runFix = useMutation({ + mutationFn: () => api.runLimitLadderFix(), + onSuccess: (data) => { + toast(data.msg, data.ok ? 'success' : 'error') + if (data.ok) invalidateKeys.forEach(k => qc.invalidateQueries({ queryKey: [k] })) + }, + onError: () => toast('修正请求失败', 'error'), + }) + + // 组装原因文案(仅降级时用) + const reasons: string[] = [] + if (!hasDepth) reasons.push('当前套餐无五档盘口能力(需 Pro+),涨停判定基于收盘价,可能含假涨停') + if (isHistorical) reasons.push('历史日期的盘口快照不可获取,无法判定真假板') + if (hasDepth && !isHistorical && !sealedReady) reasons.push('盘中 sealed 数据尚未就绪,收盘后自动恢复') + + const label = degraded ? '降级' : '修正' + + return ( +
+ + + {showHint && ( + <> +
setShowHint(false)} /> + e.stopPropagation()} + > + {degraded ? ( + <> +
真假涨停判定降级
+ {reasons.map((r, i) => ( +
+ · + {r} +
+ ))} +
+ 真假板判定依赖五档盘口实时快照(卖一/买一量)。Pro+ 套餐的当天数据在收盘后自动恢复。 +
+ + ) : ( + <> +
五档盘口修正结果
+ + +
+ 真封板显示封单量,假涨停/假跌停已归入炸板/翘板视图。{sealedReady && '数据为盘中快照,收盘后自动定版。'} +
+ + )} +
+ {hasDepth && !isHistorical && ( + + )} + +
+
+ + )} + +
+ ) +} diff --git a/serve/frontend/src/components/StockDailyKChart.tsx b/serve/frontend/src/components/StockDailyKChart.tsx new file mode 100644 index 0000000..5a57487 --- /dev/null +++ b/serve/frontend/src/components/StockDailyKChart.tsx @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { api, type KlineRow } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { + EChartsCandlestick, + OVERLAY_INDICATORS, + SUB_CHARTS, + type ChartMarker, + type ChartPriceLine, + type ChartRange, + type OHLC, + type StockInfo, +} from '@/components/EChartsCandlestick' + +const SUB_INFO_H = 16 +const SUB_GAP = 4 +const MAX_DAYS = 2000 + +export interface StockDailyKChartResult { + rows: OHLC[] + rawRows: KlineRow[] + stockInfo?: StockInfo + name?: string +} + +interface Props { + symbol: string + height?: number + className?: string + dateRange?: { start: string; end: string } + markers?: ChartMarker[] + ranges?: ChartRange[] + priceLines?: ChartPriceLine[] + showLimitMarkers?: boolean + showIndicatorControls?: boolean + showMarkerToggle?: boolean + showMA?: boolean + showInfoBar?: boolean + visibleBars?: number + linkedPrice?: number | null + onDateClick?: (date: string) => void + onDataChange?: (result: StockDailyKChartResult) => void + /** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */ + extColumns?: string +} + +function isValidRow(r: any): boolean { + return r && r.date != null && r.open != null && r.close != null +} + +export function toOHLC(rows: KlineRow[]): OHLC[] { + return rows + .filter(isValidRow) + .map(r => ({ + date: typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date), + open: Number(r.open), + high: Number(r.high), + low: Number(r.low), + close: Number(r.close), + volume: Number(r.volume ?? 0), + ma5: r.ma5 != null ? Number(r.ma5) : null, + ma10: r.ma10 != null ? Number(r.ma10) : null, + ma20: r.ma20 != null ? Number(r.ma20) : null, + ma60: r.ma60 != null ? Number(r.ma60) : null, + macd_dif: r.macd_dif != null ? Number(r.macd_dif) : null, + macd_dea: r.macd_dea != null ? Number(r.macd_dea) : null, + macd_hist: r.macd_hist != null ? Number(r.macd_hist) : null, + rsi_6: r.rsi_6 != null ? Number(r.rsi_6) : null, + rsi_14: r.rsi_14 != null ? Number(r.rsi_14) : null, + rsi_24: r.rsi_24 != null ? Number(r.rsi_24) : null, + kdj_k: r.kdj_k != null ? Number(r.kdj_k) : null, + kdj_d: r.kdj_d != null ? Number(r.kdj_d) : null, + kdj_j: r.kdj_j != null ? Number(r.kdj_j) : null, + boll_upper: r.boll_upper != null ? Number(r.boll_upper) : null, + boll_lower: r.boll_lower != null ? Number(r.boll_lower) : null, + })) +} + +function buildLimitUpMarkers(rows: KlineRow[]): ChartMarker[] { + const markers: ChartMarker[] = [] + for (const r of rows) { + const date = typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date) + if (r.signal_broken_limit_up) { + markers.push({ date, kind: 'neutral', above: true, color: '#8B5CF6', label: '炸' }) + } else if (r.signal_limit_up) { + const boards: number = r.consecutive_limit_ups ?? 1 + markers.push({ date, kind: 'buy', above: true, color: '#FACC15', label: boards <= 1 ? '板' : String(boards) }) + } + } + return markers +} + +export function getDefaultRange(): { start: string; end: string } { + const now = new Date() + const end = now.toISOString().slice(0, 10) + const s = new Date(now) + s.setMonth(s.getMonth() - 6) + const start = s.toISOString().slice(0, 10) + return { start, end } +} + +function rangeDays(range: { start: string; end: string }): number { + const start = new Date(range.start) + const end = new Date(range.end) + return Math.min(Math.ceil((end.getTime() - start.getTime()) / 86400000) + 30, MAX_DAYS) +} + +export function StockDailyKChart({ + symbol, + height = 520, + className, + dateRange: externalDateRange, + markers, + ranges, + priceLines, + showLimitMarkers = true, + showIndicatorControls = true, + showMarkerToggle = true, + showMA = true, + showInfoBar = true, + visibleBars = 60, + linkedPrice, + onDateClick, + onDataChange, + extColumns, +}: Props) { + const [activeIndicators, setActiveIndicators] = useState(['vol']) + const [showMarkers, setShowMarkers] = useState(true) + const dateRange = externalDateRange ?? getDefaultRange() + const days = useMemo(() => rangeDays(dateRange), [dateRange]) + + // extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数) + const kline = useQuery({ + queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns), + queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns), + enabled: !!symbol, + placeholderData: (prev) => prev, + }) + + const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows]) + const stockInfo = kline.data?.stock_info + const limitMarkers = useMemo(() => buildLimitUpMarkers(kline.data?.rows ?? []), [kline.data?.rows]) + const allMarkers = useMemo(() => [ + ...(markers ?? []), + ...(showLimitMarkers ? limitMarkers : []), + ], [limitMarkers, markers, showLimitMarkers]) + + const toggleIndicator = useCallback((key: string) => { + setActiveIndicators(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]) + }, []) + + const activeSubDefs = activeIndicators + .map(key => SUB_CHARTS.find(s => s.key === key)) + .filter((d): d is typeof SUB_CHARTS[number] => !!d) + let subExtraH = 0 + activeSubDefs.forEach(def => { subExtraH += SUB_INFO_H + def.height }) + if (activeSubDefs.length > 0) subExtraH += activeSubDefs.length * SUB_GAP + 14 + const chartHeight = height + subExtraH + + useEffect(() => { + onDataChange?.({ rows, rawRows: kline.data?.rows ?? [], stockInfo, name: kline.data?.name }) + }, [kline.data?.name, kline.data?.rows, onDataChange, rows, stockInfo]) + + if (!symbol) return null + + return ( +
+ {showIndicatorControls && rows.length > 0 && ( +
+ {SUB_CHARTS.map(ind => ( + + ))} + {OVERLAY_INDICATORS.map(ind => ( + + ))} + {showMarkerToggle && showLimitMarkers && ( + + )} +
+ )} + {kline.isLoading &&
加载中…
} + {kline.isError &&
日K加载失败
} + {!kline.isLoading && !kline.isError && (kline.data?.rows?.length ?? 0) > 0 && rows.length === 0 && ( +
数据格式异常,请刷新页面
+ )} + {rows.length > 0 && ( + + )} +
+ ) +} diff --git a/serve/frontend/src/components/StockInfoBar.tsx b/serve/frontend/src/components/StockInfoBar.tsx new file mode 100644 index 0000000..1063249 --- /dev/null +++ b/serve/frontend/src/components/StockInfoBar.tsx @@ -0,0 +1,278 @@ +import { useState, type ReactNode } from 'react' +import { Settings2, RadioTower, Star } from 'lucide-react' +import type { KlineRow, FinancialMetricRecord } from '@/lib/api' +import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format' +import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' +import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields' + +const BULL = '#C74040' +const BEAR = '#2D9B65' + +interface Props { + symbol: string + name?: string + stockInfo?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record } + rows: KlineRow[] + /** 信息条字段配置(由 StockPanel 提升,受控) */ + fields: ColumnConfig[] + onFieldsChange: (fields: ColumnConfig[]) => void + /** 财务指标最新一期(来自 useFinancialMetrics,受 Cap.FINANCIAL 门控) */ + financialMetrics?: FinancialMetricRecord + /** 加监控回调 (个股弹窗传入, 有值时渲染 RadioTower 图标) */ + onMonitor?: () => void + /** 加自选回调 + 是否已自选 (有 onToggle 时渲染 Star 图标) */ + inWatchlist?: boolean + onToggleWatchlist?: () => void +} + +/** + * 精简渲染扩展数据值(信息条专用)。 + * 仍尊重 extDisplay 配置:text=纯文本,tag(默认)=按分隔符拆成小标签 + maxTags 截断。 + * 与自选列表的差异:标签模式无 maxWidth/排列方向,但保留 +N 展开交互。 + */ +function renderExtInline( + val: unknown, + col: ColumnConfig, + expanded: boolean, + onToggle: () => void, +): ReactNode { + if (val == null || (typeof val === 'number' && Number.isNaN(val))) { + return + } + if (typeof val === 'number') { + const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val) + return {displayVal} + } + if (typeof val === 'boolean') { + return {val ? '是' : '否'} + } + const str = String(val) + // 纯文本模式 + if (col.extDisplay?.displayMode === 'text') { + return {str} + } + // 标签模式(默认):按分隔符拆成小标签 + const sep = col.extDisplay?.separator?.trim() || null + const tags = sep + ? str.split(sep).map(s => s.trim()).filter(Boolean) + : str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean) + if (tags.length === 0) return + // maxTags 截断 + 展开交互:收起时显示前 N 个 + +N,展开时显示全部 + 收起 + const maxTags = col.extDisplay?.maxTags ?? 0 + const hiddenIndices = maxTags > 0 ? col.extDisplay?.hiddenIndices : undefined + const showAll = maxTags <= 0 || expanded + const sliced = showAll ? tags : tags.slice(0, maxTags) + const shown = hiddenIndices?.length ? sliced.filter((_, i) => !hiddenIndices.includes(i)) : sliced + const overflow = tags.length - shown.length + return ( + + {shown.map((tag, i) => ( + + {tag} + + ))} + {!showAll && overflow > 0 && ( + + )} + {showAll && maxTags > 0 && tags.length > maxTags && ( + + )} + + ) +} + +export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics, onMonitor, inWatchlist, onToggleWatchlist }: Props) { + // 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前 + const [customizerOpen, setCustomizerOpen] = useState(false) + // ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰 + const [expandedExt, setExpandedExt] = useState>(new Set()) + + const toggleExtExpand = (key: string) => { + setExpandedExt(prev => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + if (rows.length === 0) return null + + const latest = rows[rows.length - 1] + const prev = rows.length >= 2 ? rows[rows.length - 2] : null + const close = Number(latest.close) + const chg = prev ? close - Number(prev.close) : 0 + const chgPct = prev ? chg / Number(prev.close) * 100 : 0 + const isUp = chg >= 0 + const clr = isUp ? BULL : BEAR + + const totalShares = stockInfo?.total_shares + const floatShares = stockInfo?.float_shares + const marketCap = totalShares ? close * totalShares : null + const floatMarketCap = floatShares ? close * floatShares : null + const turnoverRate = floatShares && latest.volume + ? (Number(latest.volume) * 100 / floatShares * 100) + : null + + const displayName = stockInfo?.name ?? name ?? '' + const extData = stockInfo?.ext ?? {} + + // 按指标 key 计算格式化值,无数据返回 null(渲染时跳过,与原行为一致)。 + // 普通函数:依赖行情值每次 render 都变,useCallback 无收益;且必须定义在早期 return 之后。 + const computeBuiltinValue = (key: string): string | null => { + switch (key) { + case 'market_cap': return marketCap != null ? fmtBigNum(marketCap) : null + case 'float_market_cap': return floatMarketCap != null ? fmtBigNum(floatMarketCap) : null + case 'turnover': return turnoverRate != null ? `${turnoverRate.toFixed(2)}%` : null + case 'volume': return latest.volume != null ? fmtVolume(Number(latest.volume)) : null + case 'amplitude': { + const prevClose = prev ? Number(prev.close) : null + if (prevClose == null || prevClose === 0) return null + const hi = Number(latest.high) + const lo = Number(latest.low) + return `${((hi - lo) / prevClose * 100).toFixed(2)}%` + } + case 'open': return fmtPrice(Number(latest.open)) + case 'high': return fmtPrice(Number(latest.high)) + case 'low': return fmtPrice(Number(latest.low)) + // 财务指标:百分比字段存储为百分点(12.3 表示 12.3%),直接 toFixed(2) + % + case 'eps': return financialMetrics?.eps_basic != null ? fmtPrice(financialMetrics.eps_basic) : null + case 'bps': return financialMetrics?.bps != null ? fmtPrice(financialMetrics.bps) : null + case 'roe': return financialMetrics?.roe != null ? `${financialMetrics.roe.toFixed(2)}%` : null + case 'gross_margin':return financialMetrics?.gross_margin != null ? `${financialMetrics.gross_margin.toFixed(2)}%` : null + case 'net_margin': return financialMetrics?.net_margin != null ? `${financialMetrics.net_margin.toFixed(2)}%` : null + case 'debt_ratio': return financialMetrics?.debt_to_asset_ratio != null ? `${financialMetrics.debt_to_asset_ratio.toFixed(2)}%` : null + case 'revenue_yoy': return financialMetrics?.revenue_yoy != null ? `${financialMetrics.revenue_yoy.toFixed(2)}%` : null + case 'net_income_yoy': return financialMetrics?.net_income_yoy != null ? `${financialMetrics.net_income_yoy.toFixed(2)}%` : null + // PE/PB 后端无此字段,用现价现算(PE 基于最新一期 EPS,非严格 TTM) + case 'pe_ttm': { + const eps = financialMetrics?.eps_basic + return eps && eps !== 0 ? fmtPrice(close / eps) : null + } + case 'pb': { + const bps = financialMetrics?.bps + return bps && bps !== 0 ? fmtPrice(close / bps) : null + } + default: return null + } + } + + const visibleFields = fields.filter(f => f.visible) + // 按是否单独显示分组:普通列共一行,standalone 列各占一行 + const inlineFields = visibleFields.filter(f => !f.standalone) + const standaloneFields = visibleFields.filter(f => f.standalone) + + // 渲染单个字段(builtin / ext 通用) + const renderField = (f: ColumnConfig): ReactNode => { + if (f.source.type === 'ext') { + const { configId, fieldName } = f.source + const val = extData[`${configId}__${fieldName}`] + // 无值的 ext 字段整体跳过(与 builtin 无数据行为一致) + if (val == null || (typeof val === 'number' && Number.isNaN(val))) return null + const cellKey = `${symbol}::${f.id}` + return ( + + {f.label} + + {renderExtInline(val, f, expandedExt.has(cellKey), () => toggleExtExpand(cellKey))} + + + ) + } + // builtin + const value = computeBuiltinValue(f.source.type === 'builtin' ? f.source.key : '') + if (value == null) return null + return ( + + {f.label} {value} + + ) + } + + return ( +
+ {/* Row 1: code, name, price, change, change% */} +
+ {symbol} + {displayName} + + {fmtPrice(close)} + + + {isUp ? '+' : ''}{fmtPrice(chg)} + + + {isUp ? '+' : ''}{fmtPrice(chgPct)}% + + {/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */} +
+ {onToggleWatchlist && ( + + )} + {onMonitor && ( + + )} + +
+
+ + {/* Row 2: 普通指标(builtin + ext,共一行 flex-wrap) */} + {inlineFields.length > 0 && ( +
+ {inlineFields.map(renderField)} +
+ )} + + {/* 单独显示的指标:各占一行 */} + {standaloneFields.map(f => { + const node = renderField(f) + if (node == null) return null + return ( +
+ {node} +
+ ) + })} + + setCustomizerOpen(false)} + title="信息条指标" + builtinSectionLabel="可选指标" + extColumnAlign="left" + showStandaloneToggle + /> +
+ ) +} diff --git a/serve/frontend/src/components/StockIntradayChart.tsx b/serve/frontend/src/components/StockIntradayChart.tsx new file mode 100644 index 0000000..e4fbb0a --- /dev/null +++ b/serve/frontend/src/components/StockIntradayChart.tsx @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { api, type MinuteKlineRow } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { EChartsIntraday } from '@/components/EChartsIntraday' + +interface Props { + symbol: string + date: string | null + height?: number + prevClose?: number + className?: string + onPriceHover?: (price: number | null) => void +} + +export function StockIntradayChart({ + symbol, + date, + height = 520, + prevClose, + className, + onPriceHover, +}: Props) { + const qc = useQueryClient() + const [minuteDismissed, setMinuteDismissed] = useState(false) + + const minute = useQuery({ + queryKey: QK.klineMinute(symbol, date ?? ''), + queryFn: () => api.klineMinute(symbol, date ?? undefined), + enabled: !!symbol && !!date, + }) + + const fetchMinute = useMutation({ + mutationFn: () => api.extendMinuteHistory(5, 'day'), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['kline-minute', symbol] }) + qc.invalidateQueries({ queryKey: QK.dataStatus }) + qc.invalidateQueries({ queryKey: QK.pipelineJobs }) + setMinuteDismissed(false) + }, + }) + + const minuteRows: MinuteKlineRow[] = useMemo(() => minute.data?.rows ?? [], [minute.data?.rows]) + // source=none 表示本地无数据且 TickFlow 也拉不到 (停牌/复牌延迟/非交易日) + // 此时不弹"是否获取"询问窗, 只做静态提示, 避免误导用户去拉明知拉不到的数据 + const sourceIsNone = minute.data?.source === 'none' + + useEffect(() => { + setMinuteDismissed(false) + onPriceHover?.(null) + }, [date, onPriceHover]) + + if (!symbol || !date) return null + + return ( +
+ {minute.isLoading &&
分时加载中…
} + {!minute.isLoading && minuteRows.length === 0 && ( + <> + {fetchMinute.isPending ? ( +
+ + 正在获取最近5日分钟K… +
+ ) : sourceIsNone ? ( + // 数据源确认无此日分钟数据 (停牌/复牌延迟等): 静态提示 + 保留重试 +
+
该日暂无分钟数据(数据源未提供)
+ +
+ ) : minuteDismissed ? ( +
+
暂无分钟数据
+ +
+ ) : ( +
+
是否立即获取最近5日分钟K?
+
+ + +
+
+ )} + + )} + {minuteRows.length > 0 && ( + + )} +
+ ) +} diff --git a/serve/frontend/src/components/StockPanel.tsx b/serve/frontend/src/components/StockPanel.tsx new file mode 100644 index 0000000..7385c0d --- /dev/null +++ b/serve/frontend/src/components/StockPanel.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState, useCallback, useRef, useMemo } from 'react' +import { type KlineRow, type FinancialMetricRecord } from '@/lib/api' +import { StockInfoBar } from '@/components/StockInfoBar' +import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart' +import { StockIntradayChart } from '@/components/StockIntradayChart' +import { useFinancialMetrics } from '@/lib/useFinancials' +import { useCapabilities } from '@/lib/useSharedQueries' +import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick' +import { + loadInfoFields, + saveInfoFields, + buildInfoExtColumnsParam, + type ColumnConfig, +} from '@/lib/stock-info-fields' + +interface Props { + symbol: string + height?: number + showIntraday?: boolean + className?: string + /** 当用户点击蜡烛选中日期时回调(用于外部自动开启分时图)。 */ + onSelectDate?: (date: string) => void + /** 外部传入的日期范围 */ + dateRange?: { start: string; end: string } + markers?: ChartMarker[] + ranges?: ChartRange[] + priceLines?: ChartPriceLine[] + showLimitMarkers?: boolean + showMarkerToggle?: boolean + /** 加监控回调 (传入后信息条显示 RadioTower 图标) */ + onMonitor?: () => void + /** 加自选 (传入后信息条显示 Star 图标) */ + inWatchlist?: boolean + onToggleWatchlist?: () => void +} + +export { getDefaultRange } + +export function StockPanel({ + symbol, + height = 520, + showIntraday = true, + className, + onSelectDate, + dateRange: externalDateRange, + markers, + ranges, + priceLines, + showLimitMarkers = true, + showMarkerToggle = true, + onMonitor, + inWatchlist, + onToggleWatchlist, +}: Props) { + const [linkedPrice, setLinkedPrice] = useState(null) + const [selectedDate, setSelectedDate] = useState(null) + const [dailyResult, setDailyResult] = useState(null) + // 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据 + const [fields, setFields] = useState(loadInfoFields) + const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields]) + + const handleFieldsChange = useCallback((next: ColumnConfig[]) => { + setFields(next) + saveInfoFields(next) + }, []) + + // 财务指标:仅当信息条配置含可见的财务字段且用户具备 FINANCIAL 能力 (Expert) 时才请求 + // 无能力时跳过请求, 避免后端抛 CapabilityDenied (403) 导致 free/starter 档弹错误提示 + const { data: caps } = useCapabilities() + const hasFinancialCap = !!caps?.capabilities?.['financial'] + const hasFinanceField = useMemo( + () => fields.some(f => f.visible && f.source.type === 'builtin' + && ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'].includes(f.source.key)), + [fields], + ) + const financials = useFinancialMetrics(hasFinanceField && hasFinancialCap ? symbol : undefined) + + const dateRange = externalDateRange ?? getDefaultRange() + + const handleDateClick = useCallback((date: string) => { + setSelectedDate(date) + onSelectDate?.(date) + }, [onSelectDate]) + + const rows = dailyResult?.rows ?? [] + const stockInfo = dailyResult?.stockInfo + const rawRows: KlineRow[] = dailyResult?.rawRows ?? [] + + // symbol 变化时重置分时相关状态,避免切股后残留旧日期。 + // 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存, + // 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据, + // 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。 + const prevSymbol = useRef(symbol) + useEffect(() => { + if (prevSymbol.current === symbol) return + prevSymbol.current = symbol + setSelectedDate(null) + setLinkedPrice(null) + setDailyResult(null) + }, [symbol]) + + // 当分时开启、无选中日期时,自动选中最新日期 + useEffect(() => { + if (showIntraday && !selectedDate && rows.length > 0) { + setSelectedDate(rows[rows.length - 1].date) + } + }, [showIntraday, selectedDate, rows]) + + const selectedIdx = selectedDate ? rows.findIndex(r => r.date === selectedDate) : -1 + const prevClose = selectedIdx > 0 + ? rows[selectedIdx - 1].close + : rows.length >= 2 + ? rows[rows.length - 2].close + : undefined + if (!symbol) return null + + // 财务指标最新一期(metrics 按 period_end 排序,取首项) + const financialMetrics: FinancialMetricRecord | undefined = financials.data?.data?.[0] + + return ( +
+ + +
+ + + {showIntraday && selectedDate && ( + + )} +
+
+ ) +} diff --git a/serve/frontend/src/components/StockPreviewDialog.tsx b/serve/frontend/src/components/StockPreviewDialog.tsx new file mode 100644 index 0000000..4c49108 --- /dev/null +++ b/serve/frontend/src/components/StockPreviewDialog.tsx @@ -0,0 +1,279 @@ +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { motion, AnimatePresence } from 'framer-motion' +import { X, RefreshCw, Clock } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { cnSignal } from '@/lib/signals' +import { StockPanel, getDefaultRange } from '@/components/StockPanel' +import { DatePicker } from '@/components/DatePicker' +import { RuleEditor } from '@/components/monitor/RuleEditor' + +interface Props { + symbol: string | null + name?: string + onClose: () => void + /** 触发信息 (来自监控触发记录, 有值时在顶栏下方显示) */ + triggerInfo?: { + price?: number | null + changePct?: number | null + ts?: number + signals?: string[] + message?: string + } | null +} + +// ===== 板块标识(与 Screener 列表一致)===== + +// 预设快捷范围(只保留半年和1年) +const PRESETS: { label: string; months: number }[] = [ + { label: '半年', months: 6 }, + { label: '1年', months: 12 }, +] + +function boardTag(symbol: string): { label: string; color: string } | null { + if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' } + if (/^688/.test(symbol)) return { label: '科', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' } + if (/^[48]/.test(symbol)) return { label: '北', color: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' } + return null +} + +export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) { + const [showIntraday, setShowIntraday] = useState(false) + const [dateRange, setDateRange] = useState(getDefaultRange) + const [showMonitorEditor, setShowMonitorEditor] = useState(false) + const qc = useQueryClient() + + const watchlist = useQuery({ + queryKey: QK.watchlist, + queryFn: api.watchlistList, + enabled: !!symbol, + }) + const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol) + + const toggleWatchlist = useMutation({ + mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.watchlist }) + qc.invalidateQueries({ queryKey: QK.watchlistEnriched() }) + }, + }) + + // ESC 关闭 + useEffect(() => { + if (!symbol) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [symbol, onClose]) + + const handleRefresh = () => { + if (!symbol) return + qc.invalidateQueries({ queryKey: ['kline', symbol!] }) + if (showIntraday) { + qc.invalidateQueries({ queryKey: ['kline-minute', symbol!] }) + } + } + + return ( + + {symbol && ( +
+ {/* 遮罩 */} + + + {/* 弹窗主体 */} + + {/* 顶栏 */} +
+
+ {(() => { + const board = symbol ? boardTag(symbol) : null + return board ? ( + + {board.label} + + ) : null + })()} + {symbol} + {name && {name}} +
+ +
+ {/* 日期范围快捷 */} + {PRESETS.map(p => { + const now = new Date() + const s = new Date(now) + s.setMonth(s.getMonth() - p.months) + const expected = s.toISOString().slice(0, 10) + const isActive = dateRange.start === expected + return ( + + ) + })} + setDateRange(prev => ({ ...prev, start: v }))} + max={dateRange.end} + /> + ~ + setDateRange(prev => ({ ...prev, end: v }))} + min={dateRange.start} + /> + + | + + {/* 分时开关 */} + + + | + + {/* 刷新 */} + + + {/* 关闭 */} + +
+
+ + {/* 触发信息条 (来自监控触发记录) */} + {triggerInfo && ( +
+ {/* 左: 触发标记 + 时间 */} +
+ ⚡ 触发 + {triggerInfo.ts && ( + + {new Date(triggerInfo.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} + + )} +
+ + {/* 中: 价格 + 涨跌幅 */} +
+ {triggerInfo.price != null && ( + {triggerInfo.price.toFixed(2)} + )} + {triggerInfo.changePct != null && ( + = 0 ? 'text-danger' : 'text-bear'}`}> + {triggerInfo.changePct >= 0 ? '+' : ''}{(triggerInfo.changePct * 100).toFixed(2)}% + + )} +
+ + {/* 右: 消息 + 信号标签 */} +
+ {triggerInfo.message && ( + {triggerInfo.message} + )} + {triggerInfo.signals && triggerInfo.signals.length > 0 && ( +
+ {triggerInfo.signals.map((s, j) => ( + {cnSignal(s)} + ))} +
+ )} +
+
+ )} + + {/* K 线内容 */} +
+ { if (!showIntraday) setShowIntraday(true) }} + dateRange={dateRange} + onMonitor={() => setShowMonitorEditor(true)} + inWatchlist={inWatchlist} + onToggleWatchlist={() => toggleWatchlist.mutate()} + /> +
+ + {/* 加监控编辑器弹层 */} + + {showMonitorEditor && symbol && ( + setShowMonitorEditor(false)} + > +
e.stopPropagation()}> + setShowMonitorEditor(false)} + onSaved={() => setShowMonitorEditor(false)} + /> +
+
+ )} +
+
+
+ )} +
+ ) +} diff --git a/serve/frontend/src/components/Toast.tsx b/serve/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..389514d --- /dev/null +++ b/serve/frontend/src/components/Toast.tsx @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from 'react' + +// ===== 全局 toast 状态 ===== +type ToastItem = { id: number; msg: string; kind: 'error' | 'success' } +let _id = 0 +const _listeners: Set<(items: ToastItem[]) => void> = new Set() +let _queue: ToastItem[] = [] + +function _emit() { _listeners.forEach(fn => fn([..._queue])) } + +function toast(msg: string, kind: 'error' | 'success' = 'error') { + const item = { id: ++_id, msg, kind } + _queue = [..._queue, item] + _emit() + setTimeout(() => { _queue = _queue.filter(t => t.id !== item.id); _emit() }, 4000) +} + +export { toast } + +// ===== Toast 容器 — 挂在 Layout 最顶层 ===== +export function ToastContainer() { + const [items, setItems] = useState([]) + + const sub = useCallback(() => { + _listeners.add(setItems) + return () => { _listeners.delete(setItems) } + }, []) + + useEffect(sub, [sub]) + + if (!items.length) return null + + return ( +
+ {items.map(t => ( +
+ {t.msg} +
+ ))} +
+ ) +} diff --git a/serve/frontend/src/components/WarmupBadge.tsx b/serve/frontend/src/components/WarmupBadge.tsx new file mode 100644 index 0000000..2f36d84 --- /dev/null +++ b/serve/frontend/src/components/WarmupBadge.tsx @@ -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(null) + const [pos, setPos] = useState({ 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 ( + <> + + + {createPortal( + + {open && ( + <> + {/* 全屏透明遮罩: 点击关闭 */} +
setOpen(false)} + /> + {/* 气泡: 绝对定位到 body, 绕开 overflow 裁剪 */} + e.stopPropagation()} + > +
为什么开头几个月可能没有交易?
+

+ 技术指标 (MA / MACD / RSI 等) 需要历史数据才能算出。系统会自动在回测起点之前多取约 + 120 天 (≈4 个月) 数据做预热。 +

+

+ 若本地数据恰好从回测起点才开始, 开头几个月指标算不出、信号不触发, + 属正常现象, 不是 bug。等数据攒够后自然开始产生交易。 +

+
+ 解决: 把历史数据补到回测起点之前至少半年, 或把起点往后挪。 +
+ {/* 小箭头指向锚点 */} +
+ + + )} + , + document.body, + )} + + ) +} diff --git a/serve/frontend/src/components/analysis-shared.tsx b/serve/frontend/src/components/analysis-shared.tsx new file mode 100644 index 0000000..6680f2b --- /dev/null +++ b/serve/frontend/src/components/analysis-shared.tsx @@ -0,0 +1,501 @@ +/** + * 概念/行业分析 — 共享 UI 组件 + * + * 包含: + * - AnalysisConfigDialog: 字段配置弹窗 + * - DimensionHeatmap: 维度热力图块 + * - OverviewStatCards: 总览统计卡片 + * - DimensionGroupSidebar: 维度分组侧边栏 + */ + +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, + TrendingUp, + X, +} from 'lucide-react' +import { api, type ExtDataField } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { cn } from '@/lib/cn' +import type { DimensionGroup, QuoteMap } from '@/lib/analysis-adapter' +import { computeQuoteMetrics } from '@/lib/analysis-adapter' +import { fmtPct, priceColorClass } from '@/lib/format' + +// ===== 配置类型 ===== + +export interface AnalysisFieldConfig { + /** 选中的扩展数据源 ID */ + configId?: string + /** 手动指定的维度字段(覆盖自动探测) */ + dimensionField?: string + /** 层级字段统计级别(如行业 1/2/3 级) */ + hierarchyLevel?: 1 | 2 | 3 +} + +export interface SchemaOption { + id: string + label: string + columns: { name: string; type: string; label: string }[] +} + +// ===== 配置弹窗 ===== + +export function AnalysisConfigDialog({ + currentConfig, + onSave, + onClose, + showHierarchyLevel = false, +}: { + currentConfig: AnalysisFieldConfig + onSave: (config: AnalysisFieldConfig) => void + onClose: () => void + showHierarchyLevel?: boolean +}) { + const [draft, setDraft] = useState(currentConfig) + const { data: extList } = useQuery({ + queryKey: QK.extData, + queryFn: api.extDataList, + }) + const { data: schemaData } = useQuery({ + queryKey: QK.extDataSchemaAll, + queryFn: api.extDataSchemaAll, + }) + + const configs = extList?.items ?? [] + + const fieldsForConfig = useMemo((): ExtDataField[] => { + if (!draft.configId || !schemaData?.items) return [] + const schema = schemaData.items.find(s => s.id === draft.configId) + return schema?.columns.map(c => ({ name: c.name, dtype: c.type, label: c.label })) ?? [] + }, [draft.configId, schemaData]) + + const nonMetaFields = fieldsForConfig.filter( + f => !['symbol', 'code', 'date', 'name'].includes(f.name), + ) + + return ( +
+ e.stopPropagation()} + > +
+ 配置数据源 + +
+ +
+ {/* 数据源选择 */} +
+ 扩展数据源 + +
+ + {/* 维度字段选择 */} + {nonMetaFields.length > 0 && ( +
+ 维度字段(留空自动探测) + +
+ )} + + {showHierarchyLevel && ( +
+ 统计层级 + +
+ )} + +
+ 系统自动检测数据结构:支持"个股→概念/行业"和"概念/行业→成分股列表"两种模式。 + {showHierarchyLevel ? ' 行业字段按 “-” 拆分为 1/2/3 级。' : ''} +
+
+ +
+ + +
+
+
+ ) +} + +// ===== 总览统计卡片 ===== + +export function OverviewStatCards({ + groups, + totalStocks, + configLabel, + dateStr, + accentColor, +}: { + groups: DimensionGroup[] + totalStocks: number + configLabel: string + dateStr?: string | null + accentColor: string +}) { + const cards = [ + { label: '数据源', value: configLabel, hint: dateStr ?? '快照', icon: Database }, + { label: '维度数量', value: groups.length, hint: '按维度聚合分组', icon: Tags }, + { label: '覆盖标的', value: totalStocks, hint: '去重股票数', icon: BarChart3 }, + { + label: '最大分组', + value: groups[0]?.key ?? '—', + hint: groups[0] ? `${groups[0].count} 只` : '', + icon: TrendingUp, + }, + ] + + return ( +
+ {cards.map(card => ( +
+
+ {card.label} + +
+
+ {card.value} +
+
{card.hint}
+
+ ))} +
+ ) +} + +// ===== 维度热力图 ===== + +export function DimensionHeatmap({ + groups, + quoteMap, + selectedKey, + onSelect, + colorScheme, +}: { + groups: DimensionGroup[] + quoteMap: Map + selectedKey: string | null + onSelect: (key: string | null) => void + colorScheme: 'blue' | 'amber' +}) { + const [showAll, setShowAll] = useState(false) + // 收起时最大高度(约 3 行标签高度) + const collapsedMaxH = '8rem' + + const enriched = useMemo(() => { + return groups.map(g => { + const qm = computeQuoteMetrics(g.stocks, quoteMap) + return { ...g, quoteMetrics: qm } + }) + }, [groups, quoteMap]) + + // 根据涨跌比渲染颜色强度 + const colors = colorScheme === 'blue' + ? { up: [59, 130, 246], down: [96, 165, 250], bg: [30, 64, 175] } + : { up: [245, 158, 11], down: [251, 191, 36], bg: [180, 83, 9] } + + return ( +
+
+ 热度分布(按标的覆盖数) + +
+
+ {enriched.map(g => { + const qm = g.quoteMetrics + const total = qm.upCount + qm.downCount + qm.flatCount + const upRatio = total > 0 ? qm.upCount / total : 0.5 + const size = Math.max(0.6, Math.min(1.4, g.count / (enriched[0]?.count || 1))) + const active = selectedKey === g.key + const [r, gr, b] = colors.up + + return ( + + ) + })} +
+
+ ) +} + +// ===== 维度分组侧边栏 ===== + +export function DimensionGroupSidebar({ + groups, + quoteMap, + selectedKey, + onSelect, + searchValue, + onSearchChange, + kindLabel, + colorScheme, +}: { + groups: DimensionGroup[] + quoteMap: Map + selectedKey: string | null + onSelect: (key: string) => void + searchValue: string + onSearchChange: (v: string) => void + kindLabel: string + colorScheme: 'blue' | 'amber' +}) { + const q = searchValue.trim().toLowerCase() + const filtered = q + ? groups.filter(g => g.key.toLowerCase().includes(q)) + : groups + + const accentColor = colorScheme === 'blue' ? 'rgba(59,130,246,0.7)' : 'rgba(245,158,11,0.7)' + const accentBg = colorScheme === 'blue' ? 'rgba(59,130,246,0.1)' : 'rgba(245,158,11,0.1)' + const accentBorder = colorScheme === 'blue' ? 'rgba(59,130,246,0.25)' : 'rgba(245,158,11,0.25)' + + return ( +
+
+

{kindLabel}榜单

+

按覆盖标的数量排序

+
+
+
+ + onSearchChange(e.target.value)} + placeholder={`搜索${kindLabel}`} + className="h-8 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/50" + /> +
+
+
+ {filtered.length === 0 ? ( +
没有可展示的分组
+ ) : filtered.slice(0, 200).map((group, i) => { + const active = selectedKey === group.key + const qm = computeQuoteMetrics(group.stocks, quoteMap) + return ( + + ) + })} +
+
+ ) +} + +// ===== 行情摘要条 ===== + +export function QuoteSummaryBar({ + groups, + quoteMap, +}: { + groups: DimensionGroup[] + quoteMap: Map +}) { + const stats = useMemo(() => { + let upTotal = 0, downTotal = 0, flatTotal = 0 + for (const g of groups) { + const qm = computeQuoteMetrics(g.stocks, quoteMap) + upTotal += qm.upCount + downTotal += qm.downCount + flatTotal += qm.flatCount + } + return { upTotal, downTotal, flatTotal } + }, [groups, quoteMap]) + + const total = stats.upTotal + stats.downTotal + stats.flatTotal + if (total === 0) return null + + const upPct = (stats.upTotal / total) * 100 + const downPct = (stats.downTotal / total) * 100 + + return ( +
+
+
+
+
+
+ {stats.upTotal}涨 + / + {stats.downTotal}跌 +
+ ) +} + +// ===== 配置按钮 ===== + +export function ConfigButton({ onClick }: { onClick: () => void }) { + return ( + + ) +} + +/** + * 内置预设 (概念/行业) 数据获取空状态。 + * + * 当检测到内置预设存在但无数据时, 展示图标 + 提示 + 「获取数据」按钮, + * 让用户手动触发拉取 (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 ( +
+
+ +

{title}

+

{hint}

+ + {errMsg && ( +

+ {errMsg} +

+ )} +
+
+ ) +} diff --git a/serve/frontend/src/components/data/ActiveJobCard.tsx b/serve/frontend/src/components/data/ActiveJobCard.tsx new file mode 100644 index 0000000..69cd447 --- /dev/null +++ b/serve/frontend/src/components/data/ActiveJobCard.tsx @@ -0,0 +1,135 @@ +import { useRef, useMemo, useEffect } from 'react' +import { motion } from 'framer-motion' +import { Loader2, CheckCircle2, XCircle } from 'lucide-react' +import { formatDuration, formatLogTime } from '@/lib/format' +import { Pill } from './StatCard' +import type { PipelineJob } from '@/lib/api' + +export const STAGE_LABELS: Record = { + init: '初始化', + resolve_universe: '解析标的池', + sync_instruments: '同步个股维表', + sync_daily: '同步日 K', + sync_adj: '同步除权因子', + compute_enriched: '计算技术指标', + sync_minute: '同步分钟 K', + extend_history: '扩展日K历史', + extend_minute: '扩展分钟K历史', + rebuild_enriched: '全量计算', + refresh_views: '刷新视图', + done: '完成', +} + +function LogViewer({ log }: { log: PipelineJob['log'] }) { + const containerRef = useRef(null) + + const displayLog = useMemo(() => { + if (!log.length) return [] + return log.filter((line, i) => { + const next = log[i + 1] + if ( + next && + line.stage === next.stage && + /\d+\/\d+/.test(line.msg) && + /\d+\/\d+/.test(next.msg) + ) { + return false + } + return true + }) + }, [log]) + + useEffect(() => { + const el = containerRef.current + if (el) el.scrollTop = el.scrollHeight + }, [displayLog]) + + return ( +
+ {displayLog.map((line, i) => ( +
+ {formatLogTime(line.ts)} + [{STAGE_LABELS[line.stage] ?? line.stage}] + {line.msg} +
+ ))} + {displayLog.length === 0 &&
等待启动…
} +
+ ) +} + +export function ActiveJobCard({ job }: { job: PipelineJob }) { + const statusMap = { + running: { icon: Loader2, color: 'text-accent', label: '运行中', spinning: true, border: 'border-accent/40', bg: 'bg-accent/5' }, + pending: { icon: Loader2, color: 'text-muted', label: '排队中', spinning: true, border: 'border-border', bg: 'bg-surface' }, + succeeded: { icon: CheckCircle2, color: 'text-bear', label: '完成', spinning: false, border: 'border-bear/30', bg: 'bg-bear/5' }, + failed: { icon: XCircle, color: 'text-danger', label: '失败', spinning: false, border: 'border-danger/40', bg: 'bg-danger/5' }, + } as const + const meta = statusMap[job.status] + const Icon = meta.icon + const isDone = job.status === 'succeeded' || job.status === 'failed' + const stageLabel = isDone ? meta.label : (STAGE_LABELS[job.stage] ?? job.stage) + + return ( +
+
+
+ +
+
+ {meta.label}{!isDone && ` · ${stageLabel}`} +
+
+ {job.id} · {job.duration_s != null ? formatDuration(job.duration_s) : '进行中'} +
+
+
+ {!isDone && ( +
+ {job.progress}% +
+ )} +
+ + {!isDone && ( +
+
+ +
+ {job.stage_pct > 0 && ( +
+ 当前阶段 + {job.stage_pct}% +
+ )} +
+ )} + + + + {job.status === 'succeeded' && job.result && (() => { + const skipped = new Set(job.result.skipped_stages ?? []) + const cell = (stage: string | null, v: string) => + stage && skipped.has(stage) ? '跳过' : v + return ( +
+ + + + + +
+ ) + })()} + {job.status === 'failed' && job.error && ( +
+ {job.error} +
+ )} +
+ ) +} diff --git a/serve/frontend/src/components/data/DepthConfigCard.tsx b/serve/frontend/src/components/data/DepthConfigCard.tsx new file mode 100644 index 0000000..a058515 --- /dev/null +++ b/serve/frontend/src/components/data/DepthConfigCard.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { usePreferences, useCapabilities } from '@/lib/useSharedQueries' +import { isExpertOrAbove } from '@/lib/capability-labels' + +/** + * 五档盘口 sealed(真假涨停) 配置内容(纯内容, 无外框, 由父级 Card 包裹)。 + * + * - 轮询间隔: Pro 10~120s / Expert 3~300s + * - 盘后定版时间: 15:01~18:00, 默认 15:02 + * - disabled 时(监控关闭)输入框禁用 + */ +// 注: 文件名保留 DepthConfigCard.tsx, 导出 DepthConfigContent(纯内容无外框) +export function DepthConfigContent({ disabled }: { disabled?: boolean }) { + const qc = useQueryClient() + const prefs = usePreferences() + const caps = useCapabilities() + + const hasDepth = !!caps.data?.capabilities?.['depth5.batch'] + const tierLabel = caps.data?.label ?? '' + const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 } + + const interval = prefs.data?.depth_polling_interval ?? 20 + const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 } + + const [intervalInput, setIntervalInput] = useState(String(Math.round(interval))) + const [finalizeHour, setFinalizeHour] = useState(String(finalizeTime.hour)) + const [finalizeMinute, setFinalizeMinute] = useState(String(finalizeTime.minute)) + + useEffect(() => { setIntervalInput(String(Math.round(interval))) }, [interval]) + useEffect(() => { + setFinalizeHour(String(finalizeTime.hour)) + setFinalizeMinute(String(finalizeTime.minute)) + }, [finalizeTime.hour, finalizeTime.minute]) + + const saveInterval = useMutation({ + mutationFn: (v: number) => api.updateDepthPollingInterval(v), + onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), + }) + const saveFinalize = useMutation({ + mutationFn: ({ hour, minute }: { hour: number; minute: number }) => + api.updateDepthFinalizeTime(hour, minute), + onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), + }) + + // 无能力: 显示升级提示 + if (!hasDepth) { + return ( +

+ 真假涨停判定依赖五档盘口实时快照,需 Pro 及以上套餐。 + 升级后连板梯队将自动区分真封板(显示封单量)与假涨停(归入炸板)。 +

+ ) + } + + const inputCls = `w-16 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}` + + return ( +
+ {/* 盘中轮询间隔 */} +
+
+
盘中轮询间隔
+
范围 {range.lo}~{range.hi} 秒 · 涨跌停过多时系统自动放慢
+
+
+ setIntervalInput(e.target.value)} + onBlur={() => { + if (disabled) return + let v = Number(intervalInput) + if (!Number.isFinite(v)) v = range.lo + v = Math.max(range.lo, Math.min(range.hi, v)) + saveInterval.mutate(v) + }} + className={inputCls} + /> + +
+
+ + {/* 盘后定版时间 */} +
+
+
盘后定版时间
+
范围 15:01~18:00 · 收盘后拉取最终盘口定版
+
+
+ setFinalizeHour(e.target.value)} + onBlur={() => { + if (disabled) return + let h = Number(finalizeHour) + if (!Number.isFinite(h)) h = 15 + h = Math.max(15, Math.min(18, h)) + let m = Number(finalizeMinute) + if (!Number.isFinite(m)) m = 2 + m = Math.max(0, Math.min(59, m)) + if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 } + if (h * 60 + m > 18 * 60) { h = 18; m = 0 } + saveFinalize.mutate({ hour: h, minute: m }) + }} + className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`} + /> + : + setFinalizeMinute(e.target.value)} + onBlur={() => { + if (disabled) return + let h = Number(finalizeHour) + if (!Number.isFinite(h)) h = 15 + h = Math.max(15, Math.min(18, h)) + let m = Number(finalizeMinute) + if (!Number.isFinite(m)) m = 2 + m = Math.max(0, Math.min(59, m)) + if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 } + if (h * 60 + m > 18 * 60) { h = 18; m = 0 } + saveFinalize.mutate({ hour: h, minute: m }) + }} + className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`} + /> +
+
+
+ ) +} diff --git a/serve/frontend/src/components/data/EnrichedRebuildPanel.tsx b/serve/frontend/src/components/data/EnrichedRebuildPanel.tsx new file mode 100644 index 0000000..b50cfdc --- /dev/null +++ b/serve/frontend/src/components/data/EnrichedRebuildPanel.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { usePreferences } from '@/lib/useSharedQueries' + +export function EnrichedRebuildPanel({ isRunning, onStart }: { isRunning: boolean; onStart: () => void }) { + const qc = useQueryClient() + const prefs = usePreferences() + const batchSize = prefs.data?.enriched_batch_size ?? 1000 + const [editing, setEditing] = useState(false) + const [draftSize, setDraftSize] = useState(String(batchSize)) + const [hint, setHint] = useState(null) + + function clampAndSave(raw: number) { + if (isNaN(raw) || 1 > raw) { setHint('已自动设为最小值 1'); saveBatch.mutate(1); return } + if (raw > 10000) { setHint('已自动设为上限 10000'); saveBatch.mutate(10000); return } + setHint(null); saveBatch.mutate(raw) + } + + const saveBatch = useMutation({ + mutationFn: (size: number) => api.updateEnrichedBatchSize(size), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.preferences }) + setEditing(false) + }, + }) + const rebuild = useMutation({ + mutationFn: api.rebuildEnriched, + onSuccess: () => { + onStart() + qc.invalidateQueries({ queryKey: QK.pipelineJobs }) + }, + }) + + return ( +
+
+
+
+
批次大小
+
每批计算的标的数量,影响内存占用与进度粒度
+
+ {editing ? ( +
+ setDraftSize(e.target.value)} + className="w-20 px-2 py-1 text-xs font-mono rounded-btn border border-border bg-surface text-foreground text-right tabular-nums focus:outline-none focus:border-accent" + min={1} + max={10000} + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') clampAndSave(parseInt(draftSize)) + if (e.key === 'Escape') { setEditing(false); setHint(null) } + }} + /> + + +
+ ) : ( + + )} +
+
+ + 每批内存占用 = 批次大小 × 日K历史天数。批次越大或日K历史越长,内存占用越高,可能导致程序崩溃。内存不足时请适当降低此值。 + +
+ {hint && ( +
+ {hint} +
+ )} +
+ +
+
基于已有 kline_daily + adj_factor 全量计算前复权 + 技术指标 + 信号
+ +
+
+ ) +} diff --git a/serve/frontend/src/components/data/ExtendHistoryPanel.tsx b/serve/frontend/src/components/data/ExtendHistoryPanel.tsx new file mode 100644 index 0000000..eee7cc3 --- /dev/null +++ b/serve/frontend/src/components/data/ExtendHistoryPanel.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' + +export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: { + caps: { label: string; capabilities: Record } | undefined + isRunning: boolean + earliestDate: string | null + onStart: () => void +}) { + const qc = useQueryClient() + const [value, setValue] = useState(6) + const [unit, setUnit] = useState<'month' | 'year'>('month') + const hasBatchCap = !!caps?.capabilities?.['kline.daily.batch'] + + const extend = useMutation({ + mutationFn: () => api.extendHistory(value, unit), + onSuccess: () => { + onStart() + qc.invalidateQueries({ queryKey: QK.pipelineJobs }) + }, + }) + + const offsetDays = unit === 'month' ? value * 30 : value * 365 + const estimate = earliestDate + ? (() => { + const d = new Date(earliestDate) + d.setDate(d.getDate() - offsetDays) + return d.toISOString().slice(0, 10) + })() + : null + + return ( +
+
向前扩展历史数据
+ +
+
+ +
+ {value} +
+ +
+ +
+ {(['month', 'year'] as const).map(u => ( + + ))} +
+
+ + {estimate && ( +
+ 预计扩展至 {estimate} + {earliestDate && (当前最早: {earliestDate})} +
+ )} + + + + {!hasBatchCap && ( + + 需 Pro+ 权限 + + )} +
+ ) +} diff --git a/serve/frontend/src/components/data/MinuteSyncConfig.tsx b/serve/frontend/src/components/data/MinuteSyncConfig.tsx new file mode 100644 index 0000000..2a96b1e --- /dev/null +++ b/serve/frontend/src/components/data/MinuteSyncConfig.tsx @@ -0,0 +1,219 @@ +import { useState, useEffect } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { isExpertOrAbove } from '@/lib/capability-labels' + +export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label: string; capabilities: Record } | undefined; isRunning: boolean; onStart: () => void }) { + const qc = useQueryClient() + const prefs = useQuery({ + queryKey: QK.preferences, + queryFn: api.preferences, + }) + const update = useMutation({ + mutationFn: ({ enabled, days }: { enabled: boolean; days: number }) => + api.updateMinuteSync(enabled, days), + onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), + }) + + const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch'] + const enabled = prefs.data?.minute_sync_enabled ?? false + const days = prefs.data?.minute_sync_days ?? 5 + const [localDays, setLocalDays] = useState(days) + + useEffect(() => { setLocalDays(days) }, [days]) + + const handleToggle = () => { + if (!hasMinuteCap) return + update.mutate({ enabled: !enabled, days: localDays }) + } + + return ( +
+
+
+ + + {enabled ? '自动同步' : '已关闭'} + +
+ {!hasMinuteCap && ( + + 需 Pro+ + + )} +
+ +
+ 同步天数 +
+
+ +
+ {localDays} +
+ +
+ +
+
+ +
+
向前扩展历史数据
+ +
+ +
+ A股标的 · 原始数据存储(查询时实时复权) +
+
+ ) +} + +function MinuteExtendControls({ hasMinuteCap, tierLabel, isRunning, onStart }: { hasMinuteCap: boolean; tierLabel: string; isRunning: boolean; onStart: () => void }) { + const qc = useQueryClient() + // 月单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用"天"(1~15 天) + const canUseMonth = isExpertOrAbove(tierLabel) + const [unit, setUnit] = useState<'day' | 'month'>('day') + const [value, setValue] = useState(5) + const [confirmOpen, setConfirmOpen] = useState(false) + + const dataStatus = useQuery({ + queryKey: QK.dataStatus, + queryFn: api.dataStatus, + }) + // 判断本地是否已有分钟K数据:后端 _safe_aggregate_minute 为避免全表扫描, + // rows 恒为 0,改用 trading_days(分区目录数,真实统计)判断。 + const hasMinuteData = !!(dataStatus.data?.minute?.trading_days) + + const extend = useMutation({ + mutationFn: () => api.extendMinuteHistory(value, unit), + onSuccess: () => { + onStart() + qc.invalidateQueries({ queryKey: QK.pipelineJobs }) + qc.invalidateQueries({ queryKey: QK.dataStatus }) + }, + }) + + // 各单位上限:day 15 天,month 6 月(180 天) + const maxValue = unit === 'month' ? 6 : 15 + + const handleFetch = () => { + if (!hasMinuteData) { + setConfirmOpen(true) + } else { + extend.mutate() + } + } + + // 切换单位时把 value clamp 到新单位的上限 + const switchUnit = (u: 'day' | 'month') => { + if (u === unit) return + setUnit(u) + const max = u === 'month' ? 6 : 15 + setValue(v => Math.min(v, max)) + } + + return ( + <> +
+
+ +
+ {value} +
+ +
+ + {canUseMonth ? ( +
+ {(['day', 'month'] as const).map(u => ( + + ))} +
+ ) : ( + + )} +
+ + + + {confirmOpen && ( +
+
setConfirmOpen(false)} /> +
+
本地暂无分钟K数据,是否立即获取最近 {value} {unit === 'month' ? '月' : '天'}的分钟K?
+
+ + +
+
+
+ )} + + ) +} diff --git a/serve/frontend/src/components/data/PageSettingsModal.tsx b/serve/frontend/src/components/data/PageSettingsModal.tsx new file mode 100644 index 0000000..86abaae --- /dev/null +++ b/serve/frontend/src/components/data/PageSettingsModal.tsx @@ -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> = { + adj_factor: 'adj_factor', + minute: 'kline.minute.batch', + financials: 'financial', +} + +/** + * 读取卡片显隐状态。结合档位能力决定默认值: + * - 用户显式设置过 → 用设置值 + * - 未设置 + defaultHidden → 隐藏(无条件默认隐藏) + * - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏 + * - 其他 → 显示 + */ +export function getCardVisibility( + caps: Record | undefined, +): Record { + const has = (capKey: string) => !capKey || !!caps?.[capKey] + const override = storage.dataCardVisible.get({}) + const result: Record = {} + 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(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 | undefined +}) { + const [visible, setVisible] = useState>(() => getCardVisibility(caps)) + const [order, setOrder] = useState(() => getCardOrder()) + + const persistVisible = (next: Record) => { + 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 = {} + 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 ( +
+

+ 拖动手柄调整卡片顺序,勾选控制显隐。未勾选的卡片将隐藏,不影响数据本身。 +

+ + +
+ {orderedDefs.map((def) => { + const on = visible[def.key] ?? true + return ( + toggle(def.key)} + /> + ) + })} +
+
+
+
+ +
+
+ ) +} + +// ── 可拖拽的卡片行 ── +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 ( +
+ {/* 拖拽手柄 */} + + {/* 显隐勾选 */} + +
+
{label}
+
{desc}
+
+
+ ) +} diff --git a/serve/frontend/src/components/data/PipelineScopeConfig.tsx b/serve/frontend/src/components/data/PipelineScopeConfig.tsx new file mode 100644 index 0000000..fa54297 --- /dev/null +++ b/serve/frontend/src/components/data/PipelineScopeConfig.tsx @@ -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>) => 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 ( +
+

+ 勾选盘后管道每次自动拉取的数据类型。仅影响后续同步,已存储的历史数据不受影响。 +

+
+ {ITEMS.map((item) => { + const locked = item.key === 'pipeline_pull_a_share' + const on = locked || getValue(item.key, item.defaultOn) + return ( +
+ +
+ ) + })} +
+ {updateToggle.isPending && ( +
+ 保存中… +
+ )} +
+ 数据通道基于免费接口,所有档位均可拉取。 +
+
+ ) +} diff --git a/serve/frontend/src/components/data/QuoteConfigCard.tsx b/serve/frontend/src/components/data/QuoteConfigCard.tsx new file mode 100644 index 0000000..02484b7 --- /dev/null +++ b/serve/frontend/src/components/data/QuoteConfigCard.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { Activity, Settings } from 'lucide-react' +import { Skeleton } from './Skeleton' + +export function QuoteConfigCard({ enabled, running, isTrading, lastFetchMs, intervalS, intervalMin, intervalMax, loading, onToggle, toggling, showIntervalEdit, onShowIntervalEdit, onIntervalChange }: { + enabled: boolean + running: boolean + isTrading: boolean + lastFetchMs: number | null + intervalS: number + intervalMin: number + intervalMax: number + loading: boolean + onToggle: (enabled: boolean) => void + toggling: boolean + showIntervalEdit: boolean + onShowIntervalEdit: () => void + onIntervalChange: (v: number) => void +}) { + const statusColor = running && isTrading + ? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.5)]' + : enabled && running + ? 'bg-warning/60' + : 'bg-muted' + + const statusText = !enabled + ? '已关闭' + : !isTrading + ? '非交易时段' + : running + ? '行情运行中' + : '已停止' + + const lastFetchTime = lastFetchMs + ? new Date(lastFetchMs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : null + + return ( +
+
+
+ +

实时行情

+
+ +
+ + {loading ? ( +
+
+
+
+
+
+ ) : ( +
+
+ 状态 +
+ + {statusText} +
+
+
+ 交易时段 + {isTrading ? '交易中' : '休市'} +
+
+
+ 轮询间隔 + +
+ {intervalS}s +
+
+ 最后获取 + {lastFetchTime ?? '—'} +
+
+ )} + + + {showIntervalEdit && ( + + + + )} + +
+ ) +} + +function IntervalEditor({ min, max, value, onChange }: { + min: number; max: number; value: number; onChange: (v: number) => void +}) { + const [draft, setDraft] = useState(value) + const clamped = Math.max(min, Math.min(max, draft)) + const step = min < 1 ? 0.1 : min < 3 ? 0.5 : 1 + const presets = min <= 3 ? [3, 5, 10, 30, 60] : [5, 10, 15, 30, 60] + + return ( +
+
+ 轮询间隔 ({min}s ~ {max}s) +
+
+ {presets.map(p => ( + + ))} +
+
+ { const v = parseFloat(e.target.value); setDraft(v); onChange(v) }} + className="flex-1 h-1 accent-accent cursor-pointer" + /> + + {clamped < 1 ? clamped.toFixed(1) : clamped.toFixed(0)}s + +
+
+ ) +} diff --git a/serve/frontend/src/components/data/ScheduleEditor.tsx b/serve/frontend/src/components/data/ScheduleEditor.tsx new file mode 100644 index 0000000..e8c2171 --- /dev/null +++ b/serve/frontend/src/components/data/ScheduleEditor.tsx @@ -0,0 +1,43 @@ +import { useState, useEffect } from 'react' + +export function ScheduleEditor({ value, onSave, loading, hint }: { + value: { hour: number; minute: number } + onSave: (hour: number, minute: number) => void + loading: boolean + hint?: string +}) { + const [h, setH] = useState(value.hour) + const [m, setM] = useState(value.minute) + + useEffect(() => { setH(value.hour); setM(value.minute) }, [value.hour, value.minute]) + + const handleSave = () => { + if (h === value.hour && m === value.minute) return + onSave(h, m) + } + + return ( +
+ 每日 + setH(Math.max(0, Math.min(23, Number(e.target.value))))} + className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center" + /> + : + setM(Math.max(0, Math.min(59, Number(e.target.value))))} + className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center" + /> + + 工作日自动执行{hint ? ` · ${hint}` : ''} +
+ ) +} diff --git a/serve/frontend/src/components/data/SchemaModal.tsx b/serve/frontend/src/components/data/SchemaModal.tsx new file mode 100644 index 0000000..8f9d05e --- /dev/null +++ b/serve/frontend/src/components/data/SchemaModal.tsx @@ -0,0 +1,105 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { useQuery } from '@tanstack/react-query' +import { api, type EnrichedField } from '@/lib/api' +import { QK } from '@/lib/queryKeys' + +const TABLE_TITLES: Record = { + instruments: '个股维表', + daily: '日 K', + adj_factor: '除权因子', + enriched: 'Enriched', + minute: '分钟 K', + index_instruments: '指数维表', + index_daily: '指数日 K', + index_enriched: '指数 Enriched', + etf_instruments: 'ETF 维表', + etf_daily: 'ETF 日 K', + etf_enriched: 'ETF Enriched', +} + +function categorize(name: string): string { + if (['symbol', 'date'].includes(name)) return '基础' + if (['open', 'high', 'low', 'close', 'volume', 'amount'].includes(name)) return '行情' + if (name.startsWith('raw_') || name.startsWith('ex_') || name === 'close_pre_adj') return '复权' + if (name.startsWith('ma')) return '均线 MA' + if (name.startsWith('ema')) return '指数均线 EMA' + if (name.startsWith('macd')) return 'MACD' + if (name.startsWith('boll')) return '布林带' + if (name.startsWith('kdj')) return 'KDJ' + if (name.startsWith('rsi')) return 'RSI' + if (name.startsWith('signal_') || name.startsWith('consecutive_')) return '信号' + if (['atr_14', 'vol_ratio_5d', 'vol_ma5', 'vol_ma10', 'momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d', 'annual_vol_20d', 'change_pct', 'change_amount', 'amplitude', 'turnover_rate'].includes(name)) return '波动/动量' + if (['high_60d', 'low_60d'].includes(name)) return '极值' + return '其他' +} + +export function EnrichedSchemaModal({ table, onClose }: { table: string | null; onClose: () => void }) { + const open = !!table + const schema = useQuery({ + queryKey: QK.tableSchema(table!), + queryFn: () => api.enrichedSchema(table!), + enabled: open, + staleTime: Infinity, + }) + + const fields = schema.data ?? [] + + const groups: Record = {} + for (const f of fields) { + const cat = categorize(f.name) + if (!groups[cat]) groups[cat] = [] + groups[cat].push(f) + } + + const title = table ? (TABLE_TITLES[table] ?? table) : '' + + return ( + + {open && ( + +
+ +
+

{title} 字段说明

+ {fields.length} 个字段 +
+
+ {schema.isLoading ? ( +
加载中…
+ ) : ( +
+ {Object.entries(groups).map(([cat, items]) => ( +
+
{cat}
+
+ {items.map((f) => ( +
+ {f.name} + {f.desc} + {f.type} +
+ ))} +
+
+ ))} +
+ )} +
+
+ + )} + + ) +} diff --git a/serve/frontend/src/components/data/SectionTitle.tsx b/serve/frontend/src/components/data/SectionTitle.tsx new file mode 100644 index 0000000..3756bcb --- /dev/null +++ b/serve/frontend/src/components/data/SectionTitle.tsx @@ -0,0 +1,61 @@ +import { CheckCircle2, XCircle, Loader2, AlertCircle } from 'lucide-react' +import { formatDuration } from '@/lib/format' + +export function SectionTitle({ icon: Icon, children }: { icon: React.ComponentType<{ className?: string }>; children: React.ReactNode }) { + return ( +

+ + {children} +

+ ) +} + +export function HistoryRow({ job, onClick }: { job: any; onClick: () => void }) { + const statusIcon = { + succeeded: { icon: CheckCircle2, color: 'text-bear' }, + failed: { icon: XCircle, color: 'text-danger' }, + running: { icon: Loader2, color: 'text-accent', spinning: true }, + pending: { icon: Loader2, color: 'text-muted', spinning: true }, + }[job.status as 'succeeded'] ?? { icon: AlertCircle, color: 'text-muted' } + const Icon = statusIcon.icon + + return ( + + ) +} diff --git a/serve/frontend/src/components/data/SettingsModal.tsx b/serve/frontend/src/components/data/SettingsModal.tsx new file mode 100644 index 0000000..5054ef0 --- /dev/null +++ b/serve/frontend/src/components/data/SettingsModal.tsx @@ -0,0 +1,27 @@ +import { motion } from 'framer-motion' +import { X } from 'lucide-react' + +export function SettingsModal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + return ( +
+
+ +
+

{title}

+ +
+
+ {children} +
+
+
+ ) +} diff --git a/serve/frontend/src/components/data/Skeleton.tsx b/serve/frontend/src/components/data/Skeleton.tsx new file mode 100644 index 0000000..8afc405 --- /dev/null +++ b/serve/frontend/src/components/data/Skeleton.tsx @@ -0,0 +1,7 @@ +export function Skeleton({ w = 'w-full', h = 'h-3.5', rounded = 'rounded-sm', className = '' }: { + w?: string; h?: string; rounded?: string; className?: string +}) { + return ( +
+ ) +} diff --git a/serve/frontend/src/components/data/StatCard.tsx b/serve/frontend/src/components/data/StatCard.tsx new file mode 100644 index 0000000..3e51498 --- /dev/null +++ b/serve/frontend/src/components/data/StatCard.tsx @@ -0,0 +1,318 @@ +import { motion } from 'framer-motion' +import { Loader2, CheckCircle2, Settings, Table2 } from 'lucide-react' +import { formatNumber } from '@/lib/format' +import { fmtDate } from '@/lib/format' +import { Skeleton } from './Skeleton' + +// 卡片能力定义:capKey → 查 capability limits;tierReq → 无权限时显示的档位要求 +// capKey 为空串表示该数据在 free-api 服务器(None 档/Free 档)即可获取,无需付费能力门控。 +export const CARD_META: Record = { + // 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力 + instruments: { capKey: '', tierReq: '' }, + daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' }, + adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' }, + enriched: { capKey: '', tierReq: '' }, + // ETF 复用日K批量能力(免费档 kline.daily.batch 即可),不显示档位徽章 + etf: { capKey: 'kline.daily.batch', tierReq: '' }, + minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' }, + financials: { capKey: 'financial', tierReq: 'Expert' }, +} + +export function Pill({ label, value }: { label: string; value: number | string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix }: { + hasCap: boolean + isLocal: boolean + tierLabel?: string + tierReq?: string + capInfo?: { rpm: number | null; batch: number | null; subscribe: number | null } | undefined + localSuffix?: string +}) { + if (isLocal) { + return ( + + 本地计算{localSuffix ? ` · ${localSuffix}` : ''} + + ) + } + + if (hasCap && capInfo && tierLabel) { + const parts = [tierLabel, `${capInfo.rpm}/min`] + if (capInfo.batch != null && capInfo.batch > 1) parts.push(`${capInfo.batch}股/批`) + return ( + + {parts.join(' · ')} + + ) + } + + if (!hasCap && tierReq && tierReq !== 'Free') { + // 缺权限且非 Free 档(付费档位才提示升级);Free 档人人可用, + // 若显示"需 Free"会造成 Expert 等用户困惑(通常是探测瞬时失败丢能力) + return ( + + 需 {tierReq} + + ) + } + + if (hasCap) { + return ( + + {tierLabel ?? '已授权'} + + ) + } + + return null +} + +export type FieldTab = { label: string; table: string } + +export function StatCard({ + title, hint, stats, isInstrument = false, loading = false, + active = false, done = false, skipped = false, stagePct = 0, + tierKey, capLimits, tierLabel, + auto, onSettings, onShowFields, settingsOpen, subLabel, localBadgeSuffix, fieldTabs, +}: { + title: string + hint: string + stats: any | null | undefined + isInstrument?: boolean + loading?: boolean + active?: boolean + done?: boolean + skipped?: boolean + stagePct?: number + tierKey?: string + capLimits?: Record + tierLabel?: string + onSettings?: () => void + onShowFields?: (table?: string) => void + settingsOpen?: boolean + auto?: boolean + subLabel?: string + localBadgeSuffix?: string + // 多表字段入口: [{label: '维表', table: 'index_instruments'}, ...] + // 提供时渲染多个图标按钮(每个对应一张表的字段说明); 否则回退到单个 onShowFields + fieldTabs?: FieldTab[] +}) { + const empty = loading || !stats || (stats.rows === 0 && !stats.trading_days && !stats.fields) + const borderCls = active + ? 'border-accent/50' + : done + ? 'border-bear/30' + : 'border-border' + const bgCls = active ? 'bg-accent/[0.03]' : 'bg-surface' + + const meta = tierKey ? CARD_META[tierKey] : undefined + const isLocal = meta?.capKey === '' + const capInfo = meta?.capKey ? capLimits?.[meta.capKey] : undefined + const hasCap = isLocal || !!capInfo + + // 渲染字段说明入口图标 + // - fieldTabs 提供时: 返回 null (图标由 renderSubLabelInline 内联到文字后) + // - 否则: 单个图标按钮 (onShowFields) + const renderFieldButtons = () => { + if (fieldTabs && fieldTabs.length > 0) return null + if (onShowFields) { + return ( + + ) + } + return null + } + + // 单个图标按钮 (复用样式) + const fieldIconButton = (tab: FieldTab) => ( + + ) + + // subLabel 文本内容 (不含图标) + const subLabelText = subLabel + ?? (isInstrument + ? `标的 · ${((stats?.named ?? stats?.rows) ?? 0).toLocaleString()} 个含名称` + : stats?.fields + ? '字段 · 复权 · 技术指标' + : title === '日 K' && stats?.trading_days + ? '日 · A股标的 · 日线' + : stats?.trading_days && !stats?.rows + ? '日 · A股标的 · 分钟级' + : (() => { + const parts = [`行 · ${(stats?.symbols_covered ?? 0)} 只标的`] + if (stats?.trading_days) parts.push(`· ${stats.trading_days} 日`) + return parts.join(' ') + })()) + + // 有 fieldTabs 时: 把 subLabel 按分隔符拆开, 每个匹配词后面内联图标 + // 例如 "日 · 维表 · 日K · 指标" → 日 · 维表[icon] · 日K[icon] · 指标[icon] + const renderSubLabelInline = () => { + if (!fieldTabs || fieldTabs.length === 0) { + return <>{subLabelText}{renderFieldButtons()} + } + const labels = fieldTabs.map(t => t.label) + // 按非字母数字汉字的分隔符拆分, 保留分隔符 + const tokens = subLabelText.split(/(\s*·\s*|\s+)/).filter(t => t !== '') + const used = new Set() + return ( + <> + {tokens.map((tok, i) => { + const trimmed = tok.trim() + // 跳过纯分隔符 + if (trimmed === '' || trimmed === '·') return {tok} + // 匹配某个 tab label (整体匹配, 避免部分子串误命中) + const idx = labels.indexOf(trimmed) + if (idx >= 0 && !used.has(trimmed)) { + used.add(trimmed) + return {tok}{fieldIconButton(fieldTabs[idx])} + } + return {tok} + })} + + ) + } + + return ( +
+
+

{title}

+
+ {auto !== undefined && !loading && ( + + + {auto ? '自动' : '关闭'} + + )} + {active && } + {done && !active && !skipped && } + {skipped && !active && ( + + 本次跳过 + + )} + {onSettings && ( + + )} +
+
+ +
{hint}
+ +
+ {loading ? ( + + ) : ( + + )} +
+ +
+ {loading ? ( + <> + + + + ) : empty ? ( + <> +
+
+ 暂无数据{renderFieldButtons()} +
+ + ) : ( + <> +
+ {stats.fields + ? stats.fields + : stats.trading_days && !stats.rows + ? stats.trading_days.toLocaleString() + : formatNumber(stats.rows)} +
+
+ {renderSubLabelInline()} +
+ + )} +
+ +
+ {loading ? ( + <> +
+
+ + ) : empty ? ( + <> +
+ {isInstrument ? '快照日' : '起'} + +
+
+ {isInstrument ? '标的数' : '止'} + +
+ + ) : ( + <> +
+ {isInstrument ? '快照日' : '起'} + {fmtDate(isInstrument ? stats.latest_as_of : stats.earliest_date)} +
+
+ {isInstrument ? '标的数' : '止'} + {isInstrument ? String(stats.rows) : fmtDate(stats.latest_date)} +
+ + )} +
+ + {active && stagePct > 0 && ( +
+ +
+ )} +
+ ) +} diff --git a/serve/frontend/src/components/ext-data/CreateExtDialog.tsx b/serve/frontend/src/components/ext-data/CreateExtDialog.tsx new file mode 100644 index 0000000..2c7c995 --- /dev/null +++ b/serve/frontend/src/components/ext-data/CreateExtDialog.tsx @@ -0,0 +1,383 @@ +import { useRef, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { motion } from 'framer-motion' +import { X, Loader2, Upload, Plus, AlertCircle, Tag, Clock } from 'lucide-react' +import { api, type ExtDataField } from '@/lib/api' +import { QK } from '@/lib/queryKeys' + +export function CreateExtDialog({ onClose }: { onClose: () => void }) { + const qc = useQueryClient() + const [id, setId] = useState('') + const [label, setLabel] = useState('') + const [description, setDescription] = useState('') + const [mode, setMode] = useState<'snapshot' | 'timeseries'>('snapshot') + const [fields, setFields] = useState([]) + const [error, setError] = useState('') + const detectFileRef = useRef(null) + const [detecting, setDetecting] = useState(false) + const [dragOver, setDragOver] = useState(false) + const [symbolMap, setSymbolMap] = useState>({}) + const [codeMap, setCodeMap] = useState>({}) + const [matchStatus, setMatchStatus] = useState<'none' | 'partial' | 'full'>('none') + const [selectMapping, setSelectMapping] = useState<{ fields: { name: string; dtype: string; label: string }[]; need: 'symbol' | 'code' | 'both' } | null>(null) + + const create = useMutation({ + mutationFn: () => { + const userF = fields.filter((f) => f.name.trim() && f.name !== 'symbol' && f.name !== 'code') + return api.extDataCreate({ + id, + label, + mode, + fields: [ + { name: 'symbol', dtype: 'string', label: '标的代码' }, + { name: 'code', dtype: 'string', label: '代码' }, + ...userF, + ], + description: description.trim() || undefined, + symbol_map: symbolMap, + code_map: codeMap, + }) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.extData }) + onClose() + }, + onError: (err) => setError(String(err)), + }) + + const addField = () => + setFields([...fields, { name: '', dtype: 'string', label: '' }]) + + const removeField = (i: number) => + setFields(fields.filter((_, idx) => idx !== i)) + + const updateField = (i: number, key: keyof ExtDataField, val: string) => + setFields(fields.map((f, idx) => (idx === i ? { ...f, [key]: val } : f))) + + const valid = id.trim() && label.trim() && fields.some((f) => f.name.trim()) && matchStatus !== 'none' + + const processDetection = ( + detected: { name: string; dtype: string; label: string }[], + symCands: string[], + codeCands: string[], + ) => { + let sm: Record = {} + let cm: Record = {} + let status: 'none' | 'partial' | 'full' = 'none' + + if (symCands.length === 1 && codeCands.length === 1) { + sm = { type: 'mapped', col: symCands[0] } + cm = { type: 'mapped', col: codeCands[0] } + status = 'full' + } else if (symCands.length === 1 && codeCands.length === 0) { + sm = { type: 'mapped', col: symCands[0] } + cm = { type: 'computed', from: 'symbol', method: 'strip_exchange' } + status = 'full' + } else if (symCands.length === 0 && codeCands.length === 1) { + cm = { type: 'mapped', col: codeCands[0] } + sm = { type: 'computed', from: 'code', method: 'append_exchange' } + status = 'full' + } else if (symCands.length > 1 || codeCands.length > 1) { + setSelectMapping({ + fields: detected, + need: symCands.length > 0 && codeCands.length > 0 ? 'both' : symCands.length > 0 ? 'symbol' : 'code', + }) + setFields(detected) + return + } else { + setSelectMapping({ fields: detected, need: 'both' }) + setFields(detected) + return + } + + setSymbolMap(sm) + setCodeMap(cm) + setMatchStatus(status) + setFields(detected) + setSelectMapping(null) + } + + const handleDetectFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + setDetecting(true); setError(''); setSelectMapping(null) + api.extDataDetectFields(file) + .then((res) => { + processDetection(res.fields, res.symbol_candidates, res.code_candidates) + }) + .catch((err) => setError(String(err))) + .finally(() => setDetecting(false)) + } + + const userFields = fields.filter(f => f.name !== 'symbol' && f.name !== 'code') + + return ( +
+
+ +
+
+
+

新增扩展数据

+

+ 接入自有数据,与标的自动关联(第三方接口或CSV/Excel),支持概念、人气、资金流、舆情、研报评分标签等场景 +

+
+ +
+
+ +
+
+
数据类型
+
+ {(['snapshot', 'timeseries'] as const).map((m) => { + const active = mode === m + return ( + + ) + })} +
+
+ +
+
+
显示名称
+ setLabel(e.target.value)} + placeholder={mode === 'snapshot' ? '例: 概念' : '例: 资金流'} + className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow" + /> +
+
+
标识符
+ setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))} + placeholder={mode === 'snapshot' ? '例: concept' : '例: money_flow'} + className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground font-mono placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow" + /> +
+
+ +
+
描述
+ setDescription(e.target.value)} + placeholder="扩展数据 · 与标的 JOIN(可选自定义描述)" + className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow" + /> +
+ +
+
字段定义
+ + + + {selectMapping && ( + +
+ 未自动识别到标的代码,请选择哪一列作为标的代码(symbol) +
+
+ {selectMapping.fields.map(f => ( + + ))} + +
+
+ )} + + {userFields.length === 0 ? ( +
detectFileRef.current?.click()} + onDragOver={e => { e.preventDefault(); setDragOver(true) }} + onDragLeave={() => setDragOver(false)} + onDrop={e => { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer.files[0] + if (file) { setDetecting(true); setError(''); setSelectMapping(null); api.extDataDetectFields(file).then(res => { processDetection(res.fields, res.symbol_candidates, res.code_candidates); }).catch(err => setError(String(err))).finally(() => setDetecting(false)) } + }} + className={`rounded-xl border-2 border-dashed py-6 flex flex-col items-center justify-center gap-2 cursor-pointer transition-colors ${ + dragOver ? 'border-accent bg-accent/[0.06]' : detecting ? 'border-border/40 pointer-events-none' : 'border-border/30 hover:border-accent/40 hover:bg-accent/[0.02]' + }`} + > + {detecting ? ( + <>检测字段中… + ) : ( + <> + + 上传数据文件(CSV / Excel)自动识别数据格式 + 自动识别列名和类型,symbol 列自动匹配 + + )} +
+ ) : ( + <> +
+ + +
+
+
+ 标的代码 + symbol + 文本 + {matchStatus !== 'none' + ? + {symbolMap.type === 'mapped' ? `← ${symbolMap.col}` : '← 计算'} + + : } +
+
+ 代码 + code + 文本 + {matchStatus !== 'none' + ? + {codeMap.type === 'mapped' ? `← ${codeMap.col}` : codeMap.method === 'strip_exchange' ? '← symbol截取' : '← 推算'} + + : } +
+ {userFields.map((f) => { + const idx = fields.indexOf(f) + return ( +
+ updateField(idx, 'label', e.target.value)} + placeholder="显示名" + className="w-[72px] h-7 px-2 rounded-md border border-border bg-base text-[11px] text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/40" + /> + updateField(idx, 'name', e.target.value)} + placeholder="字段名" + className="flex-1 h-7 px-2 rounded-md border border-border bg-base text-[11px] font-mono text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/40" + /> + + +
+ ) + })} +
+ + )} +
+ + {error && ( +
{error}
+ )} +
+ +
+
数据需自行维护和更新,系统不会自动同步
+
+ + +
+
+
+
+ ) +} diff --git a/serve/frontend/src/components/ext-data/EditExtDialog.tsx b/serve/frontend/src/components/ext-data/EditExtDialog.tsx new file mode 100644 index 0000000..acae389 --- /dev/null +++ b/serve/frontend/src/components/ext-data/EditExtDialog.tsx @@ -0,0 +1,252 @@ +import { useRef, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { motion } from 'framer-motion' +import { X, Loader2, Upload } from 'lucide-react' +import { api, type ExtDataConfig, type ExtDataField } from '@/lib/api' +import { QK } from '@/lib/queryKeys' + +export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onClose: () => void }) { + const qc = useQueryClient() + const [label, setLabel] = useState(config.label) + const [description, setDescription] = useState(config.description ?? '') + const [fields, setFields] = useState([...config.fields]) + const [error, setError] = useState('') + const detectFileRef = useRef(null) + const [detecting, setDetecting] = useState(false) + const [symbolMapping, setSymbolMapping] = useState<{ candidates: string[]; file: File } | null>(null) + + const update = useMutation({ + mutationFn: () => + api.extDataUpdate(config.id, { + label: label.trim(), + fields: fields.filter((f) => f.name.trim()), + description: description.trim() || '', + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.extData }) + onClose() + }, + onError: (err) => setError(String(err)), + }) + + const addField = () => + setFields([...fields, { name: '', dtype: 'string', label: '' }]) + + const removeField = (i: number) => + setFields(fields.filter((_, idx) => idx !== i)) + + const updateField = (i: number, key: keyof ExtDataField, val: string) => + setFields(fields.map((f, idx) => (idx === i ? { ...f, [key]: val } : f))) + + const valid = label.trim() && fields.some((f) => f.name.trim()) + + const applyDetectedFields = (detected: { name: string; dtype: string; label: string }[]) => { + const rest = detected.filter(f => f.name !== 'symbol') + setFields([ + { name: 'symbol', dtype: 'string', label: '标的代码' }, + ...rest, + ]) + } + + const handleDetectFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + setDetecting(true); setError(''); setSymbolMapping(null) + api.extDataDetectFields(file) + .then((res) => { + const hasSym = res.symbol_candidates.length > 0 + const hasCode = res.code_candidates.length > 0 + if (hasSym || hasCode) { + applyDetectedFields(res.fields) + } else { + const candidates = res.fields.map(f => f.name) + setSymbolMapping({ candidates, file }) + } + }) + .catch((err) => setError(String(err))) + .finally(() => setDetecting(false)) + } + + const handleSymbolMap = (_col: string) => { + if (!symbolMapping) return + setDetecting(true); setError('') + const rest = fields.filter(f => f.name !== 'symbol') + setFields([{ name: 'symbol', dtype: 'string', label: '标的代码' }, ...rest]) + setSymbolMapping(null) + setDetecting(false) + } + + return ( +
+
+ +
+

编辑扩展数据

+ +
+ +
+
+
+ +
+ {config.id} +
+
+
+ +
+ {config.mode === 'snapshot' ? '快照型' : '时序型'} +
+
+
+ +
+ + setLabel(e.target.value)} + className="w-full h-8 px-3 rounded-btn bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="可选,简要说明数据的用途" + className="w-full h-8 px-3 rounded-btn bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50" + /> +
+ +
+
+ +
+ + + +
+
+ {symbolMapping && ( +
+
未找到 symbol 列,请选择哪一列作为标的代码:
+
+ {symbolMapping.candidates.map(col => ( + + ))} + +
+
+ )} +
+ {fields.map((f, i) => { + const isBuiltin = f.name === 'symbol' || f.name === 'name' + return ( +
+ updateField(i, 'label', e.target.value)} + placeholder="显示名" + disabled={isBuiltin} + className={`w-20 h-7 px-2 rounded-btn border text-[11px] text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50 ${ + isBuiltin + ? 'bg-elevated/50 border-border text-muted cursor-not-allowed' + : 'bg-base border-border' + }`} + /> + updateField(i, 'name', e.target.value)} + placeholder="字段名 (英文)" + disabled={isBuiltin} + className={`flex-1 h-7 px-2 rounded-btn border text-[11px] font-mono placeholder:text-muted/40 focus:outline-none focus:border-accent/50 ${ + isBuiltin + ? 'bg-elevated/50 border-border text-muted cursor-not-allowed' + : 'bg-base border-border' + }`} + /> + + {!isBuiltin && fields.length > 3 && ( + + )} + {isBuiltin &&
} +
+ ) + })} +
+
+ + {error && ( +
{error}
+ )} +
+ +
+ + +
+ +
+ ) +} diff --git a/serve/frontend/src/components/ext-data/ExtDataApiPanel.tsx b/serve/frontend/src/components/ext-data/ExtDataApiPanel.tsx new file mode 100644 index 0000000..5cd3700 --- /dev/null +++ b/serve/frontend/src/components/ext-data/ExtDataApiPanel.tsx @@ -0,0 +1,79 @@ +import { Check, Copy } from 'lucide-react' +import type { ExtDataConfig } from '@/lib/api' + +export function ExtDataApiPanel({ config, copied, setCopied }: { + config: ExtDataConfig + copied: boolean + setCopied: (v: boolean) => void +}) { + const endpoint = `POST /api/ext-data/${config.id}/ingest` + + const exampleRow: Record = { symbol: '000001.SZ' } + for (const f of config.fields) { + if (f.name === 'symbol' || f.name === 'code') continue + exampleRow[f.name] = f.dtype === 'int' ? 100 : f.dtype === 'float' ? 1.5 : f.dtype === 'bool' ? true : '示例' + } + if (config.fields.some(f => f.name === 'code')) { + exampleRow['code'] = '000001' + } + const exampleBody = { + date: config.mode === 'timeseries' ? '2025-01-15' : undefined, + rows: [exampleRow], + } + const exampleJson = JSON.stringify(exampleBody, null, 2) + const curlCmd = `curl -X POST http://localhost:${window.location.port || '3018'}/api/ext-data/${config.id}/ingest \\ + -H 'Content-Type: application/json' \\ + -d '${JSON.stringify(exampleBody)}'` + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( +
+
+
接口端点
+
+ {endpoint} + +
+
+ +
+
请求体示例
+
+          {exampleJson}
+        
+
+ +
+
+ cURL 示例 + +
+
+          {curlCmd}
+        
+
+ +
+ {config.mode === 'snapshot' ? ( + <>date 可选,默认当天;每次写入覆盖同日期数据 + ) : ( + <>date 必填,指定交易日;按日期分区存储 + )} +
+
+ ) +} diff --git a/serve/frontend/src/components/ext-data/ExtDataPullPanel.tsx b/serve/frontend/src/components/ext-data/ExtDataPullPanel.tsx new file mode 100644 index 0000000..6ef6350 --- /dev/null +++ b/serve/frontend/src/components/ext-data/ExtDataPullPanel.tsx @@ -0,0 +1,326 @@ +import { useState } from 'react' +import { Loader2, Search, Check, Clock, Zap, Settings2, AlertCircle, CheckCircle2, Calendar } from 'lucide-react' +import { api, type ExtDataConfig } from '@/lib/api' +import { toast } from '@/components/Toast' + +export function ExtDataPullPanel({ config, onSaved }: { + config: ExtDataConfig + onSaved: () => void +}) { + const pull = config.pull + const [url, setUrl] = useState(pull?.url ?? '') + const [method, setMethod] = useState(pull?.method ?? 'GET') + const [headerStr, setHeaderStr] = useState( + pull?.headers ? JSON.stringify(pull.headers, null, 2) : '' + ) + const [body, setBody] = useState(pull?.body ?? '') + const [responsePath, setResponsePath] = useState(pull?.response_path ?? '') + const [fieldMapStr, setFieldMapStr] = useState( + pull?.field_map ? JSON.stringify(pull.field_map, null, 2) : '' + ) + const [schedule, setSchedule] = useState(pull?.schedule_minutes ?? 1440) + const [enabled, setEnabled] = useState(pull?.enabled ?? false) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [running, setRunning] = useState(false) + const [runResult, setRunResult] = useState<{ rows: number; date: string } | null>(null) + const [testResult, setTestResult] = useState<{ total_rows: number; preview: Record[]; has_symbol: boolean } | null>(null) + const [error, setError] = useState('') + + // 解析 JSON 输入, 失败时设置 error 并返回 null + const parseJson = (str: string, label: string): Record | undefined | null => { + if (!str.trim()) return undefined + try { return JSON.parse(str) } + catch { setError(`${label} 不是有效 JSON`); return null } + } + + // 构建保存 payload (复用当前编辑态), enabledOverride 用于开关自动保存 + const buildPayload = (enabledOverride?: boolean) => { + const headers = parseJson(headerStr, 'Headers') + if (headers === null) return null + const field_map = parseJson(fieldMapStr, '字段映射') + if (field_map === null) return null + return { + url, method, headers, body: body || undefined, + response_path: responsePath, field_map, + schedule_minutes: schedule, enabled: enabledOverride ?? enabled, + } + } + + const handleSave = (silent = false) => { + const payload = buildPayload() + if (!payload) return + setSaving(true); setError('') + api.extDataPullConfig(config.id, payload) + .then(() => { + onSaved() + if (!silent) toast('配置已保存', 'success') + }) + .catch(e => setError(e.message || '保存失败')) + .finally(() => setSaving(false)) + } + + const handleTest = () => { + setTesting(true); setError(''); setTestResult(null) + const payload = buildPayload() + if (!payload) { setTesting(false); return } + api.extDataPullConfig(config.id, payload) + .then(() => api.extDataPullTest(config.id)) + .then(r => { setTestResult(r); onSaved() }) + .catch(e => setError(e.message || '测试失败')) + .finally(() => setTesting(false)) + } + + const handleRun = () => { + setRunning(true); setError(''); setRunResult(null) + api.extDataPullRun(config.id) + .then(r => { + setRunResult({ rows: r.rows, date: r.date }) + onSaved() + toast(`拉取成功 · ${r.rows} 行`, 'success') + }) + .catch(e => setError(e.message || '执行失败')) + .finally(() => setRunning(false)) + } + + // 开关 toggle: 自动保存全量配置 (切换 enabled), 后端 refresh 后立即首次拉取 + const [toggling, setToggling] = useState(false) + const handleToggle = (next: boolean) => { + if (toggling) return + if (next && !url.trim()) { + toast('请先填写拉取 URL', 'error') + return + } + const payload = buildPayload(next) + if (!payload) return + setToggling(true); setError(''); setEnabled(next) + api.extDataPullConfig(config.id, payload) + .then(() => { + onSaved() + toast(next ? '定时拉取已启用 · 立即执行首次拉取' : '定时拉取已关闭', 'success') + }) + .catch(e => { + setEnabled(!next) // 回滚 + setError(e.message || '切换失败') + }) + .finally(() => setToggling(false)) + } + + // 格式化时间显示 + const fmtTime = (iso: string | null | undefined) => { + if (!iso) return null + const d = new Date(iso) + if (isNaN(d.getTime())) return null + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const mi = String(d.getMinutes()).padStart(2, '0') + return `${mm}-${dd} ${hh}:${mi}` + } + + return ( +
+ {/* ===== 分区 ①: 请求配置 ===== */} +
+
+ + 请求配置 +
+ +
+ + setUrl(e.target.value)} + placeholder="https://api.example.com/data" + className="flex-1 min-w-0 rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[11px] font-mono text-foreground placeholder:text-muted/50" + /> +
+ +
+
Headers (JSON,可选)
+