Merge branch 'feat/capability-routing' (v0.2.2): 能力路由矩阵、fuyao 插件、分钟策略回测、盘中/复盘增强、首用引导改版

This commit is contained in:
shy3130
2026-08-30 19:47:19 +08:00
133 changed files with 13897 additions and 2261 deletions
+11
View File
@@ -106,6 +106,7 @@
- 窗口、前 N 日和批次回算均按实际交易日,不得用自然日直接替代。
- A 股交易时段统一按北京时间处理;服务器时区不能成为业务逻辑的隐式输入。
- 分钟 K 的 `datetime` 统一为北京时间墙钟(naive,如 `09:35:00`);数据源入口(`kline_sync``_normalize_minute` / `_try_custom_minute`)强制归一,禁止 UTC 口径入库或下发。
- 日线、分钟线和实时快照必须明确交易日期归属,尤其注意午休、收盘后和跨日重启。
- 分钟 K 的股票、ETF、指数分开存储和路由,不得仅凭代码格式猜测资产类型。
@@ -129,6 +130,16 @@
- 插件加载失败、字段缺失和空数据必须有隔离测试,不能导致应用启动失败或其他数据源不可用。
- 新增数据源适配应同步更新 `docs/custom-data-source.md``docs/plugin-development.md` 中对应契约。
### 能力路由矩阵契约
能力矩阵(`backend/app/data_providers/capabilities.py` 注册表 + `/api/settings/capability-matrix`)是能力路由的单一权威,遵循以下不变量:
- 注册表集中声明每个能力的展示元数据、路由偏好字段与 TickFlow 档位要求;前端不硬编码能力清单。新增能力按既有模式扩展:注册表 + preferences getter + capability-matrix 注入 + 矩阵测试。
- 各页面能力门控统一以矩阵的 `usable` 为准(生效源当前能否真正提供该能力),不是 TickFlow 套餐视角;缺能力提示统一引导到数据源配置。
- 能力层中立:通用界面(侧栏徽章、能力路由卡、各页门控提示)不得出现 TickFlow 档位/订阅词汇;档位信息只在 TickFlow 专属详情卡展示。provider 名称作为路由事实可以出现。
- 每个能力独立路由,禁止跟随/派生特殊值(`same_as_daily` 已下线);存量非法偏好值由 preferences getter 回退默认自愈,不做迁移。
- 边界注记:分时监控由分钟能力兜底(`intraday_monitor_support`),不单设分时能力;`depth5` 已进矩阵但插件数据集白名单暂未开放,当前仅 TickFlow 提供。
## 5. 领域专项要求
### 5.1 策略
+180 -68
View File
@@ -1,48 +1,38 @@
<div align="center">
# 📈 A股智能量化工作台
[![声明:个人开源](https://img.shields.io/badge/⚠️_声明-个人开源_非TickFlow官方项目-green?style=for-the-badge&labelColor=red)](https://github.com/shy3130/tick-stock-panel)
# TSP--A股智能量化工作台
[![声明: 本项目为个人开源, 非隶属任何官方项目](https://img.shields.io/badge/%E5%A3%B0%E6%98%8E-%E6%9C%AC%E9%A1%B9%E7%9B%AE%E4%B8%BA%E4%B8%AA%E4%BA%BA%E5%BC%80%E6%BA%90_%E9%9D%9E%E9%9A%B6%E5%B1%9E%E4%BB%BB%E4%BD%95%E5%AE%98%E6%96%B9%E9%A1%B9%E7%9B%AE-green?style=for-the-badge&labelColor=red)](https://github.com/shy3130/tick-stock-panel)
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
**面向个人散户与量化爱好者而生**
**多数据源能力路由 · 分钟级策略执行 · 全时段异动监控 · AI 辅助研究**
[![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)
[![Engine: Polars](https://img.shields.io/badge/Engine-Polars-6f42c1.svg)](https://pola.rs/)
[![Data: 多源插件化 · 能力路由](https://img.shields.io/badge/Data-%E5%A4%9A%E6%BA%90%E6%8F%92%E4%BB%B6%E5%8C%96%20%C2%B7%20%E8%83%BD%E5%8A%9B%E8%B7%AF%E7%94%B1-00b386.svg)](./docs/custom-data-source.md)
[![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile)
[![Build: Docker CI](https://github.com/shy3130/tick-stock-panel/actions/workflows/docker.yml/badge.svg)](https://github.com/shy3130/tick-stock-panel/actions/workflows/docker.yml)
[![GitHub stars](https://img.shields.io/github/stars/shy3130/tick-stock-panel?style=social)](https://github.com/shy3130/tick-stock-panel/stargazers)
</div>
<div align="center">
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[完整文档](#-完整文档)**
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[技术架构](#-技术架构)** · **[配置](#-配置)** · **[完整文档](#-完整文档)**
</div>
---
**本项目以个人开源为主进行开发维护,数据源插件化,可任意接入第三方数据源。仅供学习研究使用,严禁商业用途。**
**本项目个人开源,数据源插件化,可任意接入第三方数据源。仅供学习研究使用,严禁商业用途。**
> ⚠️ 小白请绕路,本开源项目谨作为本地量化提供解决思路Demo,不作为投资软件或者看盘软件。
> ⚠️ 小白请绕路,本开源项目谨作为本地量化提供解决思路与方案,不作为投资软件或者看盘软件。
>
> **明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
问题可以邮件415333856@qq.com。
任何项目问题或商务合作 / 广告投放等合作意向,可邮件联系 415333856@qq.com。
觉得有用可以点个 Star
@@ -50,59 +40,60 @@
## ✨ 核心功能
| 模块 | 一句话 | 详见 |
| 模块&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; | 一句话 | 详见&nbsp;&nbsp;&nbsp; |
| :--------------- | :--------------------------------------------------------------------- | :-------------------------------- |
| 🔍 **选股引擎** | 18 个内置策略 + 自定义信号 + AI 生成 + 代码迁移,Polars 毫秒级扫全 A 股 | [strategy.md](./docs/strategy.md) |
| 📊 **指标流水线** | MA/EMA/MACD/RSI/KDJ/布林/量比等,一次扫表落盘 enriched Parquet | [features.md](./docs/features.md) |
| 🧪 **回测研究** | 因子/策略回测 + 财务快照因子(点时口径),T+1/费用/滑点约束,SSE 持久任务 | [features.md](./docs/features.md) |
| 🔀 **能力路由** | 多数据集(日K/除权/实时/分钟/盘口/财务,持续扩展)按源能力独立路由,任选组合 | [custom-data-source.md](./docs/custom-data-source.md) |
| 🔍 **选股引擎** | 18 个内置策略 + 分钟策略 + 自定义信号 + AI 生成,Polars 毫秒级扫全 A 股 | [strategy.md](./docs/strategy.md) |
| 📊 **指标流水线** | MA/EMA/MACD/RSI/KDJ/布林/量比等 68 列指标与信号,一次扫表落盘 enriched Parquet | [features.md](./docs/features.md) |
| 🧪 **回测研究** | 因子/策略/分钟回测 + 财务快照因子(点时口径),T+1/费用/滑点约束,结果可导出 | [features.md](./docs/features.md) |
| ⛏️ **因子挖掘** | 嵌套样本外搜索多因子排名组合,与自有策略对照,候选库显式发布、永不自动上线 | [mining.md](./docs/mining.md) |
| 🌡️ **市场环境** | 情绪周期 6 阶段(连板梯队驱动)+ 概念/行业主线排名,与 5 档环境分并存 | [market-phase.md](./docs/market-phase.md) |
| 🚨 **异动监控** | 交易所异动规则口径(3/10/30 日偏离值),盘中实时接近度,系统告警与推送接入 | — |
| 🚨 **异动监控** | 竞价/盘中/偏移三类异动一页覆盖:同花顺风向标 + 当日信号聚合 + 交易所偏离值口径 | — |
| 📡 **监控中心** | 四类监控(策略/个股信号/价格/异动),多条件 AND/OR + 语音播报 + 飞书推送 | [features.md](./docs/features.md) |
| 📈 **个股分析** | 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面) | [features.md](./docs/features.md) |
| 🏆 **连板梯队** | 连板层级统计 + 概念涨幅轮动 + 盘后 AI 复盘 + 炸板/翘板预警 | [features.md](./docs/features.md) |
| 🧰 **数据扩展** | 数据源插件化(stock-sdk 示例 + YAML 自定义源),扩展字段配成一级页面同台分析 | [custom-data-source.md](./docs/custom-data-source.md) |
| 🏆 **连板梯队** | 连板层级统计 + 概念涨幅轮动 + 盘后 AI 复盘(龙虎榜/盘前风向标注入) + 炸板/翘板预警 | [features.md](./docs/features.md) |
| 🧰 **数据扩展** | 数据源插件化(TickFlow/fuyao/stock-sdk + YAML 自定义源),扩展字段配成一级页面同台分析 | [custom-data-source.md](./docs/custom-data-source.md) |
<details>
<summary><b>📦 主要页面与功能</b></summary>
**📊 行情总览**
- **看板** Dashboard — 市场情绪评分 + 涨跌/成交额榜单 + 概念领涨领跌 + 大盘异动事件流,一日全貌
- **自选** Watchlist — 自选股池,多分组管理(M:N),表格/卡片双视图,换手/量比/RSI 等实时指标
- **看板** Dashboard — 市场情绪评分 + 涨跌/成交额榜单 + 概念/行业领涨领跌(点击板块直达成分股,领涨股带涨跌幅) + 大盘异动事件流,一日全貌
- **自选** Watchlist — 自选股池,多分组管理(M:N),表格/卡片双视图,换手/量比/RSI 等实时指标,按档位分流实时刷新
- **指数** Indices — 沪深指数浏览与同步
**🔍 选股与回测**
- **策略** Screener — Polars 毫秒级扫描全 A 股,18 个内置策略卡片 + 自定义条件
- **回测** Backtest — 两种模式:
- **因子回测** — IC/IR、分层收益、多空组合,先筛掉无效指标
- **策略回测** — 净值曲线、回撤、夏普、胜率,支持 T+1/手续费/滑点/止损,SSE 流式进度
- **策略** Screener — Polars 毫秒级扫描全 A 股,日线/分钟策略统一单池,按策略声明周期自动路由执行
- **回测** Backtest — 四种研究视图:
- **因子回测** — IC/IR、分层收益、多空组合,62+ 因子目录先筛掉无效指标
- **策略回测** — 净值曲线、回撤、夏普、胜率,T+1/手续费/滑点/止损,SSE 流式进度
- **分钟策略回测** — 逐交易日回放信号、分钟收盘入场,分钟级成交明细
- **验证** — 参数敏感性与滚动样本外
- 研究闭环:结果导出 CSV(概要/净值/交易明细/分标的统计) → 保存候选 → **一键载入复测**
- **挖掘** Mining — 嵌套样本外因子与策略挖掘:训练区间因子方向重估 + 相关性去重 + 多因子排名组合搜索,自有策略作对照轨;候选入库,显式确认后才发布,永不自动上线
**📈 个股与板块分析**
- **个股分析** Stock Analysis (Beta) — 日K + 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面)
- **财务分析** Financials — 利润表/资负表/现金流/关键指标 + AI 解读
- **概念分析** Concept Analysis — ths 概念涨幅轮动矩阵 + 领涨/领跌主线 + 个股穿透
- **行业分析** Industry Analysis — 行业分层涨幅轮动 + 领涨/领跌主线 + 成分股
- **财务分析** Financials — 利润表/资负表/现金流/关键指标(多源并集合并,fuyao 财务四表适配) + AI 解读
- **概念分析 / 行业分析** — ths 维度涨幅轮动矩阵 + 领涨/领跌主线 + 个股穿透
- **市场环境** Regime — 情绪周期 6 阶段(冰点/启动/主升/高潮/退潮/修复,连板梯队驱动,EMA 平滑 + 2 日确认)+ 概念/行业主线排名,与 5 档环境分并存
- **连板梯队** Limit Up Ladder — 连板层级统计 + 概念/行业分布 + 封单监控(可切换连跌梯队)
**🔔 监控与复盘**
- **监控中心** Monitor — 策略/个股信号/价格/异动四类规则,支持自选分组作用域,盘中实时弹窗 + 语音播报(播报个股名称与信号) + 触发记录持久化
- **异动监控** Abnormal Moves — 按交易所异动规则口径(3日 ±20%/±30%/±40% · 10日 +100% · 30日 +200%)实时计算个股偏离值接近度,盯住异动边缘名单;触发记录 + 站内通知/飞书·企微推送
- **复盘** Review (Beta) — 盘后 AI 自动生成市场复盘,可定时执行、推送飞书、下载 Markdown
- **异动监控** Abnormal Moves — 按交易时间线三 tab:
- **竞价异动** — 同花顺盘前风向标(含当日/次日真实收益对照、追高风险标记)+ 全市场竞价扫描(待采集任务)
- **盘中异动** — 涨停/炸板/翘板/跌停/新高/新低/放量当日信号聚合,零新增采集
- **偏移异动** — 交易所异动偏离值口径(主板 3 日 ±20%、创业板/科创板 ±30%、北交所 ±40%;10 日 +100%/50%、30 日 +200%/70%),实时接近度
- **复盘** Review (Beta) — 盘后 AI 自动生成市场复盘,注入龙虎榜资金动向与盘前风向标对照;可定时执行、推送飞书、下载 Markdown
**🗄️ 数据与扩展**
- **数据** Data — 本地数据画像与同步状态(维表/日K/除权/Enriched/指数/ETF/分钟K/财务),盘后管道与历史扩展
- **扩展分析** (动态菜单) — 把任意第三方/扩展数据字段配成一级菜单,与内置数据同台分析
- **设置** Settings — 数据源与能力检测、AI 接口、实时监控、扩展页面、信号库、菜单与系统设置
- **设置** Settings — 数据源与能力检测(能力路由矩阵、档位徽章)、AI 接口、实时监控、扩展页面、信号库、菜单与系统设置
</details>
---
## 📸 界面预览
@@ -148,11 +139,116 @@
</div>
---
## 🏗️ 技术架构
### 分层总览
```mermaid
flowchart TB
subgraph DATA["数据源层 · 插件化"]
direction LR
D1["TickFlow SDK"] ~~~ D2["fuyao<br/>同花顺 REST"] ~~~ D3["stock-sdk"] ~~~ D4["YAML 自定义源"] ~~~ D5["+ 更多插件…"]
end
subgraph ROUTE["能力路由层"]
direction LR
R(["多数据集 · 按能力独立路由 · 档位探测"])
end
subgraph STORE["存储层"]
direction LR
ST1[("Parquet 分区表")] ~~~ ST2[("DuckDB")] ~~~ ST3[("JSON 按日缓存")]
end
subgraph CALC["计算层 · Polars"]
direction LR
C1["指标流水线"] ~~~ C2["复权与信号"]
end
subgraph RES["研究层"]
direction LR
G1["因子引擎"] ~~~ G2["回测引擎"] ~~~ G3["因子挖掘"]
end
subgraph SVC["应用层 · FastAPI"]
direction LR
S1["REST · SSE"] ~~~ S2["监控引擎"] ~~~ S3["任务调度"] ~~~ S4["交易日探针"]
end
subgraph FE["呈现层 · React 18"]
direction LR
F1["功能页面"] ~~~ F2["图表可视化"] ~~~ F3["实时推送"]
end
subgraph EXT["二次开发与扩展 · 贯穿各层的插槽"]
direction LR
X1["自定义策略"] ~~~ X2["自定义信号"] ~~~ X3["扩展分析页面"] ~~~ X4["AI 接口"]
end
DATA --- ROUTE
ROUTE --- STORE
STORE --- CALC
CALC --- RES
RES --- SVC
SVC --- FE
FE ~~~ EXT
classDef fe fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef svc fill:#ecfeff,stroke:#06b6d4,color:#164e63
classDef res fill:#fff7ed,stroke:#f97316,color:#7c2d12
classDef calc fill:#f0f9ff,stroke:#0ea5e9,color:#0c4a6e
classDef store fill:#ecfdf5,stroke:#10b981,color:#064e3b
classDef data fill:#fdf2f8,stroke:#ec4899,color:#831843
classDef pluginSlot fill:#fdf2f8,stroke:#ec4899,color:#831843,stroke-dasharray:5 4
classDef route fill:#faf5ff,stroke:#8b5cf6,color:#6b21a8,stroke-width:2px
classDef ext fill:#f8fafc,stroke:#94a3b8,color:#334155,stroke-dasharray:5 4
class F1,F2,F3 fe
class S1,S2,S3,S4 svc
class G1,G2,G3 res
class C1,C2 calc
class ST1,ST2,ST3 store
class D1,D2,D3,D4 data
class D5 pluginSlot
class R route
class X1,X2,X3,X4 ext
style FE fill:#f5f3ff,stroke:#c7d2fe,color:#3730a3
style SVC fill:#ecfeff,stroke:#a5f3fc,color:#155e75
style RES fill:#fff7ed,stroke:#fed7aa,color:#9a3412
style CALC fill:#f0f9ff,stroke:#bae6fd,color:#075985
style STORE fill:#ecfdf5,stroke:#a7f3d0,color:#065f46
style ROUTE fill:#faf5ff,stroke:#ddd6fe,color:#6b21a8
style DATA fill:#fdf2f8,stroke:#fbcfe8,color:#9d174d
style EXT fill:#f8fafc,stroke:#94a3b8,color:#334155,stroke-dasharray:7 5
```
### 关键机制
| 机制 | 说明 |
| :--- | :--- |
| **能力路由矩阵** | 各数据集按源声明能力独立路由,注册表集中定义、可扩展:TICKFLOW 档位探测(None/Free/Starter/Pro/Expert)+ 插件源能力声明,fail-closed(声明 `pct_unit` 未声明即拒)。同一数据集可随时换源,指标与回测口径不变 |
| **交易日探针** | fuyao 交易日历(确定性,含调休)→ tickflow 全市场行情时间戳探针(OR 语义)→ 工作日兜底;节假日自动停掉实时轮询与分钟增量,零无效请求 |
| **财务多源合并** | 按 `(symbol, period_end)` 报告期累积,多源取并集、逐列按公告日取最新(PIT);公告前一律空值,绝不填 0 |
| **非路由数据集直连** | 龙虎榜/盘前风向标/交易日历等 fuyao 专有能力不进路由矩阵,由独立服务直连消费——按日 JSON 缓存(历史不可变)、交易日回退、四态降级 |
| **回测执行隔离** | 回测在 spawn worker 子进程运行,持久 run ID,刷新/切页重连不丢任务;子进程结果消息经锁保护回传 |
| **分层缓存** | enriched 读取时现算指标(存储仅 15 列基础数据,现算 68 列指标与信号)+ 进程内快照缓存;扩展字段按日分区快照,页面即配即用 |
### 技术栈
| 层 | 选型 |
| :----------- | :------------------------------------------------------------------------------------------------ |
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
| **回测** | 自研仓位模拟引擎(T+1/费用/滑点/分钟回放)· vectorbt(部分路径) |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK · fuyao(同花顺 REST) · 插件化扩展(stock-sdk 示例插件 · YAML 自定义源) |
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · [Lightweight Charts](https://www.tradingview.com/lightweight-charts/)(TradingView 开源) · ECharts · dnd-kit |
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
---
## 🚀 快速开始
@@ -195,11 +291,11 @@ CODEX_CLI_VERSION=0.144.3 docker compose up --build
### 跑起来后的第一次使用
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签与能力路由矩阵
2. **设置****立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
5. **监控中心**配规则,盘中实时弹窗 + 持久化记录
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度),结果可导出 CSV、存候选一键复测
5. **监控中心**配规则,盘中实时弹窗 + 持久化记录;**异动监控**覆盖竞价/盘中/偏移全时段
---
@@ -217,30 +313,17 @@ PORT=3018 # 服务端口
---
## 🏗️ 技术栈
| 层 | 选型 |
| :----------- | :------------------------------------------------------------------------------------------------ |
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
| **回测** | vectorbt(全项目唯一 pandas 边界) |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK · 插件化扩展(stock-sdk 示例插件 · YAML 自定义源) |
| **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/手续费/止损) | ✅ |
| 2-3 | Polars enriched 流水线 · Screener · 回测引擎(T+1/手续费/止损) | ✅ |
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
| **v0.2** | 因子挖掘全链路 · 市场阶段与主线识别 · 异动监控 · 数据源插件化 | ✅ |
| **v2** | Webhook 推送· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
| **v0.3** | 能力路由矩阵 · fuyao 数据源(财务/龙虎榜/风向标) · 分钟策略与回测 · 交易日探针 · 全时段异动中心 · 回测导出与候选复测 | ✅ |
| **v2** | Webhook 推送· 板块异动 · 早晚报 · 全市场竞价采集 · 更多扩展 | 🚧 |
---
@@ -251,11 +334,11 @@ PORT=3018 # 服务端口
| [docs/deployment.md](./docs/deployment.md) | 部署方式(Dev / Docker / GH Actions)、老 CPU 兼容、更新代码、访问密码 |
| [docs/configuration.md](./docs/configuration.md) | 所有 `.env` 配置项详解(数据源、AI、服务、密码、数据目录) |
| [docs/features.md](./docs/features.md) | 各功能模块详细说明(选股/指标/回测/监控/个股分析/数据扩展) |
| [docs/custom-data-source.md](./docs/custom-data-source.md) | 自定义数据源接入、YAML 配置与 mock 联调示例 |
| [docs/custom-data-source.md](./docs/custom-data-source.md) | 自定义数据源接入、能力路由契约、YAML 配置与 mock 联调示例 |
| [docs/strategy.md](./docs/strategy.md) | 策略体系(18 内置策略 + 三种扩展方式 + 文件结构) |
| [docs/mining.md](./docs/mining.md) | 因子与策略挖掘口径、防泄漏、任务隔离和发布边界 |
| [docs/market-phase.md](./docs/market-phase.md) | 市场情绪周期 6 阶段与概念/行业主线识别的口径与设计 |
| [docs/plugin-development.md](./docs/plugin-development.md) | 数据源插件开发规范(以 stock-sdk 为参考实现) |
| [docs/plugin-development.md](./docs/plugin-development.md) | 数据源插件开发规范(以 stock-sdk / fuyao 为参考实现) |
| [docs/secondary-development.md](./docs/secondary-development.md) | 代码二次开发、前端插槽、后端策略接口与 AI 开发模板 |
| [backend/app/strategy/prompts/strategy-guide.md](./backend/app/strategy/prompts/strategy-guide.md) | 策略开发完整规范(AI 生成与手写) |
@@ -265,15 +348,42 @@ fork同时请点个star哦,欢迎 Issue 和 PR。
## 💬 交流群
欢迎加入交流群,讨论交流。
欢迎加入交流群,一起讨论交流。作者个人维护的部分个性化接口,统一公布在群公告中,供大家免费使用。
<img src="./community-qr-code.jpg" alt="交流群二维码" width="240" />
---
## ❤️ 支持项目
<div align="center">
如果这个项目对你有帮助,欢迎请作者喝杯咖啡 ☕
<table>
<tr>
<td width="50%" align="center"><b>微信赞赏</b></td>
<td width="50%" align="center"><b>支付宝</b></td>
</tr>
<tr>
<td width="50%" align="center"><img src="./assets/support/wechat-appreciation.jpg" alt="微信赞赏码 · 感谢道友支持 愿一路长红" height="280" /></td>
<td width="50%" align="center"><img src="./assets/support/alipay.jpg" alt="支付宝收款码 · 打开支付宝扫一扫" height="280" /></td>
</tr>
</table>
愿道友一路长红 📈
</div>
> 打赏完全自愿,金额不限;不用于购买任何功能、数据权限、投资建议
>
> 作者精力有限,优先响应赞助回馈,希望理解
---
## ⚠️ 免责声明
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源官方为准。
## 📄 License
@@ -281,6 +391,8 @@ fork同时请点个star哦,欢迎 Issue 和 PR。
本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款
内置数据源插件 [fuyao](https://fuyao.aicubes.cn/docs/api-reference/) 提供同花顺 REST 数据接口(行情 / 财务 / 龙虎榜 / 盘前风向标 / 交易日历等),需自备 API Key,使用前请遵守其服务条款
数据源插件 [stock-sdk](https://stock-sdk.linkdiary.cn) 遵循其各自的 ISC 协议。
## 社区
+1 -1
View File
@@ -1 +1 @@
v0.1.88
v0.2.2
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.2.1"
__version__ = "0.2.2"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+16 -2
View File
@@ -1,13 +1,27 @@
"""异动边缘监控 API — 按交易所异动规则口径统计接近触发的个股。"""
"""异动监控 API — 竞价/盘中/偏移三类异动。
- /intraday: 盘中量价信号聚合 (enriched 当日信号列, 零新增采集)
- /overview: 偏移异动边缘总览 (交易所异动规则口径的接近度)
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from app.services.abnormal_moves import build_overview
from app.services.abnormal_moves import build_intraday, build_overview
router = APIRouter(prefix="/api/abnormal", tags=["abnormal"])
@router.get("/intraday")
def abnormal_intraday(
request: Request,
limit: int = Query(500, ge=1, le=2000),
):
"""盘中异动: 涨停/炸板/跌停翘板/跌停/新高/新低/放量 信号命中行。"""
repo = request.app.state.repo
return build_intraday(repo, limit=limit)
@router.get("/overview")
def abnormal_overview(
request: Request,
+29
View File
@@ -346,6 +346,33 @@ class StrategyBacktestRequest(BaseModel):
regime_filter: dict | None = None
def _guard_minute_strategy_backtest(
request: Request, strategy_id: str, start: date, asset_type: str,
) -> None:
"""分钟策略回测入口守卫: 仅 A 股 + 本地分钟K覆盖检查 (fail-fast)。"""
engine = getattr(request.app.state, "strategy_engine", None)
if engine is None:
return
try:
s = engine.get(strategy_id)
except ValueError:
return
if s is None or s.execution_backend != "minute_filter":
return
if asset_type != "stock":
raise HTTPException(400, detail="分钟策略回测当前仅支持 A 股 (stock)")
earliest = request.app.state.repo.earliest_minute_date()
if earliest is None or start < earliest:
have = f"最早到 {earliest}, " if earliest else ""
raise HTTPException(
400,
detail=(
f"本地分钟K{have}无法覆盖回测起始日 {start}"
"请先用「扩展分钟K历史」拉取更多数据, 或缩小回测区间"
),
)
@router.post("/strategy/run")
def strategy_run(req: StrategyBacktestRequest, request: Request):
"""策略回测 — 复用 StrategyDef 体系做全周期回测。"""
@@ -355,6 +382,7 @@ def strategy_run(req: StrategyBacktestRequest, request: Request):
end = req.end or date.today()
start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS)
_guard_server_backtest_range(start, end)
_guard_minute_strategy_backtest(request, req.strategy_id, start, req.asset_type)
cfg = StrategyBacktestConfig(
strategy_id=req.strategy_id,
@@ -505,6 +533,7 @@ async def strategy_stream(
# 空 start = 全部历史: 用本地最早日K日期, 查不到再回退到默认窗口
earliest = request.app.state.repo.earliest_daily_date()
start_date = earliest or (end_date - timedelta(days=FACTOR_DEFAULT_DAYS))
_guard_minute_strategy_backtest(request, strategy_id, start_date, asset_type)
# 服务端范围保护
guard_violated = False
+193
View File
@@ -7,6 +7,7 @@ import math
import re
import shutil
import tempfile
import time
from datetime import date, datetime
from pathlib import Path
from typing import Literal
@@ -485,6 +486,198 @@ def dimension_members(
}
# ---------------------------------------------------------------------------
# 板块分时 (dimension intraday)
# ---------------------------------------------------------------------------
# 点击触发 + 60s 进程内缓存: 分钟分区是滚动底座 (minute_refresh / 盘后分钟同步),
# 不做后台预计算 — 板块基数大而单次聚合仅几十毫秒。
_DIMENSION_INTRADAY_CACHE: dict[tuple[str, str, str, str | None], tuple[float, dict]] = {}
_DIMENSION_INTRADAY_CACHE_TTL_S = 60.0
# 成分股网格化 ffill 上限: 超大板块退化为逐时间戳可得均值 (内存保护)。
_DIMENSION_INTRADAY_FFILL_CAP = 2000
def _bare_symbol_expr(col: str = "symbol") -> pl.Expr:
"""'000001.SZ''000001'; 已是裸代码则原样。"""
return pl.col(col).cast(pl.String).str.strip_chars().str.split(".").list.first()
def _dimension_member_bares(matched: pl.DataFrame, config: ExtConfig) -> list[str]:
"""成分股裸代码集合 (symbol 列优先级与 dimension-members 端点一致)。"""
if matched.is_empty():
return []
symbol_columns = ["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_columns.append(str(mapping["col"]))
cols = [c for c in dict.fromkeys(symbol_columns) if c in matched.columns]
if not cols:
return []
coalesced = pl.coalesce(
[pl.col(c).cast(pl.String).str.strip_chars().str.split(".").list.first() for c in cols]
)
series = matched.select(coalesced.alias("_bare")).to_series()
return sorted({s for s in series.to_list() if s})
def _prev_daily_close(data_dir: Path, target_date: str) -> pl.DataFrame | None:
"""目标日前最近一个日K分区的收盘价 → (_bare, prev_close); 无则 None。"""
daily = data_dir / "kline_daily"
if not daily.exists():
return None
dates = sorted(
d.name[5:]
for d in daily.iterdir()
if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists()
)
prevs = [d for d in dates if d < target_date]
if not prevs:
return None
path = daily / f"date={prevs[-1]}" / "part.parquet"
if not path.exists():
return None
df = pl.read_parquet(path, columns=["symbol", "close"])
return (
df.with_columns(_bare_symbol_expr().alias("_bare"))
.select([pl.col("_bare"), pl.col("close").cast(pl.Float64).alias("prev_close")])
.unique(subset=["_bare"], keep="last")
)
def _dimension_intraday_compute(
config: ExtConfig,
data_dir: Path,
field: str,
value: str,
snapshot_date: str | None,
) -> dict:
"""板块等权分时: 成分股当日分钟K逐分钟平均涨跌幅 + 全市场对照线。
口径: pct = 分钟close / ref − 1 (小数制, 与快照涨跌幅契约一致, 前端 ×100 显示),
ref 优先前一交易日日K收盘 (prev_close, 开盘跳空体现在曲线起点);
日K缺失的标的退化为当日首根分钟close (混合基准)。
停牌/无成交分钟按成分股 forward-fill 后再平均, 全市场线取逐时间戳可得均值。
"""
minute_dir = data_dir / "kline_minute"
partitions: list[str] = []
if minute_dir.exists():
partitions = sorted(
d.name[5:]
for d in minute_dir.iterdir()
if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists()
)
if snapshot_date:
target = snapshot_date if snapshot_date in partitions else None
else:
target = partitions[-1] if partitions else None
if not target:
return {"status": "no_data", "reason": "minute_missing", "date": snapshot_date, "points": []}
ext_df, _active = _read_ext_dataframe(config, data_dir)
if ext_df.is_empty() or field not in ext_df.columns:
return {"status": "empty", "reason": "no_members", "date": target, "points": []}
member_bares = _dimension_member_bares(_filter_dimension_member_rows(ext_df, field, value), config)
if not member_bares:
return {"status": "empty", "reason": "no_members", "date": target, "points": []}
try:
bars = pl.read_parquet(
minute_dir / f"date={target}" / "part.parquet",
columns=["symbol", "datetime", "close"],
)
except Exception as exc: # noqa: BLE001
logger.warning("dimension-intraday read minute partition failed: %s", exc)
return {"status": "no_data", "reason": "minute_schema", "date": target, "points": []}
bars = bars.drop_nulls(subset=["datetime", "close"])
if bars.is_empty():
return {"status": "no_data", "reason": "minute_empty", "date": target, "points": []}
bars = bars.with_columns(_bare_symbol_expr().alias("_bare"))
prev = _prev_daily_close(data_dir, target)
joined = bars.join(prev, on="_bare", how="left") if prev is not None else bars.with_columns(
pl.lit(None, dtype=pl.Float64).alias("prev_close")
)
refs = joined.group_by("_bare").agg(
pl.col("prev_close").first().alias("_prev"),
pl.col("close").sort_by("datetime").first().alias("_first"),
).with_columns(pl.coalesce(["_prev", "_first"]).alias("_ref"))
n_prev = refs["_prev"].is_not_null().sum()
basis = "prev_close" if n_prev == refs.height else ("first_close" if n_prev == 0 else "mixed")
joined = (
joined.join(refs.select(["_bare", "_ref"]), on="_bare", how="left")
.with_columns((pl.col("close") / pl.col("_ref") - 1.0).alias("_pct"))
)
market = joined.group_by("datetime").agg(pl.col("_pct").mean().alias("_market"))
member_bars = joined.filter(pl.col("_bare").is_in(member_bares))
members_with_minute = member_bars["_bare"].n_unique() if not member_bars.is_empty() else 0
if members_with_minute == 0:
return {
"status": "empty", "reason": "no_member_bars", "date": target,
"member_count": len(member_bares), "members_with_minute": 0, "points": [],
}
if members_with_minute <= _DIMENSION_INTRADAY_FFILL_CAP:
# 网格化 (成分股 × 全时间轴) + 逐股 ffill: 停牌分钟冻结在最后价而非退出均值
grid = (
member_bars.select(pl.col("_bare").unique())
.join(joined.select(pl.col("datetime").unique()), how="cross")
)
member_bars = (
grid.join(member_bars.select(["_bare", "datetime", "_pct"]), on=["_bare", "datetime"], how="left")
.sort(["_bare", "datetime"])
.with_columns(pl.col("_pct").forward_fill().over("_bare"))
)
sector = member_bars.group_by("datetime").agg(pl.col("_pct").mean().alias("_sector"))
combined = market.join(sector, on="datetime", how="left").sort("datetime")
def _r4(v) -> float | None:
return round(float(v), 4) if v is not None and not (isinstance(v, float) and math.isnan(v)) else None
points = [
{
"time": row["datetime"].strftime("%H:%M"),
"sector": _r4(row["_sector"]),
"market": _r4(row["_market"]),
}
for row in combined.iter_rows(named=True)
]
return {
"status": "ok",
"date": target,
"basis": basis,
"member_count": len(member_bares),
"members_with_minute": members_with_minute,
"points": points,
}
@router.get("/{config_id}/dimension-intraday")
def dimension_intraday(
request: Request,
config_id: str,
field: str = Query(..., min_length=1),
value: str = Query(..., min_length=1),
snapshot_date: str | None = Query(None, alias="date"),
):
"""板块分时走势 (等权): 成分股 × 当日分钟K聚合; 60s 缓存, 点击触发不预计算。"""
config = _store(request).get(config_id)
if not config:
raise HTTPException(404, f"配置 '{config_id}' 不存在")
cache_key = (config_id, field, value.strip(), snapshot_date)
now = time.monotonic()
hit = _DIMENSION_INTRADAY_CACHE.get(cache_key)
if hit is not None and now - hit[0] < _DIMENSION_INTRADAY_CACHE_TTL_S:
return hit[1]
payload = _dimension_intraday_compute(config, _data_dir(request), field, value, snapshot_date)
_DIMENSION_INTRADAY_CACHE[cache_key] = (now, payload)
return payload
# ---------------------------------------------------------------------------
# 文件上传
# ---------------------------------------------------------------------------
+22 -3
View File
@@ -10,7 +10,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from app.indicators.pipeline import compute_enriched, compute_enriched_single
from app.market_time import cn_now, cn_today
from app.market_time import cn_now, cn_today, in_continuous_session
from app.price_limits import is_risk_warning_name, price_limit_pct
from app.db_safe import is_valid_ext_ident
from app.services import kline_sync
@@ -780,11 +780,15 @@ def get_minute(
request: Request,
symbol: str = Query(..., description="标的代码"),
trade_date: date | None = Query(None, alias="date", description="交易日期, 默认最新"),
live: bool = Query(False, description="当日盘中跳过本地优先, 直接实时拉取(个股详情分时轮询用)"),
):
"""读取某只股票某天的分钟 K 线。
- 本地有完整数据(240条) → 直接返回
- 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入)
- live=true 且当日连续竞价时段 → 跳过本地优先直接实时拉取:
盘中分钟增量落盘的本地分区按 ≥60s 轮次更新, 90% 完整度启发式会让
详情分时图停在上一增量轮, 与行情列表的节奏脱节
"""
repo = request.app.state.repo
asset_type = repo.resolve_asset_type(symbol)
@@ -834,6 +838,19 @@ def get_minute(
price_limit = _get_price_limit_info(
repo, symbol, trade_date, asset_type, stock_name,
)
if live and trade_date == cn_today() and in_continuous_session():
# 详情分时轮询: 当日盘中实时拉取最新一根K, 不落盘; 拉空(源侧延迟/
# 时段边界)则落回下方本地优先路径。
live_df = kline_sync.fetch_minute_single(symbol, trade_date, asset_type=asset_type)
if not live_df.is_empty():
return {
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
"date": str(trade_date), "rows": live_df.to_dicts(),
"source": "live", "asset_type": asset_type,
"price_limit": price_limit, "prev_close": prev_close,
}
df = repo.get_minute(symbol, trade_date, asset_type=asset_type)
# 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算
@@ -1078,9 +1095,11 @@ async def clear_minute(request: Request):
removed = 0
if minute_dir.exists():
try:
result = repo.db.execute("SELECT COUNT(*) AS cnt FROM kline_minute").fetchone()
# execute_one (cursor+close): 直连 db.execute 的未消费结果集会在 Windows 上
# 钉住分区句柄, 导致下方 rmtree 静默删不掉被钉文件
result = repo.execute_one("SELECT COUNT(*) AS cnt FROM kline_minute")
removed = result[0] if result else 0
except Exception: # noqa: BLE001
except Exception:
pass
# 仅删 kline_minute 目录, 绝不触碰其他目录
shutil.rmtree(minute_dir, ignore_errors=True)
+46 -3
View File
@@ -1,4 +1,4 @@
"""AI 大盘复盘 API — 流式复盘 + 报告持久化。
"""AI 大盘复盘 API — 流式复盘 + 报告持久化 + 龙虎榜 + 盘前风向标
路由前缀: /api/market-recap
@@ -7,16 +7,19 @@
GET /reports 历史复盘列表
POST /reports 保存一条复盘报告
DELETE /reports/{report_id} 删除一条复盘报告
GET /dragon-tiger 龙虎榜三榜 (fuyao 专有, 历史按日缓存)
GET /auction-benchmark 盘前风向标 (fuyao 专有, 含当日/次日真实收益)
"""
from __future__ import annotations
import logging
from datetime import date as date_cls
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services import market_recap_reports
from app.services import auction_benchmark, dragon_tiger, market_recap_reports
from app.services.market_recap import recap_market_stream
logger = logging.getLogger(__name__)
@@ -30,6 +33,46 @@ class AnalyzeRequest(BaseModel):
focus: str = "" # 可选:用户追加的复盘关注点
@router.get("/dragon-tiger")
def get_dragon_tiger(
request: Request,
date: str | None = Query(default=None, description="复盘目标日 YYYY-MM-DD, 缺省取最近已发布交易日"),
):
"""龙虎榜三榜 (全部/机构/游资)。fuyao 专有, 未配置时 state=source_unavailable。
非交易日/当日未发布由服务层自动回退到上一交易日 (state=fallback_prev)。
"""
target = None
if date:
try:
target = date_cls.fromisoformat(date)
except ValueError:
raise HTTPException(400, f"date 格式应为 YYYY-MM-DD, 收到: {date}")
return dragon_tiger.get_dragon_tiger(
request.app.state.repo.store.data_dir, target
)
@router.get("/auction-benchmark")
def get_auction_benchmark(
request: Request,
date: str | None = Query(default=None, description="复盘目标日 YYYY-MM-DD, 缺省取最近交易日"),
):
"""盘前风向标 (同花顺竞价筛选名单 + 当日/次日真实收益)。
fuyao 专有, 未配置时 state=source_unavailable; 非交易日由服务层自动回退。
"""
target = None
if date:
try:
target = date_cls.fromisoformat(date)
except ValueError:
raise HTTPException(400, f"date 格式应为 YYYY-MM-DD, 收到: {date}")
return auction_benchmark.get_auction_benchmark(
request.app.state.repo.store.data_dir, target
)
@router.post("/analyze")
async def analyze_market(request: Request, req: AnalyzeRequest):
"""AI 大盘复盘 — NDJSON 流式返回。
+6
View File
@@ -107,6 +107,11 @@ class RuleModel(BaseModel):
# ladder 专属 (连板梯队封单监控)
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
# volume_delta 专属 (轮询放量监控): 相邻两次全市场快照的成交量增量
threshold_volume: float = 9000 # 单轮增量 >= 此值(手)时报警
threshold_amount: float = 1e6 # metric=amount 时: 单轮增量 >= 此值(元)时报警
# 基础过滤 (与策略 basic_filter 语义对齐): 值为 null 表示不过滤
basic_filter: dict = {}
# ── 字段选项 ─────────────────────────────────────────────
@@ -160,6 +165,7 @@ def get_options(request: Request):
{"key": "strategy", "label": "策略监控"},
{"key": "abnormal", "label": "异动监控"},
{"key": "sector", "label": "板块监控"},
{"key": "volume_delta", "label": "轮询放量"},
],
"scopes": [
{"key": "symbols", "label": "指定标的"},
+5 -2
View File
@@ -317,6 +317,9 @@ def run_preset(req: PresetRequest, request: Request):
raise HTTPException(status_code=status_code, detail=str(e)) from e
safe_data = _safe(asdict(result))
# 分钟周期结果不写入盘后缓存 (strategy_cache 是日线语义, as_of/updated_at
# 混入分钟结果会污染页面秒加载路径)。
if req.timeframe == "1d":
_update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data)
return _result_with_ext(safe_data, ext_values)
@@ -583,8 +586,8 @@ def run_all(request: Request, body: Optional[dict] = None):
elapsed = (time.perf_counter() - t_total) * 1000
logger.info("run_all: total took %.1fms (%d strategies)", elapsed, len(all_ids))
# 写入策略缓存 (供页面秒加载)
if results:
# 写入策略缓存 (供页面秒加载); 分钟周期结果不落盘 (日线语义缓存)
if results and timeframe == "1d":
try:
strategy_cache.write_cache(data_dir, str(as_of), results)
except Exception: # noqa: BLE001
+62 -22
View File
@@ -386,6 +386,21 @@ def _realtime_allowed() -> bool:
return QuoteService.is_realtime_allowed()
def _minute_history_days() -> int | None:
"""当前分钟源的 1 分钟历史深度(交易日); None = 深历史(tickflow 基准)。
provider 可选类属性 minute_history_days 声明 (如 stock-sdk = 5,
免费分时接口只保留最近 5 个交易日); 未声明或走 tickflow 时视为深历史。
前端分时档位/默认值据此收窄。
"""
from app.services import kline_sync, preferences
provider_name = preferences.get_minute_data_provider()
provider, fallback, _err = kline_sync._resolve_minute_provider(provider_name)
if fallback or provider is None:
return None
return getattr(provider, "minute_history_days", None)
class MinuteSyncPrefs(BaseModel):
minute_sync_enabled: bool
minute_sync_days: int = 5
@@ -397,6 +412,7 @@ class DataProvidersIn(BaseModel):
daily_data_provider: str | None = None
adj_factor_provider: str | None = None
minute_data_provider: str | None = None
depth5_data_provider: str | None = None
realtime_data_provider: str | None = None
financial_data_provider: str | None = None
@@ -478,14 +494,17 @@ def get_preferences() -> dict:
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
"minute_sync_days": preferences.get_minute_sync_days(),
"minute_sync_segment_days": preferences.get_minute_sync_segment_days(),
"minute_refresh_enabled": preferences.get_minute_refresh_enabled(),
"minute_refresh_interval": preferences.get_minute_refresh_interval(),
"daily_data_provider": preferences.get_daily_data_provider(),
"adj_factor_provider": preferences.get_adj_factor_provider(),
"minute_data_provider": preferences.get_minute_data_provider(),
"minute_history_days": _minute_history_days(),
"depth5_data_provider": preferences.get_depth5_data_provider(),
"realtime_data_provider": preferences.get_realtime_data_provider(),
"financial_data_provider": preferences.get_financial_provider(),
"data_source_job_timeout_s": preferences.get_data_source_job_timeout_s(),
"data_source_long_job_timeout_s": preferences.get_data_source_long_job_timeout_s(),
"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(),
@@ -541,6 +560,31 @@ def list_data_sources() -> dict:
}
@router.get("/capability-matrix")
def get_capability_matrix() -> dict:
"""能力 x 源路由矩阵: 能力注册表 + 各源能力声明 + 当前路由偏好, 设置页一次拉全。
偏好值经 preferences getters 注入 (自带合法源校验, 非法值回退默认),
TickFlow 当前档位由 tickflow policy 注入 (候选按档位过滤),
组装逻辑在 data_providers.capabilities, 本层保持薄。
"""
from app.data_providers.capabilities import build_capability_matrix
from app.services import preferences
from app.tickflow import policy
return build_capability_matrix(
{
"realtime_data_provider": preferences.get_realtime_data_provider(),
"daily_data_provider": preferences.get_daily_data_provider(),
"minute_data_provider": preferences.get_minute_data_provider(),
"depth5_data_provider": preferences.get_depth5_data_provider(),
"adj_factor_provider": preferences.get_adj_factor_provider(),
"financial_data_provider": preferences.get_financial_provider(),
},
tickflow_tier=policy.base_tier_name(),
)
@router.post("/plugin-key")
def save_plugin_key(req: PluginKeyIn) -> dict:
"""保存插件 API Key(先探后存, 对齐 /tickflow-key 语义)。
@@ -687,9 +731,8 @@ def delete_data_source(name: str, request: Request) -> dict:
updates["realtime_data_provider"] = "tickflow"
if preferences.get_financial_provider() == name:
updates["financial_data_provider"] = "tickflow"
adj = preferences.get_adj_factor_provider()
if adj == name:
updates["adj_factor_provider"] = "same_as_daily"
if preferences.get_adj_factor_provider() == name:
updates["adj_factor_provider"] = "tickflow"
if updates:
preferences.save(updates)
# 删除源可能触发偏好回退 tickflow, 同步刷新能力快照
@@ -735,6 +778,7 @@ def update_data_providers(req: DataProvidersIn, request: Request) -> dict:
"daily_data_provider": preferences.get_daily_data_provider(),
"adj_factor_provider": preferences.get_adj_factor_provider(),
"minute_data_provider": preferences.get_minute_data_provider(),
"depth5_data_provider": preferences.get_depth5_data_provider(),
"realtime_data_provider": preferences.get_realtime_data_provider(),
"financial_data_provider": preferences.get_financial_provider(),
}
@@ -841,6 +885,15 @@ def update_minute_sync(req: MinuteSyncPrefs) -> dict:
}
@router.get("/minute-refresh/status")
def minute_refresh_status(request: Request) -> dict:
"""盘中分钟增量刷新服务状态 (开关/能力门控/最近一轮/下一轮)。"""
svc = getattr(request.app.state, "minute_refresh", None)
if svc is None:
return {"available": False}
return {"available": True, **svc.status()}
class RealtimeQuotesPrefs(BaseModel):
realtime_quotes_enabled: bool
@@ -857,8 +910,8 @@ class RealtimeQuoteScopePrefs(BaseModel):
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
"""保存全局实时行情开关。
none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。
前端据此把开关置灰 / 回弹。
无实时能力的档位(TickFlow none/free)开关回弹强制关闭;
starter+ 或自定义实时源(如 fuyao)为全市场实时。前端据此把开关置灰 / 回弹。
"""
from app.services import preferences
qs = getattr(request.app.state, "quote_service", None)
@@ -912,10 +965,6 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
+ f"(任务 {job_id}"
)
raise HTTPException(status_code=409, detail=detail)
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})
_sync_depth_polling(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:
@@ -936,18 +985,6 @@ def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict:
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
@@ -981,6 +1018,9 @@ class RealtimeMonitorConfigIn(BaseModel):
screener_auto_run: bool | None = None
minute_intraday_refresh: bool | None = None
minute_intraday_refresh_interval: int | None = None
# 盘中分钟增量落盘 (Expert 专有) — 交易时段常驻服务, 归实时监控配置
minute_refresh_enabled: bool | None = None
minute_refresh_interval: int | None = None
monitor_ext_fields: dict | None = None
+10 -2
View File
@@ -813,6 +813,14 @@ class BacktestEngine:
matrix, raw_candidates, config, progress_cb, cancel_event,
)
@staticmethod
def _resolve_entry_prices(matrix: "MarketMatrix", config: "MatcherConfig") -> np.ndarray:
"""入场价矩阵: 分钟策略的逐格覆盖有限值处优先, 否则按 open/close 惯例。"""
base = matrix.open if config.entry_fill == "open_t+1" else matrix.close
if matrix.entry_price is None:
return base
return np.where(np.isfinite(matrix.entry_price), matrix.entry_price, base)
def _simulate_independent_matrix(
self,
matrix: MarketMatrix,
@@ -823,7 +831,7 @@ class BacktestEngine:
options: SimulationOptions | None = None,
) -> SimResult:
options = options or SimulationOptions()
entry_prices = matrix.open if config.entry_fill == "open_t+1" else matrix.close
entry_prices = self._resolve_entry_prices(matrix, config)
exit_prices = matrix.open if config.exit_fill == "open_t+1" else matrix.close
buy_cost_pct = config.buy_cost_pct()
sell_cost_pct = config.sell_cost_pct()
@@ -1722,7 +1730,7 @@ class BacktestEngine:
) -> SimResult:
options = options or SimulationOptions()
time_count, asset_count = matrix.shape
entry_prices = matrix.open if config.entry_fill == "open_t+1" else matrix.close
entry_prices = self._resolve_entry_prices(matrix, config)
exit_prices = matrix.open if config.exit_fill == "open_t+1" else matrix.close
buy_cost_pct = config.buy_cost_pct()
sell_cost_pct = config.sell_cost_pct()
+11
View File
@@ -551,6 +551,9 @@ class MarketMatrix:
exit_signal_code: np.ndarray
entry_signal_ids: tuple[str, ...]
exit_signal_ids: tuple[str, ...]
# 逐格入场价覆盖 (time x asset, NaN=回退 open/close 惯例)。分钟策略回测用:
# 信号在盘中第 m 根触发, 入场价 = 触发分钟收盘价, 而非当日开盘/收盘。
entry_price: np.ndarray | None = None
@property
def shape(self) -> tuple[int, int]:
@@ -2302,11 +2305,14 @@ def build_market_matrix_from_signals(
exit_delay_bars: int = 0,
reference_price: np.ndarray | None = None,
minute_exit_trigger: bool = False,
entry_price_override: np.ndarray | None = None,
) -> MarketMatrix:
"""Combine base data and strategy signals into the matcher input matrix."""
if entry_delay_bars not in (0, 1) or exit_delay_bars not in (0, 1):
raise ValueError("phase-two MarketMatrix supports only zero or one bar delay")
validate_signal_matrix(signals, market.shape)
if entry_price_override is not None and entry_price_override.shape != market.shape:
raise ValueError("entry_price_override shape does not match MarketDataMatrix")
present = _present_matrix(market.open, market.high, market.low, market.close, market.volume)
entry, entry_signal_time, entry_signal_code = _delay_signal_matrix(
@@ -2379,6 +2385,11 @@ def build_market_matrix_from_signals(
exit_signal_code=exit_signal_code,
entry_signal_ids=signals.entry_signal_ids,
exit_signal_ids=signals.exit_signal_ids,
entry_price=(
np.array(entry_price_override, dtype=np.float32, copy=True)
if entry_price_override is not None
else None
),
)
+286
View File
@@ -0,0 +1,286 @@
"""分钟策略回测回放器 — 逐交易日回放 filter_minute_history 产生入场信号。
与实盘选股 (ScreenerService 1m context) 走同一条 StrategyEngine.run 执行路径,
消除回测/实盘偏差语义铁律:
- 分钟侧: 传入当日全量分钟分区, 策略函数自身因果 ( m 根只用 <=m 的K线);
- 日线侧: T 日的日线条件窗口只含 T-1 及更早的完成态日K 与实盘盘中行为一致
(当日成形K不进窗口), 杜绝未来函数;
- 按交易日精确对日: 缺分钟分区的日子显式跳过, 不做"回退最近分区" (那是实盘语义)
"""
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import date, timedelta
import polars as pl
from app.price_limits import is_risk_warning_name, price_limit_pct
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine
# 日线面板列: 基础行情 + 涨停/炸板信号 (策略日线窗口契约) + 基础过滤/展示列。
# raw_close 用于涨停价计算 (分钟价是未复权真实价, 涨停规则定义在原始价上)。
MINUTE_DAILY_PANEL_COLUMNS = frozenset({
"open", "high", "low", "close", "volume", "amount",
"raw_close", "raw_high", "raw_low",
"turnover_rate",
"signal_limit_up", "signal_limit_down", "signal_broken_limit_up",
})
MINUTE_INSTRUMENT_COLUMNS = frozenset({"name", "total_shares", "float_shares"})
def minute_replay_feature_plan(daily_bars: int):
"""分钟回测的日线面板加载计划。
execution_backend polars_expr "按需计算信号"路径: enriched 分区只落
基础列, 涨停/炸板信号由 load_panel_for_backtest compute_limit_signals
signal_columns 需求现算 (matrix_native 路径会跳过通用信号计算)
"""
# 函数级导入规避与 strategy.py 的循环依赖 (strategy 顶层导入本模块)。
from app.backtest.strategy import ResolvedFeaturePlan
return ResolvedFeaturePlan(
base_columns=MINUTE_DAILY_PANEL_COLUMNS,
intermediate_columns=frozenset(),
indicator_columns=frozenset(),
signal_columns=frozenset({
"signal_limit_up", "signal_limit_down", "signal_broken_limit_up",
}),
matrix_columns=frozenset(),
instrument_columns=MINUTE_INSTRUMENT_COLUMNS,
warmup_bars=max(daily_bars, 1),
full_feature_fallback=False,
execution_backend="polars_expr",
)
def minute_panel_start(start: date, daily_bars: int) -> date:
"""日线面板加载起点: 覆盖首个回测日的 daily_bars 交易日窗口。
N 个交易日约需 N*2 自然日 (周末/节假日), 再留 warmup 余量
"""
calendar_days = max(daily_bars, 1) * 2 + 30
return start - timedelta(days=calendar_days)
def _trigger_hhmm(value) -> str:
"""从 last_datetime 提取北京时间 "HH:MM" 触发分钟。
分区 datetime UTC 存储 (tz-aware naive-UTC), 统一折算到北京时区
"""
from app.market_time import CN_TZ
if hasattr(value, "astimezone"):
if value.tzinfo is None:
from datetime import timezone
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(CN_TZ).strftime("%H:%M")
text = str(value or "")
if len(text) >= 16 and text[13] == ":":
return text[11:16]
return text[-5:] if text else ""
def _scalar_limit_up_price(prev_close: float, limit_pct: float) -> float:
"""与 polars_limit_price 同口径的标量涨停价 (整数分半进位)。"""
cents = int(prev_close * 100 + 0.5)
numerator = round((1 + limit_pct) * 100)
return ((cents * numerator + 50) // 100) / 100
@dataclass
class MinuteReplayHit:
"""一个盘中入场信号: 触发分钟收盘买入。"""
trade_date: date
symbol: str
# 已按当日 复权close/原始close 比例折算到复权价系的入场价, 与日线出场价同尺度。
entry_price: float
trigger_time: str # "HH:MM" — 触发分钟K的时间戳
score: float = 0.0
@dataclass
class MinuteReplayResult:
hits: list[MinuteReplayHit] = field(default_factory=list)
skipped_days: list[date] = field(default_factory=list)
replayed_days: int = 0
strategy_matches: int = 0
buy_limit_up: int = 0
elapsed_ms: float = 0.0
class MinuteSignalReplayer:
"""逐交易日回放分钟策略, 产出与实盘选股同源的入场命中。"""
def __init__(self, engine, strategy_engine: StrategyEngine) -> None:
# engine: BacktestEngine — 只用其 repo (分钟分区读取)。
self.engine = engine
self.strategy_engine = strategy_engine
def replay(
self,
strategy: StrategyDef,
*,
panel: pl.DataFrame,
start: date,
end: date,
params: dict,
overrides: dict,
pool: list[str] | None = None,
symbols: list[str] | None = None,
progress_cb: Callable[[dict], None] | None = None,
cancel_event=None,
) -> MinuteReplayResult:
t0 = time.perf_counter()
result = MinuteReplayResult()
repo = self.engine.repo
if panel.is_empty():
return result
universe = symbols if symbols else panel.get_column("symbol").unique().to_list()
daily_bars = int(strategy.minute_daily_bars or 0)
# 面板交易日序列 (升序) — 日线窗口切片与缺分区日判定的基准。
panel_dates = panel.get_column("date").unique().sort().to_list()
date_to_window: dict[date, tuple[date, date]] = {}
for i, day in enumerate(panel_dates):
window_start = panel_dates[max(0, i - daily_bars)] if daily_bars > 0 else day
date_to_window[day] = (window_start, day)
# 逐分区日回放: 只回放 [start, end] 内有分钟分区的交易日。
minute_days = repo.list_minute_dates(start, end, "stock")
minute_day_set = set(minute_days)
replay_days = [day for day in panel_dates if start <= day <= end]
result.skipped_days = [day for day in replay_days if day not in minute_day_set]
total = len(minute_days)
# 逐标的的 T-1 原始收盘/复权收盘查表 (涨停价与复权折算用)。
prev_raw_close: dict[str, float] = {}
prev_name: dict[str, str] = {}
adj_factor: dict[str, float] = {}
for i, day in enumerate(minute_days):
if cancel_event is not None and cancel_event.is_set():
break
if progress_cb is not None:
progress_cb({
"day": i + 1,
"total": max(total, 1),
"date": str(day),
})
history = repo.get_minute_by_dates(universe, [day], "stock")
if history.is_empty():
result.skipped_days.append(day)
continue
# 日线窗口: 截至 T-1 的完成态日K (index of last panel date < day)。
prior = [d for d in panel_dates if d < day]
if not prior:
# 面板起点之前的分区日 (窗口数据不足), 策略按数据不足自然不命中。
daily_history = pl.DataFrame()
current = pl.DataFrame()
else:
last_prior = prior[-1]
window_start, _ = date_to_window[last_prior]
daily_history = panel.filter(
(pl.col("date") >= window_start) & (pl.col("date") <= last_prior)
) if daily_bars > 0 else pl.DataFrame()
current = panel.filter(pl.col("date") == last_prior)
# T-1 收盘/名称 + T 日复权因子查表。
_refresh_day_lookups(prev_raw_close, prev_name, current, prior)
day_rows = panel.filter(pl.col("date") == day).select(
"symbol", "close", "raw_close",
)
adj_factor.clear()
adj_factor.update(_adj_factors(day_rows))
context = StrategyDataContext(
asset_type="stock",
timeframe="1m",
as_of=day,
current=current if not current.is_empty() else None,
history=history,
daily_history=daily_history if not daily_history.is_empty() else None,
)
try:
run_result = self.strategy_engine.run(
strategy.meta.get("id", ""),
context,
pool,
params,
overrides,
)
except ValueError:
# 单日执行失败 (如窗口缺列) 记为跳过, 不中断整个回放。
result.skipped_days.append(day)
continue
result.replayed_days += 1
result.strategy_matches += len(run_result.rows)
for row in run_result.rows:
symbol = row.get("symbol")
close = row.get("close")
if not symbol or close is None or float(close) <= 0:
continue
raw_close = float(close)
name = prev_name.get(str(symbol), "")
prev = prev_raw_close.get(str(symbol))
# 涨停拒买: 触发分钟收盘已达当日涨停价 (按 T-1 原始收盘 + 板块规则)。
if prev is not None and prev > 0:
limit_up = _scalar_limit_up_price(
prev, price_limit_pct(str(symbol), day, is_risk_warning=is_risk_warning_name(name)),
)
if raw_close >= limit_up - 1e-9:
result.buy_limit_up += 1
continue
trigger = row.get("last_datetime")
trigger_time = _trigger_hhmm(trigger)
result.hits.append(MinuteReplayHit(
trade_date=day,
symbol=str(symbol),
entry_price=raw_close * adj_factor.get(str(symbol), 1.0),
trigger_time=trigger_time,
score=float(run_result.scores.get(str(symbol), 0.0) or 0.0),
))
result.elapsed_ms = round((time.perf_counter() - t0) * 1000, 1)
return result
def _refresh_day_lookups(
prev_raw_close: dict[str, float],
prev_name: dict[str, str],
prior_snapshot: pl.DataFrame,
prior: list[date],
) -> None:
"""从 T-1 快照刷新逐标的原始收盘与名称查表 (涨停价/ST 判定用)。"""
if prior_snapshot.is_empty():
return
frame = prior_snapshot
if "raw_close" not in frame.columns:
frame = frame.with_columns(pl.col("close").alias("raw_close"))
if "name" not in frame.columns:
frame = frame.with_columns(pl.lit("").alias("name"))
prev_raw_close.clear()
prev_name.clear()
for symbol, raw_close, name in frame.select("symbol", "raw_close", "name").iter_rows():
prev_raw_close[str(symbol)] = float(raw_close) if raw_close is not None else 0.0
prev_name[str(symbol)] = str(name or "")
def _adj_factors(day_rows: pl.DataFrame) -> dict[str, float]:
"""T 日 复权close/原始close 比例: 把分钟原始价折算到复权价系。"""
factors: dict[str, float] = {}
if day_rows.is_empty() or "raw_close" not in day_rows.columns:
return factors
for symbol, close, raw_close in day_rows.select("symbol", "close", "raw_close").iter_rows():
if close and raw_close and float(raw_close) > 0:
factors[str(symbol)] = float(close) / float(raw_close)
return factors
+322 -1
View File
@@ -28,13 +28,20 @@ from app.backtest.matrix import (
MatrixPipelineConfig,
MatrixPrewarmCancelledError,
MatrixStrategyPipeline,
SignalMatrix,
apply_time_masks,
build_market_data_matrix,
build_market_matrix,
build_market_matrix_from_signals,
rolling_mean,
slice_market_data_matrix,
slice_signal_matrix,
)
from app.backtest.minute_replay import (
MinuteSignalReplayer,
minute_panel_start,
minute_replay_feature_plan,
)
from app.backtest.minute_trigger import unsupported_minute_exit_signals
from app.config import settings
from app.indicators.pipeline import (
@@ -995,7 +1002,7 @@ class StrategyBacktestService:
s,
StrategyDataContext(
asset_type=config.asset_type,
timeframe="1d",
timeframe="1m" if s.execution_backend == "minute_filter" else "1d",
as_of=config.end,
),
)
@@ -1044,6 +1051,26 @@ class StrategyBacktestService:
overrides.get("score_max"),
)
if s.execution_backend == "minute_filter":
# 分钟策略回测: 逐交易日回放 filter_minute_history (与实盘选股同源),
# 信号分钟收盘价入场, 之后复用日K矩阵模拟的离场与组合管理。
return self._run_minute_backtest(
config, s, params, overrides,
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,
score_min=score_min,
score_max=score_max,
progress_cb=progress_cb,
cancel_event=cancel_event,
result_policy=result_policy,
run_id=run_id,
t0=t0,
)
try:
if s.execution_backend == "composite":
# composite 回测: 子策略必须全为 matrix_native(否则 fail-closed),
@@ -1629,6 +1656,300 @@ class StrategyBacktestService:
elapsed_ms=round(elapsed, 1),
)
# ── 分钟策略回测: 逐日回放入场 + 日K矩阵离场 ──
def _run_minute_backtest(
self,
config: StrategyBacktestConfig,
s: StrategyDef,
params: dict,
overrides: dict,
*,
stop_loss,
take_profit,
trailing_stop,
trailing_take_profit_activate,
trailing_take_profit_drawdown,
max_hold_days,
score_min,
score_max,
progress_cb,
cancel_event,
result_policy: BacktestResultPolicy,
run_id: str,
t0: float,
) -> StrategyBacktestResult:
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,
)
if config.asset_type != "stock":
return _err("分钟策略回测当前仅支持 A 股 (stock)")
if config.exit_fill == "signal_next_minute":
return _err("分钟策略回测暂不支持「信号触发卖出」离场口径")
minute_days = self.engine.repo.list_minute_dates(config.start, config.end, "stock")
if not minute_days:
earliest = self.engine.repo.earliest_minute_date()
hint = f"本地分钟K最早到 {earliest}, " if earliest else "本地无分钟K数据, "
return _err(
f"回测区间内无分钟K数据: {hint}请先用「扩展分钟K历史」拉取, 或开启盘中分钟增量"
)
# 日线面板一次加载: 覆盖首个回测日的日线窗口 + 模拟区间 (含 full 模式尾部)。
daily_bars = int(s.minute_daily_bars or 0)
feature_plan = minute_replay_feature_plan(daily_bars)
load_start = minute_panel_start(config.start, daily_bars)
full_horizon_days = int(max_hold_days or config.holding_days or 5)
load_end = config.end
if config.mode == "full":
load_end = config.end + timedelta(days=(full_horizon_days + 5) * 2)
sim_end = load_end if config.mode == "full" else config.end
timing_ms: dict[str, float] = {}
t_load = time.perf_counter()
try:
panel = self.engine.load_panel_for_backtest(
config.symbols,
load_start,
load_end,
feature_plan,
asset_type="stock",
)
except (ValueError, OSError, pl.exceptions.PolarsError) as e:
return _err(f"回测特征准备失败: {e}")
timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1)
if panel.is_empty():
return _err("无日线数据, 请检查日期范围或先运行盘后管道")
replayer = MinuteSignalReplayer(self.engine, self.strategy_engine)
replay = replayer.replay(
s,
panel=panel,
start=config.start,
end=config.end,
params=params,
overrides=overrides,
symbols=config.symbols,
progress_cb=progress_cb,
cancel_event=cancel_event,
)
timing_ms["minute_replay"] = replay.elapsed_ms
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 not replay.hits:
skipped_hint = (
f" (区间内 {len(replay.skipped_days)} 个交易日缺分钟K分区被跳过)"
if replay.skipped_days else ""
)
return _err("在指定区间内未产生买入信号" + skipped_hint)
# 日频信号网格: 正式区间面板 → time x asset 矩阵, 命中格写入入场价覆盖。
sim_panel = panel.filter(
(pl.col("date") >= config.start) & (pl.col("date") <= sim_end)
)
if sim_panel.is_empty():
return _err("正式回测区间内无数据")
axis_dates = sim_panel.get_column("date").unique().sort().to_list()
# 轴顺序必须与 build_market_data_matrix 的 _encode_axes 一致 (unique().sort()),
# 否则 (time, asset) 下标指向错误的标的。
axis_symbols = sim_panel.get_column("symbol").cast(pl.Utf8).unique().sort().to_list()
time_index = {day: i for i, day in enumerate(axis_dates)}
asset_index = {sym: i for i, sym in enumerate(axis_symbols)}
shape = (len(axis_dates), len(axis_symbols))
entry = np.zeros(shape, dtype=np.uint8)
score = np.zeros(shape, dtype=np.float32)
entry_price_override = np.full(shape, np.nan, dtype=np.float32)
trigger_times: dict[tuple[str, date], str] = {}
dropped_axis_hits = 0
for hit in replay.hits:
time_id = time_index.get(hit.trade_date)
asset_id = asset_index.get(hit.symbol)
if time_id is None or asset_id is None:
dropped_axis_hits += 1
continue
entry[time_id, asset_id] = 1
score[time_id, asset_id] = hit.score
entry_price_override[time_id, asset_id] = hit.entry_price
trigger_times[(hit.symbol, hit.trade_date)] = hit.trigger_time
raw_candidates = int(entry.sum())
entry.setflags(write=False)
score.setflags(write=False)
entry_price_override.setflags(write=False)
exit_mask = np.zeros(shape, dtype=np.uint8)
exit_mask.setflags(write=False)
codes = np.zeros(shape, dtype=np.int16)
codes.setflags(write=False)
signals = SignalMatrix(
entry=entry,
exit=exit_mask,
score=score,
entry_signal_code=codes,
exit_signal_code=codes,
entry_signal_ids=(),
exit_signal_ids=(),
)
matcher_config = MatcherConfig(
matching=config.matching,
entry_fill="close_t",
exit_fill=config.exit_fill,
fees_pct=config.fees_pct,
commission_pct=config.commission_pct,
stamp_tax_pct=config.stamp_tax_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,
# 分钟策略的成交价由 entry_price_override 提供 (触发分钟收盘),
# 不再叠加日线口径的分钟成交细化。
minute_fill=False,
)
t_matrix = time.perf_counter()
market_data = build_market_data_matrix(sim_panel)
market_matrix = build_market_matrix_from_signals(
market_data,
signals,
# 入场即信号日盘中 (分钟价覆盖), 离场沿用日K口径。
entry_delay_bars=0,
exit_delay_bars=1 if matcher_config.exit_fill == "open_t+1" else 0,
entry_price_override=entry_price_override,
)
timing_ms["matrix_build"] = round((time.perf_counter() - t_matrix) * 1000, 1)
del sim_panel, market_data
t_sim = time.perf_counter()
if config.mode == "full":
result = self.engine.simulate_independent_market_matrix(
market_matrix,
raw_candidates,
matcher_config,
progress_cb,
cancel_event,
result_policy.simulation_options(),
)
else:
result = self.engine.simulate_market_matrix(
market_matrix,
matcher_config,
progress_cb,
cancel_event,
result_policy.simulation_options(),
)
timing_ms["simulate"] = round((time.perf_counter() - t_sim) * 1000, 1)
timing_ms["statistics"] = float(result.stats.pop("statistics_ms", 0.0))
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"])
execution = result.stats.get("execution") or {}
execution["buy_limit_up"] = int(execution.get("buy_limit_up", 0)) + replay.buy_limit_up
result.stats["execution"] = execution
timing_ms["total"] = round((time.perf_counter() - t0) * 1000, 1)
result.stats["timing_ms"] = timing_ms
result.stats["panel_rows"] = int(len(axis_dates) * len(axis_symbols))
result.stats["panel_columns"] = 0
result.stats["feature_columns"] = 0
result.stats["execution_backend"] = s.execution_backend
result.stats["selection"] = {
"strategy_matches": replay.strategy_matches,
"entry_candidates": raw_candidates,
"entry_trigger_filtered": max(replay.strategy_matches - raw_candidates, 0),
"entry_trigger_enabled": False,
}
result.stats["minute_replay"] = {
"replayed_days": replay.replayed_days,
"skipped_days": [str(day) for day in replay.skipped_days[:50]],
"skipped_day_count": len(replay.skipped_days),
"dropped_axis_hits": dropped_axis_hits,
}
benchmark_curve = (
self._build_benchmark_curve(config.start, config.end)
if result_policy.include_benchmark
else []
)
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": [],
"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,
"execution_backend": s.execution_backend,
} if result_policy.include_strategy_info else {}
trades = (
[self._trade_to_dict(t) for t in result.trades]
if result_policy.include_trades
else []
)
# 入场时间戳补分钟: 交易记录携带触发分钟 (HH:MM), 与日线回测的纯日期区分。
for trade in trades:
entry_text = str(trade.get("entry_date") or "")
try:
key = (str(trade.get("symbol")), date.fromisoformat(entry_text[:10]))
except ValueError:
continue
trigger = trigger_times.get(key)
if trigger:
trade["entry_date"] = f"{entry_text[:10]} {trigger}"
selected_stats = result_policy.select_stats(result.stats)
elapsed = (time.perf_counter() - t0) * 1000
return StrategyBacktestResult(
run_id=run_id,
config=self._config_to_dict(config),
stats=selected_stats,
equity_curve=result.equity_curve if result_policy.include_curves else [],
drawdown_curve=result.drawdown_curve if result_policy.include_curves else [],
benchmark_curve=benchmark_curve,
trades=trades,
per_symbol_stats=(
result.per_symbol_stats
if result_policy.include_per_symbol_stats
else []
),
strategy_info=strategy_info,
elapsed_ms=round(elapsed, 1),
)
# ── 全量模拟 (选股能力统计, 不建组合不算净值) ──
def _run_full_simulation(
+19 -3
View File
@@ -257,9 +257,10 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None:
if store is not None:
with suppress(Exception):
store.db.close()
# 终态消息已入队: 显式冲刷队列后立即退出。大数据量任务跳过解释器
# teardown (GC、DuckDB 线程 join、DLL 卸载), 否则收尾可达数十秒,
# 会撞上父进程 10s 退出预算。close+join_thread 保证消息完整落管。
# 终态消息已入队: 显式冲刷队列后立即退出。put 只是入队, 实际写管道的
# 是后台 feeder 线程, close+join_thread 保证消息完整落管 (否则父进程误判
# "exited without result"); 大数据量任务再跳过解释器 teardown (GC、DuckDB
# 线程 join、DLL 卸载), 否则收尾可达数十秒, 撞上父进程 10s 退出预算。
with suppress(Exception):
event_queue.close()
event_queue.join_thread()
@@ -320,6 +321,21 @@ def run_worker_task(
elif message_type == "error":
failure = message
# 子进程退出后, 队列读线程可能尚未把管道尾部的 result/error 搬进本地缓冲
# (0.1s 轮询在系统高负载下会先看到 Empty+进程已死)。join 后做一次兜底排空,
# 只要消息完整刷入过管道就一定能取到。
if result is None and failure is None:
for _ in range(2):
try:
message = events.get(timeout=1.0)
except queue.Empty:
break
message_type = message.get("type")
if message_type == "result":
result = message["payload"]
elif message_type == "error":
failure = message
process.join(timeout=10.0)
worker_exit_forcibly = False
if process.is_alive():
+201
View File
@@ -0,0 +1,201 @@
"""能力注册表与能力路由矩阵 — 数据集维度的单一权威定义。
能力 (capability) = 一个标准化数据集 (CONTRIBUTING数据源插件化要求):
daily / adj_factor / realtime / minute / depth5 / financial (注册表顺序即设置页卡片顺序)注册表集中声明每个
能力的展示元数据路由偏好字段与 TickFlow 档位要求, 前端设置页不再各自硬编码
depth5 目前仅 TickFlow (插件数据集白名单未开放, loader), 仍进矩阵是为了
可用性门控诚实: 五档不可用时连板梯队封单/看板封单缺数据应有提示
build_capability_matrix 把注册表插件/自定义源的能力声明 (datasets) 和当前
路由偏好合并为一个矩阵, 供设置页一次拉全当前偏好由 API 层注入
(preferences getters 自带合法源校验), 本模块不反向依赖 services
候选契约: 每个能力的 candidates 只包含当前确实可提供该能力的源
TickFlow 按当前订阅档位过滤 (日K全档位, 其余按注册表 tf_tier 门槛),
未就绪的插件/自定义源 (依赖未装/Key 未配) 放入 pending 并携带原因,
供前端置灰提示其他页面可以把 candidates 直接当作可用提供方名单
usable 契约: 每个能力额外给出 usable = 生效源当前能否真正提供该能力
(生效源在 candidates )各页面的能力门控 (缺能力提示 数据源配置)
统一以 usable 为准, 而不是 TickFlow 套餐视角 路由到可用插件时同样可用,
路由到 TickFlow 但档位不足时同样不可用
"""
from __future__ import annotations
from app.data_providers import custom as custom_sources
CAPABILITY_REGISTRY: list[dict] = [
{
"id": "daily",
"label": "日K",
"desc": "历史K线与实时覆写",
"field": "daily_data_provider",
"default": "tickflow",
"tf_tier": "none",
},
{
"id": "adj_factor",
"label": "除权因子",
"desc": "前复权计算基准",
"field": "adj_factor_provider",
"default": "tickflow",
"tf_tier": "starter",
# 独立路由 (曾经的「跟随日K」特殊值已下线: 每个能力单独配置,
# 复权口径一致性改由未来的一致性警示保障, 不做路由耦合)
},
{
"id": "realtime",
"label": "实时行情",
"desc": "全市场实时快照",
"field": "realtime_data_provider",
"default": "tickflow",
"tf_tier": "starter",
},
{
"id": "minute",
"label": "分钟K",
"desc": "分时图与分钟回测",
"field": "minute_data_provider",
"default": "tickflow",
"tf_tier": "pro",
},
{
"id": "depth5",
"label": "五档盘口",
"desc": "连板梯队封单与盘口深度",
"field": "depth5_data_provider",
"default": "tickflow",
"tf_tier": "pro",
# 插件契约暂未开放 depth5 数据集 (loader 白名单), 当前仅 TickFlow 供
},
{
"id": "financial",
"label": "财务数据",
"desc": "财务指标与三大报表",
"field": "financial_data_provider",
"default": "tickflow",
"tf_tier": "expert",
},
{
"id": "full_minute",
"label": "全量分钟",
"desc": "盘中全市场当日分钟落盘 (冷启动全天 + 标的池增量)",
"field": None,
"default": "tickflow",
"tf_tier": "expert",
# intraday.universe 能力 (TickFlow Expert 专有): 插件契约不开放此数据集,
# 生效源恒为 TickFlow — 不可路由, 无对应 provider 偏好字段
},
]
_TICKFLOW_CANDIDATE = {
"name": "tickflow",
"display": "TickFlow",
"kind": "builtin",
"available": True,
"status": "ok",
"note": None,
}
# 档位排序: none 最低 (无 Key/无效 Key, 仅免费通道历史日K), 未知档按 none 处理 (fail-closed)
_TIER_RANK = {"none": -1, "free": 0, "starter": 1, "pro": 2, "expert": 3}
def _tier_base(tier: str) -> str:
"""归一化档位输入为基础名: "Pro +" -> "pro"; 空值归为 none。"""
text = str(tier or "").strip().lower()
if not text:
return "none"
return text.split()[0].split("+")[0]
def _declared_sources() -> list[dict]:
"""插件 + 自定义源 → 统一能力声明视图。未注册 (hidden/加载失败) 的源不会出现。"""
rows: list[dict] = []
for plugin in custom_sources.list_plugins():
rows.append({
"name": plugin["name"],
"display": plugin.get("display_name") or plugin["name"],
"datasets": set(plugin.get("datasets") or []),
"available": bool(plugin.get("available")),
"status": str(plugin.get("status") or ""),
"kind": "plugin",
})
for source in custom_sources.list_sources():
rows.append({
"name": source["name"],
"display": source.get("display_name") or source["name"],
"datasets": set(source.get("datasets") or []),
# 自定义源注册即已通过加载校验, 视为可用
"available": True,
"status": "ok",
"kind": "custom",
})
return rows
def _display_of(sources: list[dict], name: str) -> str:
if name == "tickflow":
return "TickFlow"
for s in sources:
if s["name"] == name:
return s["display"]
return name
def build_capability_matrix(current: dict[str, str], tickflow_tier: str = "none") -> dict:
"""注册表 + 源能力声明 + 当前偏好 → 能力路由矩阵。
current {偏好字段: 当前值}, API 层经 preferences getters 注入;
getters 已把非法值 (未注册源) 回退为默认, 这里直接信任effective
即当前值本身 (每个能力独立路由, 无跟随/派生特殊值)
tickflow_tier TickFlow 当前档位基础名 (none/free/starter/pro/expert),
API 层从 tickflow policy 注入当前档位不提供的能力里 TickFlow
不进候选, 但偏好仍指向 tickflow 时以 tf_available=False 标记,
供前端提示档位不足未知档按 none 处理
"""
tier_base = _tier_base(tickflow_tier)
tier_rank = _TIER_RANK.get(tier_base, -1)
sources = _declared_sources()
capabilities = []
for cap in CAPABILITY_REGISTRY:
# field=None → 不可路由能力 (仅 TickFlow 提供, 无路由偏好), 生效源恒为默认
effective = current.get(cap["field"], cap["default"]) if cap["field"] else cap["default"]
tf_available = tier_rank >= _TIER_RANK[cap["tf_tier"]]
candidates: list[dict] = []
pending: list[dict] = []
if tf_available:
candidates.append(dict(_TICKFLOW_CANDIDATE))
for s in sources:
if cap["id"] not in s["datasets"]:
continue
entry = {
"name": s["name"],
"display": s["display"],
"kind": s["kind"],
"available": s["available"],
"status": s["status"],
"note": None if s["available"] else (s["status"] or "不可用"),
}
(candidates if s["available"] else pending).append(entry)
usable = any(c["name"] == effective for c in candidates)
capabilities.append({
"id": cap["id"],
"label": cap["label"],
"desc": cap["desc"],
"field": cap["field"],
"default": cap["default"],
"tf_tier": cap["tf_tier"],
"tf_available": tf_available,
"usable": usable,
"current": effective,
"current_display": _display_of(sources, effective),
"effective": effective,
"effective_display": _display_of(sources, effective),
"candidates": candidates,
"pending": pending,
})
return {"tickflow_tier": tier_base, "capabilities": capabilities}
@@ -37,6 +37,9 @@ class DatasetConfig:
end_param: str = "end_time"
asset_type_param: str | None = None
freq_param: str | None = None
# realtime 比例字段(change_pct/amplitude/turnover_rate)的单位声明:
# "percent"(返回 3.66 表示 3.66%)或 "decimal"(返回 0.0366 表示 3.66%)。
pct_unit: str | None = None
@dataclass(frozen=True)
@@ -75,6 +78,10 @@ def _dataset_from_dict(raw: dict[str, Any]) -> DatasetConfig:
if not 0 < timeout <= MAX_TIMEOUT:
raise ValueError(f"timeout must be between 0 and {MAX_TIMEOUT:g} seconds")
pct_unit = str(raw.get("pct_unit") or "").strip().lower() or None
if pct_unit not in (None, "percent", "decimal"):
raise ValueError(f"pct_unit must be 'percent' or 'decimal', got {pct_unit!r}")
return DatasetConfig(
url=str(raw.get("url", "") or ""),
method=str(raw.get("method", "GET") or "GET").upper(),
@@ -91,6 +98,7 @@ def _dataset_from_dict(raw: dict[str, Any]) -> DatasetConfig:
end_param=str(raw.get("end_param", "end_time") or "end_time").strip() or "end_time",
asset_type_param=(str(raw.get("asset_type_param") or "").strip() or None),
freq_param=(str(raw.get("freq_param") or "").strip() or None),
pct_unit=pct_unit,
)
@@ -11,6 +11,7 @@ from pathlib import Path
import yaml
from app import secrets_store
from app.config import settings
from app.data_providers.custom.config import (
DEFAULT_TIMEOUT,
@@ -89,6 +90,19 @@ def list_plugins() -> list[dict]:
return list(_PLUGIN_STATUS.values())
def _plugin_key_masked(name: str, api_key_env: str) -> str:
"""插件当前生效 Key 的脱敏串 (secrets.json 优先, .env 兜底), 未配置返回空。
TickFlow Key 的展示契约一致 (settings API tickflow_api_key_masked):
完整 Key 永不出后端, 只出 mask() 结果, 供设置页常驻显示
"""
env = str(api_key_env or "").strip()
if not env:
return ""
key = secrets_store.get_env_backed_secret(f"{name.lower()}_api_key", env)
return secrets_store.mask(key) if key else ""
def plugin_manifest(name: str) -> dict | None:
"""读取指定插件的 plugin.yaml 清单。"""
plugin_dir = plugins_dir() / (name or "")
@@ -353,6 +367,7 @@ def _config_to_dict(config: CustomSourceConfig) -> dict:
} if ds_name != "realtime" else {}),
**({"asset_type_param": ds.asset_type_param} if ds_name == "minute" and ds.asset_type_param else {}),
**({"freq_param": ds.freq_param} if ds_name == "minute" and ds.freq_param else {}),
**({"pct_unit": ds.pct_unit} if ds_name == "realtime" and ds.pct_unit else {}),
}
return out
@@ -476,6 +491,13 @@ def _sanitize_dataset(ds_name: str, ds_cfg: dict) -> dict:
out["start_param"] = start_param
if end_param:
out["end_param"] = end_param
pct_unit = str(ds_cfg.get("pct_unit") or "").strip().lower()
if pct_unit:
if ds_name != "realtime":
raise ValueError(f"{ds_name}: pct_unit 仅用于 realtime 数据集")
if pct_unit not in ("percent", "decimal"):
raise ValueError(f"{ds_name}: pct_unit 必须是 percent 或 decimal")
out["pct_unit"] = pct_unit
if ds_name == "minute":
asset_type_param = str(ds_cfg.get("asset_type_param") or "").strip()
freq_param = str(ds_cfg.get("freq_param") or "").strip()
@@ -552,7 +574,9 @@ def _register_one_plugin(manifest: dict) -> None:
"status": reason,
"description": manifest.get("description", ""),
"install_hint": manifest.get("install_hint", ""),
"homepage": manifest.get("homepage", ""),
"api_key_env": manifest.get("api_key_env", ""),
"api_key_masked": _plugin_key_masked(name, manifest.get("api_key_env", "")),
}
if not available:
return # 依赖没装: 不注册, 但状态已记录供 UI 显示
+47 -10
View File
@@ -34,29 +34,56 @@ _REQUIRED = {
"financial": {"symbol"},
}
# 小数制下 change_pct/amplitude/turnover_rate 的物理上限: A股最大涨跌停 30% (+容差)。
# 小数制下 change_pct 的物理上限: A股最大涨跌停 30% (+容差)。
# 中位数口径下小数制批次不可能超过该值, 百分制批次(典型中位数 0.5~3)必然超过。
# 仅对 change_pct 有效——amplitude/turnover_rate 的两种单位在数值区间上重叠
# (百分制 0.05 = 0.05% 与小数制 0.05 = 5%), 无物理依据可判。
_PCT_FRACTION_MAX = 0.31
_PCT_COLUMNS = ("change_pct", "amplitude", "turnover_rate")
def _normalize_pct_units(df: pl.DataFrame) -> pl.DataFrame:
"""百分制源自适应归一为小数制 (契约: change_pct/amplitude/turnover_rate 为小数,
0.0366 = 3.66%)不少第三方接口( a-stock-data)直接返回 3.66 表示 3.66%,
若不归一, 下游(行业/概念统计前端 x100 展示)会整体放大 100
截面判定: 样本 >= 5 || 中位数(对个别无涨跌幅限制新股免疫),
小样本退用最大值整批同除 100, 避免逐值阈值在 0.3~1 区间的歧义
def _normalize_pct_units(
df: pl.DataFrame,
pct_unit: str | None = None,
transformed_cols: frozenset[str] = frozenset(),
) -> pl.DataFrame:
"""比例字段单位归一为契约小数制 (change_pct/amplitude/turnover_rate,
0.0366 = 3.66%, CONTRIBUTING §3.1)单位只认显式声明, 不靠数值猜:
- pct_unit="percent" 三列无条件 /100 (声明即契约, 即使数值看着像小数制);
- pct_unit="decimal" 原样透传 (即使数值看着像百分制也不动);
- 未声明 change_pct 保留截面中位数判定(涨跌停 30% 上限使其物理可判:
样本 >= 5 || 中位数, 小样本退用最大值, 整批同除 100);
amplitude/turnover_rate None 交下游重算(enriched 管道按
high/low/prev_close 与股本口径重算), 除非该列已被 transforms 显式
处理过(视为用户已接管单位, 原样透传)
"""
for col in ("change_pct", "amplitude", "turnover_rate"):
dropped_undeclared = False
for col in _PCT_COLUMNS:
if col not in df.columns:
continue
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False).alias(col))
if pct_unit == "percent":
df = df.with_columns((pl.col(col) / 100).alias(col))
elif pct_unit == "decimal" or col in transformed_cols:
continue
elif col == "change_pct":
vals = df[col].drop_nulls().abs()
if vals.is_empty():
continue
stat = vals.median() if vals.len() >= 5 else vals.max()
if stat > _PCT_FRACTION_MAX:
df = df.with_columns((pl.col(col) / 100).alias(col))
else:
df = df.with_columns(pl.lit(None, dtype=pl.Float64).alias(col))
dropped_undeclared = True
if dropped_undeclared:
logger.warning(
"自定义源 realtime 未声明 pct_unit: amplitude/turnover_rate 的单位"
"无法从数值判定, 已置 None 交由下游按股本/价格口径重算;"
"请在 realtime 数据集配置中显式声明 pct_unit: percent 或 decimal"
)
return df
@@ -82,6 +109,11 @@ class GenericHTTPProvider:
missing = sorted(required - mapped)
if missing:
errors.append(f"{dataset}: missing mapped fields: {', '.join(missing)}")
if cfg.pct_unit is not None:
if dataset != "realtime":
errors.append(f"{dataset}: pct_unit 仅用于 realtime 数据集")
elif cfg.pct_unit not in ("percent", "decimal"):
errors.append(f"{dataset}: pct_unit 必须是 percent 或 decimal")
if dataset != "realtime":
request_params = [cfg.symbols_param, cfg.start_param, cfg.end_param]
if dataset == "minute":
@@ -146,8 +178,13 @@ class GenericHTTPProvider:
cfg = self._dataset("realtime")
rows = self._request_rows(cfg)
df = self._mapped_frame(cfg, rows)
# 百分制源(返回 3.66 表示 3.66%)截面归一为契约小数制
df = _normalize_pct_units(df)
# 单位归一: 显式 pct_unit 声明优先; 未声明时 amplitude/turnover_rate
# fail-closed 置 None(交下游重算), change_pct 保留截面判定
df = _normalize_pct_units(
df,
pct_unit=cfg.pct_unit,
transformed_cols=frozenset(cfg.transforms) & set(_PCT_COLUMNS),
)
if df.is_empty():
return []
return df.to_dicts()
-2
View File
@@ -303,8 +303,6 @@ def run_now(
written_adj = 0
affected_symbols: list[str] = []
adj_provider = _prefs.get_adj_factor_provider()
if adj_provider == "same_as_daily":
adj_provider = _prefs.get_daily_data_provider()
can_sync_adj = capset.has(Cap.ADJ_FACTOR) or adj_provider != "tickflow"
if can_sync_adj:
from datetime import datetime, timedelta
+13
View File
@@ -173,6 +173,16 @@ async def _application_lifespan(app: FastAPI):
except Exception as e: # noqa: BLE001
logger.warning("depth_service init failed: %s", e)
# 盘中分钟增量刷新 (Expert 专有): 线程常驻, 开关/时段/能力门控在循环内每轮判断
try:
from app.services.minute_refresh import MinuteRefreshService
minute_refresh = MinuteRefreshService(repo)
minute_refresh.set_app_state(app.state)
app.state.minute_refresh = minute_refresh
minute_refresh.start()
except Exception as e:
logger.warning("minute_refresh init failed: %s", e)
# 停机缺口自检: 延迟后台扫描, 发现最近交易日的盘中快照/缺口时自动创建
# 修复任务 (盘中停机→次日开实时场景, 不修则坏数据被"只刷今天"分支永久留存)
try:
@@ -356,6 +366,9 @@ async def _application_lifespan(app: FastAPI):
wbot = getattr(app.state, "wecom_bot_service", None)
if wbot:
wbot.stop()
mrs = getattr(app.state, "minute_refresh", None)
if mrs:
mrs.stop()
logger.info("shutdown")
+9
View File
@@ -28,6 +28,15 @@ def cn_today() -> date:
return datetime.now(CN_TZ).date()
def in_continuous_session(now: datetime | None = None) -> bool:
"""A股连续竞价时段 (北京时间): 9:30-11:30 / 13:00-15:00, 仅工作日。"""
now = now or cn_now()
return now.weekday() < 5 and (
_MORNING_START <= now.time() <= _MORNING_END
or _AFTERNOON_START <= now.time() <= _AFTERNOON_END
)
def trading_minutes_elapsed_from_dt(dt: datetime) -> float:
"""根据北京时间 datetime 计算当日已交易分钟数。
+176 -4
View File
@@ -1,12 +1,17 @@
"""扶摇(同花顺金融数据 API) HTTP 客户端。
职责: 认证统一信封解包分页拉取快照不知道 provider / services
职责: 认证统一信封解包快照分页单标的日K市场 dump 下载不知道 provider / services
文档: https://fuyao.aicubes.cn/docs REST + X-api-key, 响应信封 {code, message, data}
时间字段口径: 所有 *ms 字段( start/end 入参与 date_ms/ex_date_ms 出参)均为
北京时间零点对应的 epoch ms(= UTC 前一日 16:00), provider 层统一 +8h 换算
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
import httpx
@@ -14,8 +19,9 @@ logger = logging.getLogger(__name__)
BASE_URL = "https://fuyao.aicubes.cn"
# A 股约 5400 只, 500/页约 11 页; 50 页上限防御 count 异常导致的死循环。
_SNAPSHOT_PAGE_SIZE = 500
# 单页 6000 覆盖全市场(实测 ~5600 含北交所, 2026-08 服务端不截断 limit=6000),
# 一次请求拉完; 分页循环兜底未来标的扩容或服务端改为截断的场景。
_SNAPSHOT_PAGE_SIZE = 6000
_SNAPSHOT_MAX_PAGES = 50
_PAGE_INTERVAL_S = 0.15 # 页间隔, 降低触发限频 (code=4001) 的概率
@@ -59,7 +65,9 @@ class FuyaoClient:
return payload.get("data") or {}
# ---- 快照 ----
def snapshot_page(self, limit: int = _SNAPSHOT_PAGE_SIZE, offset: int = 0) -> tuple[list[dict], int]:
def snapshot_page(
self, limit: int = _SNAPSHOT_PAGE_SIZE, offset: int = 0
) -> tuple[list[dict], int]:
"""拉取一页 A 股全市场快照。返回 (rows, total), total 为全市场总数。
实测响应(2026-08): data={timestamp, total, item}; 官方文档示例为
@@ -106,3 +114,167 @@ class FuyaoClient:
if not out:
raise FuyaoError("全市场快照为空")
return out, server_ts
# ---- 历史日K ----
def historical_kline(
self, thscode: str, start_ms: int, end_ms: int, adjust: str = "none"
) -> list[dict]:
"""单标的日K(interval=1d 固定)。单次窗口 ≤10 年, 超出由调用方分片。
adjust 必须显式传 "none" 取原始价 服务端默认是 forward(前复权),
官方前复权序列事件间存在逐日漂移, 项目内禁止使用
返回 data.item 原始行: {date_ms, open_price, high_price, low_price,
close_price, volume(), turnover()}
"""
data = self._get(
"/api/a-share/prices/historical",
{
"thscode": thscode,
"interval": "1d",
"adjust": adjust,
"start": int(start_ms),
"end": int(end_ms),
},
)
rows = data.get("item")
return rows if isinstance(rows, list) else []
# ---- 财务 ----
# 端点均单标的(thscode 不接受逗号)。取数模式二选一: limit=最近N期 或 start/end 区间,
# 这里只用 limit。period=quarterly 覆盖每个季度末(含年报期), 与项目"各报告期累积"口径一致。
_STATEMENT_ENDPOINTS = {
"income": "income-statements",
"balance_sheet": "balance-sheets",
"cash_flow": "cash-flow-statements",
}
def financial_statements(
self, stmt: str, thscode: str, limit: int = 1
) -> list[dict]:
"""单标的财务报表多期序列。stmt: income | balance_sheet | cash_flow。
返回 data.item 原始行: 共有元数据(thscode/period/fiscal_year/fiscal_period/
report_date_ms/period_end_ms/currency) + 各表字段行内 null 表示该期未披露
"""
endpoint = self._STATEMENT_ENDPOINTS.get(stmt)
if endpoint is None:
raise FuyaoError(f"未知财务报表类型: {stmt}")
data = self._get(
f"/api/a-share/financials/{endpoint}",
{"thscode": thscode, "period": "quarterly", "limit": max(1, min(20, limit))},
)
rows = data.get("item")
return rows if isinstance(rows, list) else []
def financial_indicators(self, thscode: str, report: str) -> list[dict]:
"""单标的单报告期财务指标(report 格式 yyyy-N, N=1..4 对应一季报..年报)。
返回 data.abilities 原始列表 [{ability, indicators: [{index_id, value}]}];
value 为保留原始精度的数值字符串(百分制指标即百分点数), 缺失为 null
未披露报告期实测返回 code=5003(文档写 3002, 以实测为准) _get FuyaoError,
由调用方按"该期无数据"处理
"""
data = self._get(
"/api/a-share/financials/indicators",
{"thscode": thscode, "report": report},
)
abilities = data.get("abilities")
return abilities if isinstance(abilities, list) else []
def valuations_snapshot(self, thscodes: list[str]) -> list[dict]:
"""批量估值快照(pe_ttm/pe_mrq/pb_mrq/ps_ttm/pcf_ttm), 数值为最新口径。
服务端单次上限 100 (超出 code=1003), 分批由调用方负责
返回 data.item 原始行
"""
data = self._get(
"/api/a-share/valuations/snapshot",
{"thscodes": ",".join(thscodes[:100])},
)
rows = data.get("item")
return rows if isinstance(rows, list) else []
def price_snapshot_batch(self, thscodes: list[str]) -> list[dict]:
"""按 thscodes 批量行情快照(最新价等), 用于估值推导的分母。
与全市场分页快照同一端点; thscodes 显式传入时不分页
返回 data.item 原始行
"""
data = self._get(
"/api/a-share/prices/snapshot",
{"thscodes": ",".join(thscodes[:100])},
)
rows = data.get("item")
return rows if isinstance(rows, list) else []
def trading_days(self) -> list[dict]:
"""近一年 A 股交易日序列 (固定窗口 [今日-1年, 今日], 无入参)。
返回 data.item 原始行: {date_ms(上海零点), date(yyyyMMdd)}
供交易日探针判定今天在列表内 交易日
"""
data = self._get("/api/a-share/calendar/trading-days", {})
rows = data.get("item")
return rows if isinstance(rows, list) else []
def dragon_tiger_list(self, board_type: str = "all", date: str | None = None) -> dict:
"""龙虎榜榜单 (特色数据)。board_type: all | org | hot_money。
返回 data 原始容器: {trade_date, count, stock_count, stock_items[],
hot_money_items[]}省略 date 时服务端自动取最近已发布交易日;
显式传非交易日返回 code=1002 (由调用方做交易日回退)
实测字段(2026-08): stock_items 12 个基础字段, org 榜额外带 4 个机构字段;
文档中的 limit_reason / amount 实际不返回
"""
params: dict = {"board_type": board_type}
if date:
params["date"] = date
return self._get("/api/a-share/special-data/dragon-tiger-list", params)
def short_term_benchmark(self, date: str | None = None) -> dict:
"""短线风向标竞价基准 (同花顺竞价筛选, 每日约 5~6 只)。
返回 data 原始容器: {date, date_ms, item[]}item :
{thscode, ticker, name, auction_pct, tags[]}支持一年内历史日期;
显式传非交易日返回 code=1002 (由调用方做交易日回退)
"""
params: dict = {}
if date:
params["date"] = date
return self._get("/api/a-share/auction/short-term-benchmark", params)
# ---- 市场 dump ----
def dump_download_url(self, dump_kind: str) -> dict:
"""获取 dump 预签名下载信息(约 300s 有效)。
dump_kind: adjustment-factors | daily-k-10d | daily-k
返回 {presigned_url, presigned_url_expires_at, expires_in_seconds};
release 版本号( 20260828)嵌在 presigned_url releases/<date>/ 路径中,
供调用方做缓存版本管理
"""
return self._get(f"/api/dump/market-dumps/{dump_kind}/download-url", {})
def download_dump(self, dump_kind: str, dest: Path) -> Path:
"""下载 dump 到 dest(先写 .part 临时文件, 成功后原子改名)。失败抛 FuyaoError。
预签名 URL 指向对象存储, 请求不得携带 X-api-key 用独立裸请求,
不经过持有认证头的 self._http
"""
url = str(self.dump_download_url(dump_kind).get("presigned_url") or "")
if not url:
raise FuyaoError(f"dump {dump_kind} 未返回预签名 URL")
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".part")
try:
with httpx.stream("GET", url, timeout=120.0, follow_redirects=True) as resp:
if resp.status_code != 200:
raise FuyaoError(f"dump {dump_kind} 下载失败 HTTP {resp.status_code}")
with open(tmp, "wb") as fh:
for chunk in resp.iter_bytes(1 << 20):
fh.write(chunk)
tmp.replace(dest)
except httpx.HTTPError as e:
raise FuyaoError(f"dump {dump_kind} 下载网络失败: {e}") from e
finally:
tmp.unlink(missing_ok=True)
return dest
+3 -3
View File
@@ -7,8 +7,8 @@ display_name: "fuyao"
runtime: none
entry: app.plugins.fuyao.provider:FuyaoProvider
check: app.plugins.fuyao.provider:availability
datasets: [realtime]
datasets: [realtime, daily, adj_factor, financial]
api_key_env: FUYAO_API_KEY # 声明后设置页提供 Key 输入框(先探后存, secrets.json 优先)
hidden: true # 优化完成前不在数据源页展示; 删除此行即可恢复
description: "同花顺官方 REST 数据 API。当前提供 A 股全市场实时快照(分页拉取);日K/分钟/财务未接入,自动回退 TickFlow。"
description: "同花顺官方 REST 数据 API。A 股实时快照、日K(原始价: 近端走 10d dump, 深窗口走 10 年全量 dump 一次下载秒级筛选, 兜底单标的接口)、除权因子(事件 dump + 本地日K dump 配价推导, 涨跌停自检, 秒级)、财务(利润表/资产负债表/现金流量表/指标, 字段映射至项目口径, bps 由估值反推; 股本无上游接口, 指标历史期建议切回 TickFlow 补齐);分钟未接入,自动回退 TickFlow。"
install_hint: "点击卡片中的输入框配置 API Key(https://fuyao.aicubes.cn 申请),或在 .env 中配置 FUYAO_API_KEY"
homepage: "https://fuyao.aicubes.cn"
+884 -9
View File
@@ -3,35 +3,77 @@
方法签名对齐 custom.GenericHTTPProvider(service 分流点按这套签名调用),
注入 custom loader 注册表后, service 无需改动即可路由到本 provider
当前实现数据集: realtime (A 股全市场快照, 分页)
未声明 daily / minute / financial provider_has_dataset False, 自动回退 tickflow
实现数据集:
- realtime A 股全市场快照 (分页)
- daily A 股日K, 原始价; 近端窗口走 daily-k-10d 全市场 dump(1 次请求),
深窗口走单标的 historical 接口(10 /次自动分片)
- adj_factor A 股除权因子; adjustment-factors 事件 dump + 自家原始日K前收盘,
按交易所公式推导单事件比值, 涨跌停自检
- financial 财务五表(股本除外): 三表多期序列 + 指标单期, 字段映射为 TickFlow
canonical 列名, 扶摇独有字段原名透传为扩展列; bps 由估值 pb_mrq
反推; shares 无上游接口恒空
未声明 minute provider_has_dataset False, 自动回退 tickflow
单位口径 (CONTRIBUTING §3.1, 不可凭字段名推断):
单位口径 (CONTRIBUTING §3.1, 不可凭字段名推断):
- 扶摇 price_change_ratio_pct 为百分数数值 (1.74 = +1.74%), 本项目 realtime
change_pct 契约为小数制 (0.0174 = 1.74%) 此处显式 / 100
- volume 单位股turnover 单位, 与内部契约一致, 直接透传
- 扶摇 volume 单位为股, 本项目日K/实时契约均为手 统一 floor(/100)
- turnover 单位元, 与内部一致, 直接透传
- 日K取数 adjust=none 锁定: 官方 forward 序列事件间有逐日漂移(2026-08 实测),
项目内前复权一律由 indicators.pipeline 用本地因子计算
- ex_factor 为单事件比值(非累积), 累积链由 pipeline._apply_adj_factor 构建
"""
from __future__ import annotations
import calendar
import contextlib
import logging
import math
import re
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import polars as pl
from app.data_providers.normalizer import DAILY_COLS, normalize_daily
from app.indicators.pipeline import filter_halt_days
from app.plugins.fuyao import client as fuyao_client
from app.plugins.fuyao.client import FuyaoClient, FuyaoError
logger = logging.getLogger(__name__)
# 只声明真实提供的数据集; 其余数据集 provider_has_dataset 返回 False → 回退 tickflow
_DATASETS = ("realtime",)
_DATASETS = ("realtime", "daily", "adj_factor", "financial")
API_KEY_ENV = "FUYAO_API_KEY"
SECRETS_FIELD = "fuyao_api_key" # UI 配置的 Key 存 secrets.json, 优先级高于 .env
# 扶摇 *ms 时间字段为北京时间零点(= UTC 前一日 16:00), +8h 后按 UTC 解析即得交易日
_SH_MS = 28_800_000
_HIST_MAX_SPAN_MS = 3650 * 86_400_000 # historical 单次窗口上限 10 年, 超出由本层分片
_HIST_INTERVAL_S = 0.12 # 单标的请求节流(实测 200+ 连发未触发 4001 限频)
_FINANCIAL_HISTORY_PERIODS = 8 # 财务首装全量历史: 最近 8 期季报(约 2 年)
_VALUATION_BATCH = 100 # 估值/价格快照端点单次上限 100 只
# 项目财务表名 → 扶摇报表端点名
_STATEMENT_ENDPOINTS = {
"income": "income-statements",
"balance_sheet": "balance-sheets",
"cash_flow": "cash-flow-statements",
}
_ADJ_DUMP_KIND = "adjustment-factors"
_DAILY10_DUMP_KIND = "daily-k-10d"
_DAILY_DUMP_KIND = "daily-k" # 10 年全量日K dump(约 172MB), 深窗口一次下载覆盖全市场
_RECENT_DUMP_DAYS = 12 # 窗口跨度 ≤ 此天数时优先走 10d dump(覆盖 ≈10 个交易日)
_PREV_CLOSE_BACKDAYS = 30 # 推导因子时向前找"除权日前收盘"的回看天数(容忍长期停牌)
def get_api_key() -> str:
from app import secrets_store
return secrets_store.get_env_backed_secret(SECRETS_FIELD, API_KEY_ENV)
@@ -39,7 +81,8 @@ def availability() -> tuple[bool, str]:
"""loader 启动自检: API Key 已配置(secrets.json 或 .env)才注册为可切换数据源。不抛异常。"""
if get_api_key():
return True, "ok"
return False, f"未配置 {API_KEY_ENV}(可在设置页数据源卡片中直接填写)"
# 状态行会拼在「未配置」标签之后, 文案不再重复"未配置"字样
return False, f"缺少 API Key(可在下方输入框直接填写,或配置环境变量 {API_KEY_ENV})"
def probe_api_key(api_key: str) -> tuple[bool, str]:
@@ -85,6 +128,128 @@ def _first(row: dict, *names: str):
return None
def _date_of_ms(value) -> date | None:
"""扶摇 *ms(上海零点) → 交易日。None/非法值返回 None, 不伪造。"""
if value is None:
return None
try:
ms = int(value)
except (TypeError, ValueError):
return None
return datetime.fromtimestamp((ms + _SH_MS) // 1000, tz=UTC).date()
def _ms_of_date(d: date) -> int:
"""交易日 → 扶摇 start/end 入参口径的 ms(该日上海零点的 epoch ms, 不依赖本机时区)。"""
return (calendar.timegm(d.timetuple()) - 28_800) * 1000
def _iso_of_ms(value) -> str | None:
"""扶摇 *ms → ISO 日期字符串(项目财务表 period_end/announce_date 的存储口径)。"""
d = _date_of_ms(value)
return d.isoformat() if d is not None else None
def _report_quarter(fiscal_period) -> int | None:
"""fiscal_period(Q1..Q4/FY) → 指标接口 report 参数的季号 N(1..4)。"""
if not fiscal_period:
return None
text = str(fiscal_period).strip().upper()
if text == "FY":
return 4
try:
return int(text.lstrip("Q"))
except ValueError:
return None
def _ref_price(
prev_close: float, dividend: float, bonus: float, allot: float, allot_price: float
) -> float | None:
"""交易所除权参考价: (P - D + AR·AP) / (1 + S + AR), 四舍五入(half-up)保留 2 位。
half-up 是交易所口径; 银行家舍入会让约半数事件在第 2 位小数上偏离(对拍实证)
"""
denom = 1.0 + bonus + allot
if denom <= 0:
return None
x = (prev_close - dividend + allot * allot_price) / denom
return math.floor(x * 100 + 0.5) / 100
def _price_limit(symbol: str) -> float:
"""按代码前缀给涨跌停幅度(自检容差用): 创业板/科创板 20%, 北交所 30%, 主板 10%"""
code = symbol.split(".")[0]
if code.startswith(("300", "301", "688", "689")):
return 0.20
if code.startswith(("8", "4", "92")):
return 0.30
return 0.10
def _release_of(url: str) -> str:
"""从预签名 URL 提取 release 版本号(releases/<date>/ 路径), 提不到返回 unknown。"""
m = re.search(r"releases/(\d+)/", url or "")
return m.group(1) if m else "unknown"
def _cache_dir() -> Path:
from app.config import settings
d = settings.data_dir / "cache" / "fuyao"
d.mkdir(parents=True, exist_ok=True)
return d
def _kline_rows(symbol: str, bars: list[dict]) -> list[dict]:
"""historical/dump 原始行(价格元, volume 股) → 内部行(volume 手)。"""
out = []
for b in bars:
v = _to_float(b.get("volume"))
out.append(
{
"symbol": symbol,
"date": _date_of_ms(b.get("date_ms")),
"open": _to_float(b.get("open_price")),
"high": _to_float(b.get("high_price")),
"low": _to_float(b.get("low_price")),
"close": _to_float(b.get("close_price")),
"volume": math.floor(v / 100.0) if v is not None else None,
"amount": _to_float(b.get("turnover")),
}
)
return out
def _tail_ok(end_d: date, covered_max: date) -> bool:
"""请求终点是否被覆盖到 covered_max: 周末/节假日的自然缺口(≤3 天)不算缺失。"""
if end_d <= covered_max:
return True
return (end_d - covered_max).days <= 3 and end_d.weekday() >= 5
def _dump_covers(dump: pl.DataFrame, start_d: date, end_d: date) -> bool:
"""dump 日期范围是否覆盖请求窗口: 起点必须落在 dump 内; 终点允许周末自然缺口。"""
if dump.is_empty() or "date_ms" not in dump.columns:
return False
dates = pl.from_epoch(dump["date_ms"].cast(pl.Int64) + _SH_MS, time_unit="ms").dt.date()
dmin, dmax = dates.min(), dates.max()
return start_d >= dmin and _tail_ok(end_d, dmax)
def _dump_date_range(path: Path) -> tuple[date | None, date | None]:
"""lazy 读 parquet 的 date_ms 边界(走元数据/少量行组, 不整读大文件)。"""
row = (
pl.scan_parquet(path)
.select(
pl.from_epoch(pl.col("date_ms").min() + _SH_MS, time_unit="ms").dt.date().alias("dmin"),
pl.from_epoch(pl.col("date_ms").max() + _SH_MS, time_unit="ms").dt.date().alias("dmax"),
)
.collect()
)
return row["dmin"][0], row["dmax"][0]
def _map_snapshot_row(row: dict, fetched_ms: int) -> dict | None:
"""扶摇快照行 → 内部 realtime record。字段缺失时按依赖推导, 不伪造数据。
@@ -108,6 +273,8 @@ def _map_snapshot_row(row: dict, fetched_ms: int) -> dict | None:
# 与 quote_service 的推导同口径: 小数制, 不乘 100
change_pct = change_amount / prev
volume = _to_float(row.get("volume"))
return {
"symbol": symbol,
"name": row.get("name"), # 快照无名称, 由下游维表关联
@@ -116,7 +283,7 @@ def _map_snapshot_row(row: dict, fetched_ms: int) -> dict | None:
"open": _to_float(row.get("open_price")),
"high": _to_float(_first(row, "high_price", "highest_price")),
"low": _to_float(_first(row, "low_price", "lowest_price")),
"volume": _to_float(row.get("volume")),
"volume": math.floor(volume / 100.0) if volume is not None else None, # 股 → 手
"amount": _to_float(row.get("turnover")),
"change_pct": change_pct,
"change_amount": change_amount,
@@ -136,18 +303,76 @@ class FuyaoProvider:
def __init__(self) -> None:
self.config = _FuyaoConfig()
self._client: FuyaoClient | None = None
self._dump_memo: dict[str, pl.DataFrame] = {}
self._dump_path_memo: dict[str, Path] = {}
def close(self) -> None: # loader.load_all 重建注册表时会对每个 provider 调 close
if self._client is not None:
with contextlib.suppress(Exception):
self._client.close()
self._client = None
self._dump_memo.clear()
self._dump_path_memo.clear()
def _get_client(self) -> FuyaoClient:
if self._client is None:
self._client = fuyao_client.FuyaoClient(api_key=get_api_key())
return self._client
# ---- dump 缓存 ----
def _ensure_dump_path(self, dump_kind: str, cache_prefix: str) -> Path:
"""确保最新 release 的 dump 已落盘, 返回缓存路径(大文件不整读进内存)。
release 号取自预签名 URL releases/<date>/ 路径; release 落盘后清理旧版缓存
"""
memo = self._dump_path_memo.get(dump_kind)
if memo is not None and memo.exists():
return memo
client = self._get_client()
info = client.dump_download_url(dump_kind)
release = _release_of(str(info.get("presigned_url") or ""))
dest = _cache_dir() / f"{cache_prefix}__{release}.parquet"
if not dest.exists():
client.download_dump(dump_kind, dest)
for old in dest.parent.glob(f"{cache_prefix}__*.parquet"):
if old.name != dest.name:
old.unlink(missing_ok=True)
logger.info("扶摇 dump %s(release %s)已下载: %s", dump_kind, release, dest.name)
self._dump_path_memo[dump_kind] = dest
return dest
def _ensure_dump(self, dump_kind: str, cache_prefix: str) -> pl.DataFrame:
"""小体量 dump(快照 10d / 因子)整读 + 进程内 memo, 避免重复打接口/读盘。"""
memo = self._dump_memo.get(dump_kind)
if memo is not None:
return memo
df = pl.read_parquet(self._ensure_dump_path(dump_kind, cache_prefix))
self._dump_memo[dump_kind] = df
return df
def _ensure_daily_big_dump(self, start_d: date) -> Path | None:
"""10 年全量日K dump(约 172MB)。只要求覆盖窗口起点; 末端缺口由 10d dump 补。
已有缓存覆盖起点就直接复用, 不追新 release(避免深窗口高频触发时日日重下
172MB release 的中段历史不会变, 尾部新鲜度交给 1MB 10d dump)
"""
for f in sorted(_cache_dir().glob("daily_k__*.parquet"), reverse=True):
try:
dmin, _ = _dump_date_range(f)
except Exception: # 缓存损坏不致命, 换下一个/重拉
continue
if dmin is not None and start_d >= dmin:
return f
try:
path = self._ensure_dump_path(_DAILY_DUMP_KIND, "daily_k")
except FuyaoError as e:
logger.warning("扶摇 10 年 dump 不可用, 回退单标的接口: %s", e)
return None
dmin, _ = _dump_date_range(path)
if dmin is None or start_d < dmin:
return None # 窗口比 10 年更早 → 单标的兜底
return path
# ---- realtime ----
def get_realtime(self) -> list[dict]:
"""全市场实时快照 → 内部 realtime records。失败软返回空列表(不阻断轮询)。"""
@@ -175,11 +400,661 @@ class FuyaoProvider:
logger.info("扶摇实时行情拉取完成: %d 条(丢弃 %d 行)", len(records), dropped)
return records
# ---- daily ----
def get_daily(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: str = "stock",
on_chunk_done: Callable[[int, int], None] | None = None,
) -> pl.DataFrame:
"""A 股日K → 内部契约。原始价(adjust=none 锁定)、volume 股→手(floor /100)。
取数三档(逐级降级, 全部失败才空手而归):
- 近端窗口(跨度 12 ): daily-k-10d dump( 1MB), 1 次请求覆盖全部标的;
- 深窗口: daily-k 10 年全量 dump( 172MB, 缓存覆盖起点即复用, 不追新 release),
末端缺口由 10d dump 补尾 全市场深回填从"逐票 34 分钟"降为"一次下载+秒级筛选";
- 兜底: 单标的 historical 接口(窗口早于 dump 覆盖 / dump 不可用; 10 年自动分片,
逐标的节流 + 进度回调)
"""
if not symbols or asset_type != "stock":
return pl.DataFrame()
end_dt = end_time or datetime.now()
start_dt = start_time or (end_dt - timedelta(days=365))
start_d, end_d = start_dt.date(), end_dt.date()
if (end_d - start_d).days <= _RECENT_DUMP_DAYS:
try:
dump = self._ensure_dump(_DAILY10_DUMP_KIND, "daily_k_10d")
if _dump_covers(dump, start_d, end_d):
df = self._daily_from_dump(dump, set(symbols), start_d, end_d)
if on_chunk_done:
on_chunk_done(1, 1)
logger.info("扶摇日K(10d dump)完成: %d 行 [%s ~ %s]", df.height, start_d, end_d)
return df
logger.info("扶摇 10d dump 未覆盖窗口 [%s ~ %s], 尝试 10 年 dump", start_d, end_d)
except FuyaoError as e:
logger.warning("扶摇日K 10d dump 不可用, 尝试 10 年 dump: %s", e)
df = self._daily_from_big_dump(set(symbols), start_d, end_d)
if df is not None:
if on_chunk_done:
on_chunk_done(1, 1)
logger.info("扶摇日K(10 年 dump)完成: %d 行 [%s ~ %s]", df.height, start_d, end_d)
return df
df = self._daily_from_api(symbols, start_d, end_d, on_chunk_done)
logger.info("扶摇日K(单标的接口)完成: %d 行 [%s ~ %s]", df.height, start_d, end_d)
return df
def _daily_from_dump(
self, dump: pl.DataFrame, symset: set[str], start_d: date, end_d: date
) -> pl.DataFrame:
df = dump.with_columns(
pl.from_epoch(pl.col("date_ms") + _SH_MS, time_unit="ms").dt.date().alias("date")
)
return self._map_daily_dump(df, symset, start_d, end_d)
def _map_daily_dump(
self, df: pl.DataFrame, symset: set[str], start_d: date, end_d: date
) -> pl.DataFrame:
"""dump 原始行 → 内部日K契约(股→手、字段重命名、停牌过滤)。"""
if "adjusted" in df.columns:
df = df.filter(pl.col("adjusted") == "none") # 防御: dump 变为复权口径时拒绝落库
out = (
df.filter(
(pl.col("date") >= start_d)
& (pl.col("date") <= end_d)
& pl.col("thscode").is_in(sorted(symset))
)
.with_columns(
(pl.col("volume") / 100.0).floor().alias("volume"), # 股 → 手
pl.col("thscode").alias("symbol"),
)
.rename(
{
"open_price": "open",
"high_price": "high",
"low_price": "low",
"close_price": "close",
"turnover": "amount",
}
)
)
out = filter_halt_days(out)
cols = [c for c in DAILY_COLS if c in out.columns]
return out.select(cols).sort(["symbol", "date"]) if not out.is_empty() else out.select(cols)
def _daily_from_big_dump(
self, symset: set[str], start_d: date, end_d: date
) -> pl.DataFrame | None:
"""深窗口主路径: 10 年全量 dump(lazy 按需筛) + 必要时 10d dump 补尾。
覆盖不了(窗口早于 10 / dump 拉取失败)返回 None, 由调用方走单标的接口
"""
path = self._ensure_daily_big_dump(start_d)
if path is None:
return None
_, dmax = _dump_date_range(path)
big_hi = min(end_d, dmax)
# 窗口/标的过滤下推到 lazy 计划, 只物化需要的行(全量 10 年 ≈ 13.6M 行)
window = (
pl.scan_parquet(path)
.with_columns(
pl.from_epoch(pl.col("date_ms") + _SH_MS, time_unit="ms").dt.date().alias("date")
)
.filter(
(pl.col("date") >= start_d)
& (pl.col("date") <= big_hi)
& pl.col("thscode").is_in(sorted(symset))
)
.collect()
)
parts = [self._map_daily_dump(window, symset, start_d, big_hi)]
if not _tail_ok(end_d, dmax):
# 末端缺口(如 10 年 dump 是旧 release, end 是最近交易日): 10d dump 补尾
try:
ten = self._ensure_dump(_DAILY10_DUMP_KIND, "daily_k_10d")
ten_dates = pl.from_epoch(
ten["date_ms"].cast(pl.Int64) + _SH_MS, time_unit="ms"
).dt.date()
ten_min, ten_max = ten_dates.min(), ten_dates.max()
if (
ten_min is not None
and ten_min <= dmax + timedelta(days=1)
and _tail_ok(end_d, ten_max)
):
tail_start = max(start_d, dmax + timedelta(days=1))
parts.append(self._daily_from_dump(ten, symset, tail_start, end_d))
else:
return None # 中段或尾部仍有缺口 → 单标的兜底, 不交缺口数据
except FuyaoError as e:
logger.warning("扶摇 10d dump 补尾失败: %s", e)
return None
non_empty = [p for p in parts if not p.is_empty()]
if not non_empty:
return pl.DataFrame()
out = pl.concat(non_empty, how="vertical_relaxed")
return out.unique(subset=["symbol", "date"], keep="last").sort(["symbol", "date"])
def _daily_from_api(
self,
symbols: list[str],
start_d: date,
end_d: date,
on_chunk_done: Callable[[int, int], None] | None,
) -> pl.DataFrame:
frames: list[pl.DataFrame] = []
for i, sym in enumerate(symbols):
rows = self._historical_bars(sym, start_d, end_d)
time.sleep(_HIST_INTERVAL_S)
if rows:
df = normalize_daily(_kline_rows(sym, rows), default_symbol=sym, source=self.name)
if not df.is_empty():
frames.append(df)
if on_chunk_done:
on_chunk_done(i + 1, len(symbols))
return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
def _historical_bars(self, symbol: str, start_d: date, end_d: date) -> list[dict]:
"""按 ≤10 年窗口分片拉取单标的原始日K。中途失败软返回已得行, 不抛出。"""
out: list[dict] = []
s = _ms_of_date(start_d)
e = _ms_of_date(end_d)
while s <= e:
chunk_end = min(e, s + _HIST_MAX_SPAN_MS - 1)
try:
out.extend(self._get_client().historical_kline(symbol, s, chunk_end, adjust="none"))
except FuyaoError as err:
logger.warning("扶摇日K拉取失败 %s [%s ~ %s]: %s", symbol, start_d, end_d, err)
break
if s + _HIST_MAX_SPAN_MS <= e:
time.sleep(_HIST_INTERVAL_S)
s = chunk_end + 1
return out
# ---- adj_factor ----
def get_adj_factors(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: str = "stock",
on_chunk_done: Callable[[int, int], None] | None = None,
) -> pl.DataFrame:
"""A 股除权因子 → 内部契约(symbol/trade_date/ex_factor, 单事件比值非累积)。
数据链: adjustment-factors 全量事件 dump 清洗(时区 +8h / 滤全零 / 同日成分合并)
前收盘价从本地日K dump 一次取齐(缺价标的回退单标的接口) 交易所公式推导
涨跌停自检剔除异常
未来已公告事件无前收盘, 留给滚动增量窗口(15 )自然补上
"""
schema = {"symbol": pl.String, "trade_date": pl.Date, "ex_factor": pl.Float64}
if not symbols or asset_type != "stock":
return pl.DataFrame(schema=schema)
try:
events = self._load_adj_events(set(symbols), start_time, end_time)
except FuyaoError as e:
logger.warning("扶摇除权因子 dump 加载失败: %s", e)
return pl.DataFrame(schema=schema)
if events.is_empty():
return pl.DataFrame(schema=schema)
out_rows: list[dict] = []
syms = sorted(events["symbol"].unique().to_list())
# 前收盘价优先从本地日K dump 一次取齐(全市场配价从逐标的 ~13 分钟降为秒级),
# 大 dump 不可用或个别标的缺价时回退单标的接口(节流保留)。
bounds = events.group_by("symbol").agg(
pl.col("ex_date").min().alias("first_ex"),
pl.col("ex_date").max().alias("last_ex"),
)
closes_by_sym = self._closes_from_dumps(bounds)
fallbacks = 0
for i, sym in enumerate(syms):
evs = events.filter(pl.col("symbol") == sym).sort("ex_date")
first_ex: date = evs["ex_date"][0]
last_ex: date = evs["ex_date"][-1]
closes = (closes_by_sym or {}).get(sym)
if not closes or min(closes) > first_ex:
# dump 无该标的 / 覆盖不到首个事件前 → 单标的接口兜底
closes = self._fetch_closes(
sym, first_ex - timedelta(days=_PREV_CLOSE_BACKDAYS), last_ex
)
time.sleep(_HIST_INTERVAL_S)
fallbacks += 1
if not closes:
logger.warning("扶摇除权因子: %s 原始日K为空, 跳过其 %d 个事件", sym, evs.height)
continue
days = sorted(closes)
for ev in evs.iter_rows(named=True):
exd: date = ev["ex_date"]
prev_days = [d for d in days if d < exd]
if not prev_days:
continue # 日K窗口未覆盖的远古事件(如 10 年分片边界之外)
p = closes[prev_days[-1]]
ref = _ref_price(p, ev["dividend"], ev["bonus"], ev["allot"], ev["allot_price"])
if ref is None or ref <= 0:
logger.warning(
"扶摇除权因子: %s %s 参考价无法计算(P=%s D=%s S=%s AR=%s AP=%s), 跳过",
sym,
exd,
p,
ev["dividend"],
ev["bonus"],
ev["allot"],
ev["allot_price"],
)
continue
factor = p / ref
ex_days = [d for d in days if d >= exd]
if ex_days: # 涨跌停自检: 错误因子会令复权后除权日涨跌幅超出板限
ret = closes[ex_days[0]] / (p / factor) - 1.0
if abs(ret) > _price_limit(sym) + 0.02:
logger.warning(
"扶摇除权因子自检剔除 %s %s: 复权后除权日涨跌幅 %.1f%% 超出涨跌停",
sym,
exd,
ret * 100,
)
continue
out_rows.append({"symbol": sym, "trade_date": exd, "ex_factor": factor})
if on_chunk_done:
on_chunk_done(i + 1, len(syms))
if closes_by_sym is not None:
logger.info(
"扶摇除权因子: 本地 dump 配价 %d/%d 标的, 回退接口 %d 标的",
len(closes_by_sym),
len(syms),
fallbacks,
)
if not out_rows:
return pl.DataFrame(schema=schema)
return (
pl.DataFrame(out_rows, schema=schema)
.unique(subset=["symbol", "trade_date"], keep="last")
.sort(["symbol", "trade_date"])
)
def _load_adj_events(
self,
symset: set[str],
start_time: datetime | None,
end_time: datetime | None,
) -> pl.DataFrame:
"""事件 dump → 清洗 → 窗口过滤。返回 symbol/ex_date/dividend/bonus/allot/allot_price。
清洗规则(对拍实证, 2026-08 验证记录):
- ex_date_ms 为上海零点戳, +8h 转日期;
- 全零事件行(疑似特殊事件, dump 未给成分)过滤;
- 同日拆行(如分红/送转各一行)按成分合并后推导, 顺序不可反;
- 配股但配股价缺失 无法推导, 过滤
"""
today = datetime.now().date()
df = self._ensure_dump(_ADJ_DUMP_KIND, "adj_factors").rename({"thscode": "symbol"})
df = (
df.with_columns(
pl.from_epoch(pl.col("ex_date_ms") + _SH_MS, time_unit="ms")
.dt.date()
.alias("ex_date"),
pl.col("dividend_per_share").fill_null(0.0),
pl.col("per_share_bonus").fill_null(0.0),
pl.col("allotment_ratio").fill_null(0.0),
pl.col("allotment_price").fill_null(0.0),
)
.filter(
(pl.col("ex_date") <= today) # 未来已公告事件无前收盘, 留给滚动增量
& (
(pl.col("dividend_per_share") != 0)
| (pl.col("per_share_bonus") != 0)
| (pl.col("allotment_ratio") != 0)
)
)
.group_by("symbol", "ex_date")
.agg(
pl.col("dividend_per_share").sum().alias("dividend"),
pl.col("per_share_bonus").sum().alias("bonus"),
pl.col("allotment_ratio").sum().alias("allot"),
pl.col("allotment_price").max().alias("allot_price"), # 同日拆行共享配股价
)
.filter(~((pl.col("allot") > 0) & (pl.col("allot_price") <= 0)))
.filter(pl.col("symbol").is_in(sorted(symset)))
)
if start_time is not None:
df = df.filter(pl.col("ex_date") >= start_time.date())
if end_time is not None:
df = df.filter(pl.col("ex_date") <= end_time.date())
return df.select("symbol", "ex_date", "dividend", "bonus", "allot", "allot_price")
# ---- financial ----
# 字段映射: 扶摇原始字段 → 项目 canonical 列名(TickFlow 口径, 前端财务页与回测
# FUNDAMENTAL_FACTORS 按此消费)。映射表之外的扶摇独有字段以原名透传为扩展列。
_INCOME_FIELD_MAP = {
"operating_income": "revenue",
"operating_costs": "operating_cost",
"sales_fee": "selling_expense",
"manage_fee": "admin_expense",
"research_and_development_expenses": "rd_expense",
"operating_profit": "operating_profit",
"interest_expenses": "financial_expense", # 近似口径: 扶摇只给利息费用
"profit_total": "total_profit",
"income_tax_expense": "income_tax",
"net_profit": "net_income",
"parent_holder_net_profit": "net_income_attributable",
"basic_eps": "basic_eps",
}
_BALANCE_FIELD_MAP = {
"assets_total": "total_assets",
"total_current_assets": "total_current_assets",
"non_current_nets_total": "total_non_current_assets",
"cash": "cash_and_equivalents",
"accounts_receivable": "accounts_receivable",
"total_debt": "total_liabilities",
"holder_equity_total": "total_equity",
}
_CASHFLOW_FIELD_MAP = {
"act_cash_flow_net": "net_operating_cash_flow",
"invest_cash_flow_net": "net_investing_cash_flow",
"financing_cash_flow_net": "net_financing_cash_flow",
"pay_fixed_assets_etc_cash": "capex",
"cash_equivalents_net_addition": "net_cash_change",
}
# 官方指标 index_id → canonical。归母净利同比近似 tickflow net_income_yoy;
# 实测 index_id 与文档有出入(calculate_ 前缀等), 以实测为准。
_METRICS_FIELD_MAP = {
"index_weighted_avg_roe": "roe",
"total_assets_net_ratio": "roa",
"sale_gross_margin": "gross_margin",
"sale_net_interest_ratio": "net_margin",
"assets_debt_ratio": "debt_to_asset_ratio",
"calculate_operating_income_yoy_growth_ratio": "revenue_yoy",
"calculate_parent_holder_net_profit_yoy_growth_ratio": "net_income_yoy",
"operating_cash_flow_net_divide_income": "operating_cash_to_revenue",
"inventory_turnover_ratio": "inventory_turnover",
}
def get_financials(
self,
table: str,
symbols: list[str],
latest_only: bool = True,
) -> pl.DataFrame:
"""拉取财务数据, 映射为 canonical 列(symbol/period_end/announce_date/指标)。
- 三大报表: 单股单请求, latest_only 决定最近 1 期还是 8 期季报;
- metrics: 指标接口为单股单期, 恒只拉最新一期(bps 由估值快照 pb_mrq 反推,
eps_basic 顺带取自利润表); 历史各期建议切回 TickFlow 同步补齐
报告期合并写入会让两源数据共存, 互不覆盖;
- shares: 扶摇无股本接口, 恒返回空(已有存量靠合并写入保留)
"""
if table == "shares":
logger.info("扶摇无股本接口, shares 表跳过 (已有数据保留)")
return pl.DataFrame()
if table in _STATEMENT_ENDPOINTS:
field_map = {
"income": self._INCOME_FIELD_MAP,
"balance_sheet": self._BALANCE_FIELD_MAP,
"cash_flow": self._CASHFLOW_FIELD_MAP,
}[table]
return self._financial_statements(table, field_map, symbols, latest_only)
if table == "metrics":
return self._financial_metrics(symbols)
return pl.DataFrame()
def _financial_statements(
self,
stmt: str,
field_map: dict[str, str],
symbols: list[str],
latest_only: bool,
) -> pl.DataFrame:
client = self._get_client()
limit = 1 if latest_only else _FINANCIAL_HISTORY_PERIODS
rows_out: list[dict] = []
for i, sym in enumerate(symbols):
if i:
time.sleep(_HIST_INTERVAL_S)
try:
rows = client.financial_statements(stmt, sym, limit=limit)
except FuyaoError as e:
logger.warning("扶摇财务 %s %s 失败: %s", stmt, sym, e)
continue
for r in rows:
row: dict = {
"symbol": sym,
"period_end": _iso_of_ms(r.get("period_end_ms")),
"announce_date": _iso_of_ms(r.get("report_date_ms")),
}
for src, dst in field_map.items():
row[dst] = _to_float(r.get(src))
# 扶摇独有字段以原名透传为扩展列 (canonical 之外的增量信息)
for src, value in r.items():
if src not in field_map and src not in row and isinstance(value, (int, float)):
row[src] = value
rows_out.append(row)
return pl.DataFrame(rows_out) if rows_out else pl.DataFrame()
def _financial_metrics(self, symbols: list[str]) -> pl.DataFrame:
client = self._get_client()
# 指标接口按 report(yyyy-N) 单期查询 → 先用利润表 limit=1 反查每股最新披露期
latest: dict[str, dict] = {}
for i, sym in enumerate(symbols):
if i:
time.sleep(_HIST_INTERVAL_S)
try:
rows = client.financial_statements("income", sym, limit=1)
except FuyaoError as e:
logger.warning("扶摇财务 income %s 失败: %s", sym, e)
continue
if rows:
latest[sym] = rows[0]
if not latest:
return pl.DataFrame()
bps_by_sym = self._derive_bps(sorted(latest))
rows_out: list[dict] = []
for sym, r in latest.items():
quarter = _report_quarter(r.get("fiscal_period"))
report = f"{r.get('fiscal_year')}-{quarter}" if quarter else None
row: dict = {
"symbol": sym,
"period_end": _iso_of_ms(r.get("period_end_ms")),
"announce_date": _iso_of_ms(r.get("report_date_ms")),
"eps_basic": _to_float(r.get("basic_eps")),
"bps": bps_by_sym.get(sym),
}
if report:
try:
abilities = client.financial_indicators(sym, report)
except FuyaoError as e:
logger.warning("扶摇指标 %s %s 失败: %s", sym, report, e)
abilities = []
for ability in abilities:
for ind in ability.get("indicators") or []:
index_id = ind.get("index_id")
if not index_id:
continue
value = _to_float(ind.get("value"))
if value is not None:
row[self._METRICS_FIELD_MAP.get(index_id, index_id)] = value
rows_out.append(row)
return pl.DataFrame(rows_out) if rows_out else pl.DataFrame()
def trading_days(self) -> set:
"""近一年交易日集合 (供交易日探针)。失败抛 FuyaoError, 由探针兜为未知。"""
rows = self._get_client().trading_days()
return {
d
for d in (_date_of_ms(r.get("date_ms")) for r in rows)
if d is not None
}
def dragon_tiger(self, board_type: str = "all", date: str | None = None) -> dict:
"""龙虎榜单榜 (复盘页卡片 + AI 复盘上下文)。返回原始 data 容器。
非路由数据集 (tickflow 无对应能力), 不进 plugin.yaml datasets,
services.dragon_tiger 统一做三榜聚合/缓存/交易日回退
"""
return self._get_client().dragon_tiger_list(board_type, date)
def short_term_benchmark(self, date: str | None = None) -> dict:
"""短线风向标竞价基准 (复盘页卡片 + AI 复盘上下文)。返回原始 data 容器。
非路由数据集 (tickflow 无对应能力), 不进 plugin.yaml datasets,
services.auction_benchmark 统一做按日缓存/收益enrich/交易日回退
"""
return self._get_client().short_term_benchmark(date)
def _derive_bps(self, symbols: list[str]) -> dict[str, float]:
"""估值快照 pb_mrq 与行情快照最新价同源同刻 → bps = price / pb_mrq。
与财报口径 bps 可能差几个百分点(上游权益基准不完全透明), 用于补齐
metrics.bps 使回测 pb_latest 因子可用接口失败只影响 bps , 不致命
"""
client = self._get_client()
pb: dict[str, float] = {}
price: dict[str, float] = {}
for i in range(0, len(symbols), _VALUATION_BATCH):
if i:
time.sleep(_HIST_INTERVAL_S)
batch = symbols[i : i + _VALUATION_BATCH]
for fetch, store in (
(client.valuations_snapshot, pb),
(client.price_snapshot_batch, price),
):
time.sleep(_HIST_INTERVAL_S)
try:
for r in fetch(batch):
value = _to_float(r.get("pb_mrq" if store is pb else "last_price"))
code = r.get("thscode")
if code and value is not None and value != 0:
store[code] = value
except FuyaoError as e:
logger.warning("扶摇 bps 推导快照失败(%d 只): %s", len(batch), e)
return {
sym: price[sym] / pb[sym]
for sym in symbols
if sym in pb and pb[sym] and sym in price
}
def _fetch_closes(self, symbol: str, start_d: date, end_d: date) -> dict[date, float]:
rows = self._historical_bars(symbol, start_d, end_d)
out: dict[date, float] = {}
for r in rows:
d = _date_of_ms(r.get("date_ms"))
c = _to_float(r.get("close_price"))
if d is not None and c is not None:
out[d] = c
return out
def _closes_from_dumps(
self, bounds: pl.DataFrame
) -> dict[str, dict[date, float]] | None:
"""从本地日K dump 一次取齐全部事件标的的收盘价。
每标的开窗 [first_ex-30d, last_ex](与单标的接口同窗): 10 年大 dump
主体, 10d dump 叠加补末端新鲜度(除权日当天的自检需要 ex 日收盘)
返回 symbol {date: close}; dump 不可用/窗口早于其覆盖/读盘失败
None, 由调用方整轮回退单标的接口
"""
try:
lo = bounds["first_ex"].min() - timedelta(days=_PREV_CLOSE_BACKDAYS)
path = self._ensure_daily_big_dump(lo)
if path is None:
return None
sources = [self._closes_scan(pl.scan_parquet(path))]
ten = self._dump_memo.get(_DAILY10_DUMP_KIND)
if ten is not None:
sources.append(self._closes_scan(ten.lazy()))
else:
try:
p10 = self._ensure_dump_path(_DAILY10_DUMP_KIND, "daily_k_10d")
except FuyaoError as e:
logger.info("扶摇 10d dump 不可用, 配价仅用 10 年 dump: %s", e)
else:
sources.append(self._closes_scan(pl.scan_parquet(p10)))
win = bounds.select(
"symbol",
(pl.col("first_ex") - pl.duration(days=_PREV_CLOSE_BACKDAYS)).alias("lo"),
"last_ex",
)
# concat 顺序 = 叠加优先级: 同 (symbol, date) 时 10d dump(更新鲜)覆盖大 dump
df = (
pl.concat(sources, how="vertical_relaxed")
.join(win.lazy(), on="symbol", how="inner")
.filter(
(pl.col("date") >= pl.col("lo")) & (pl.col("date") <= pl.col("last_ex"))
)
.unique(subset=["symbol", "date"], keep="last", maintain_order=True)
.collect()
)
return {
f["symbol"][0]: dict(
zip(f["date"].to_list(), f["close"].to_list(), strict=True)
)
for f in df.partition_by("symbol")
}
except Exception as e: # 缓存损坏等不致命: 回退逐标的接口
logger.warning("扶摇除权因子本地配价失败, 回退单标的接口: %s", e)
return None
@staticmethod
def _closes_scan(lf: pl.LazyFrame) -> pl.LazyFrame:
"""dump 行 → (symbol, date, close) lazy 投影; adjusted 列存在时锁 none。"""
if "adjusted" in lf.collect_schema().names():
lf = lf.filter(pl.col("adjusted") == "none")
return lf.select(
pl.col("thscode").alias("symbol"),
pl.from_epoch(pl.col("date_ms").cast(pl.Int64) + _SH_MS, time_unit="ms")
.dt.date()
.alias("date"),
pl.col("close_price").cast(pl.Float64).alias("close"),
).filter(pl.col("close").is_not_null())
# ---- 测试(设置页试拉) ----
def test_dataset(self, dataset: str, symbols: list[str] | None = None) -> dict:
if dataset in ("daily", "adj_factor"):
syms = [s for s in (symbols or [])][:3] or ["000001.SZ"]
try:
if dataset == "daily":
df = self.get_daily(syms, datetime.now() - timedelta(days=30), datetime.now())
else:
df = self.get_adj_factors(
syms, datetime.now() - timedelta(days=365), datetime.now()
)
except FuyaoError as e:
return {"provider": self.name, "dataset": dataset, "rows": 0, "error": str(e)}
head = df.head(5).to_dicts()
for row in head: # date/datetime → ISO 字符串, 保证 JSON 可序列化
for k, v in list(row.items()):
if isinstance(v, (date, datetime)):
row[k] = v.isoformat()
return {
"provider": self.name,
"dataset": dataset,
"rows": df.height,
"columns": df.columns,
"preview": head,
}
if dataset == "financial":
syms = [s for s in (symbols or [])][:1] or ["600519.SH"]
try:
df = self.get_financials("metrics", syms, latest_only=True)
except FuyaoError as e:
return {"provider": self.name, "dataset": dataset, "rows": 0, "error": str(e)}
head = df.head(5).to_dicts()
return {
"provider": self.name,
"dataset": dataset,
"rows": df.height,
"columns": df.columns,
"preview": head,
}
if dataset != "realtime":
return {"provider": self.name, "dataset": dataset, "rows": 0,
"error": f"扶摇插件未接入 {dataset} 数据集(自动回退 TickFlow)"}
return {
"provider": self.name,
"dataset": dataset,
"rows": 0,
"error": f"扶摇插件未接入 {dataset} 数据集(自动回退 TickFlow)",
}
try:
rows, count = self._get_client().snapshot_page(limit=5)
except FuyaoError as e:
+77 -5
View File
@@ -12,7 +12,8 @@ from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timedelta
from datetime import time as dtime
import polars as pl
@@ -51,6 +52,9 @@ class StockSDKProvider:
name = "stocksdk"
builtin = True
# 分钟历史深度能力(可选声明, 未声明视为深历史): stock-sdk 免费分时接口
# 只保留最近 5 个交易日的 1 分钟数据, 分时档位/默认值据此收窄。
minute_history_days = 5
def __init__(self) -> None:
self.config = _StockSDKConfig()
@@ -145,15 +149,47 @@ class StockSDKProvider:
return pl.DataFrame()
period = "".join(ch for ch in str(freq) if ch.isdigit()) or "1"
logger.info("stock-sdk minute 拉取开始(%d symbols, period=%s)", len(symbols), period)
# 上游区间查询的分钟 open 为日级常量(伪值), 单日查询才给最新交易日真实
# 分钟 open → 末尾 3 个自然日逐日单拉(跳过周末, 覆盖周五收盘后场景),
# 其余历史段仍走单个区间任务控制桥接成本。伪 open 由 _null_degenerate_opens
# 在 _minute_df 内置 null。
windows: list[tuple[datetime | None, datetime | None]] = []
if (
start_time is not None
and end_time is not None
and end_time.date() > start_time.date()
):
tail_start = end_time.date() - timedelta(days=3)
if tail_start > start_time.date():
head_end = datetime.combine(tail_start - timedelta(days=1), dtime.max)
windows.append((start_time, head_end))
else:
tail_start = start_time.date()
day = tail_start
while day <= end_time.date():
if day.weekday() < 5:
windows.append((
datetime.combine(day, dtime.min),
datetime.combine(day, dtime.max),
))
day += timedelta(days=1)
else:
windows.append((start_time, end_time))
frames: list[pl.DataFrame] = []
chunks = chunked(symbols, _BATCH)
for i, chunk in enumerate(chunks):
total = len(chunks) * len(windows)
step = 0
for win_start, win_end in windows:
for chunk in chunks:
step += 1
job = {
"op": "minute",
"symbols": chunk,
"period": period,
"start": _yyyymmdd(start_time),
"end": _yyyymmdd(end_time),
"start": _yyyymmdd(win_start),
"end": _yyyymmdd(win_end),
}
try:
result = bridge.run_job(job, timeout=180)
@@ -165,9 +201,44 @@ class StockSDKProvider:
if not df.is_empty():
frames.append(df)
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
on_chunk_done(step, total)
# 末窗口(最新一日)的真实 open 与首窗口可能重叠同日(时区/边界), keep="last"
# 由上层 _write_minute_partition 的 unique 处理; 这里仅拼接。
return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
@staticmethod
def _null_degenerate_opens(df: pl.DataFrame) -> pl.DataFrame:
""""日级常量"的伪分钟 open 置 null。
stock-sdk 上游对历史日的分钟 open 只给全天常量(如涨跌停价/日开),
并非真实分钟开盘价; 只有最新交易日在单日查询下给真实值 open 入库
会让分钟K的 close-vs-open 口径全偏(如分时量恒红), 故按日检测:
同日 rows>10 open 唯一值<=3 close 唯一值>10 open 判定非分钟级,
null(fail-closed, 不伪造 prev_close 替代)
"""
if df.is_empty() or "open" not in df.columns or "datetime" not in df.columns:
return df
stats = df.group_by(pl.col("datetime").dt.date()).agg(
pl.len().alias("n"),
pl.col("open").n_unique().alias("uo"),
pl.col("close").n_unique().alias("uc"),
)
fake_dates = stats.filter(
(pl.col("n") > 10) & (pl.col("uo") <= 3) & (pl.col("uc") > 10)
)["datetime"]
if fake_dates.is_empty():
return df
logger.warning(
"stock-sdk minute open 为日级常量, 置 null: %s %s",
df["symbol"][0] if "symbol" in df.columns else "?", fake_dates.to_list(),
)
return df.with_columns(
pl.when(pl.col("datetime").dt.date().is_in(fake_dates))
.then(None)
.otherwise(pl.col("open"))
.alias("open")
)
@staticmethod
def _minute_df(rows: list[dict], symbol: str) -> pl.DataFrame:
if not rows:
@@ -192,6 +263,7 @@ class StockSDKProvider:
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 = StockSDKProvider._null_degenerate_opens(df)
keep = [c for c in _MINUTE_CANONICAL if c in df.columns]
return df.select(keep) if "datetime" in keep else pl.DataFrame()
+57
View File
@@ -223,3 +223,60 @@ def build_overview(
"counts": counts,
"rows": out_rows[:limit],
}
# ================================================================
# 盘中异动 (量价信号聚合, 异动监控「盘中」tab)
#
# 数据源: enriched 最新快照的当日消息号列 (零新增采集):
# 涨停/跌停/跌停翘板/炸板/放量(量比≥2)/创60日新高/新低。
# 行序 = 信号优先级 (涨停 > 炸板 > 翘板 > 跌停 > 新高 > 新低 > 放量),
# 同级按 |今日涨跌| 降序; counts 供前端筛选 chips 展示各类型数量。
# ================================================================
_INTRADAY_SIGNALS: tuple[tuple[str, str], ...] = (
("signal_limit_up", "limit_up"),
("signal_broken_limit_up", "broken"),
("signal_limit_down_recovery", "recovery"),
("signal_limit_down", "limit_down"),
("signal_n_day_high", "new_high"),
("signal_n_day_low", "new_low"),
("signal_volume_surge", "volume_surge"),
)
_INTRADAY_PRIORITY = {key: i for i, (_, key) in enumerate(_INTRADAY_SIGNALS)}
_INTRADAY_COLS = ("symbol", "name", "close", "change_pct", "amplitude",
"vol_ratio_5d", "turnover_rate", "consecutive_limit_ups")
def build_intraday(repo: Any, limit: int = 500) -> dict[str, Any]:
"""enriched 最新快照 → 当日异动信号命中行 (含各类型计数)。"""
df, cache_date = repo.get_enriched_latest()
empty = {"cache_date": cache_date.isoformat() if cache_date else None,
"counts": {}, "rows": []}
if df.is_empty() or "symbol" not in df.columns:
return empty
present = [(c, k) for c, k in _INTRADAY_SIGNALS if c in df.columns]
if not present:
return empty
hits = df.filter(pl.any_horizontal([pl.col(c).fill_null(False) for c, _ in present]))
if hits.is_empty():
return empty
counts = {k: int(hits[c].fill_null(False).sum()) for c, k in present}
sig_cols = {k: hits[c].fill_null(False).to_list() for c, k in present}
base_cols = [c for c in _INTRADAY_COLS if c in hits.columns]
base = hits.select(base_cols).to_dicts()
rows: list[dict[str, Any]] = []
for i, r in enumerate(base):
signals = [k for k, flags in sig_cols.items() if flags[i]]
rows.append({
**{c: r.get(c) for c in base_cols},
"signals": signals,
"_prio": min((_INTRADAY_PRIORITY[s] for s in signals), default=99),
})
rows.sort(key=lambda r: (r["_prio"], -abs(r.get("change_pct") or 0.0)))
for r in rows:
r.pop("_prio", None)
return {"cache_date": cache_date.isoformat() if cache_date else None,
"counts": counts, "rows": rows[:limit]}
+288
View File
@@ -0,0 +1,288 @@
"""短线风向标服务 (fuyao 专有) — 复盘页卡片 + AI 复盘上下文。
非路由数据集: tickflow 无对应能力, 直接经 custom_sources fuyao provider;
fuyao 未配置时返回 source_unavailable 状态, 前端降级提示
数据契约 (实测 2026-08-30, 60 交易日回测 353 样本):
- 每日 5~6 , 服务端筛选的竞价异动股, 附概念标签
- 名单当日 (开盘买收盘卖) 均值 +0.54% vs 全市场 +0.10%, 有真实当日选股能力;
但高开5% 子集当日 -1.97% (追高陷阱) 前端对高开子集标追高风险
- 次日无显著优势 (+0.08%), 定位为当日观察名单而非隔夜轮动信号
缓存策略 ( dragon_tiger 同模式):
- 历史名单不可变 按日落 JSON 缓存 (data/auction_benchmark/date=YYYY-MM-DD.json),
缓存命中不触发插件注册表加载
- 收益 enrich (当日oc/全天/次日) 不落缓存 次日数据晚到, 读取时现算
- 当日名单不缓存 (竞价阶段名单可能变动, 以现拉为准)
- 显式日期失败 回退上一交易日一次 (state=fallback_prev)
日期解析: 接口对显式非交易日报 code=1002, 本层用本地 kline_daily 分区日期
把目标日回退到目标日的最近交易日, 规避报错
"""
from __future__ import annotations
import contextlib
import json
import logging
import re
from datetime import date as date_cls
from pathlib import Path
import polars as pl
from app.market_time import cn_today
logger = logging.getLogger(__name__)
_DATE_DIR_RE = re.compile(r"^date=(\d{4}-\d{2}-\d{2})$")
def _local_trading_days(data_dir: Path) -> list[date_cls]:
"""本地日K分区日期 = 已知交易日集合 (升序)。扫描失败返回空。"""
root = data_dir / "kline_daily"
out: list[date_cls] = []
try:
for d in root.iterdir():
m = _DATE_DIR_RE.match(d.name)
if d.is_dir() and m:
try:
out.append(date_cls.fromisoformat(m.group(1)))
except ValueError:
continue
except OSError:
return []
return sorted(out)
def resolve_trade_date(data_dir: Path, target: date_cls | None) -> date_cls | None:
"""目标日 → ≤目标日的最近本地交易日。None → None (由 fuyao 取当日)。"""
if target is None:
return None
days = _local_trading_days(data_dir)
if not days:
return target
candidates = [d for d in days if d <= target]
return max(candidates) if candidates else target
def _prev_trading_day(data_dir: Path, d: date_cls) -> date_cls | None:
days = _local_trading_days(data_dir)
earlier = [x for x in days if x < d]
return max(earlier) if earlier else None
def _next_trading_day(data_dir: Path, d: date_cls) -> date_cls | None:
days = _local_trading_days(data_dir)
later = [x for x in days if x > d]
return min(later) if later else None
def _provider():
from app.data_providers import custom as custom_sources
if not custom_sources.is_custom_provider("fuyao"):
return None
return custom_sources.get_provider("fuyao")
def _cache_path(data_dir: Path, d: date_cls) -> Path:
return data_dir / "auction_benchmark" / f"date={d.isoformat()}.json"
def _load_cache(path: Path) -> dict | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _store_cache(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".part")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
def _raw_items(data: dict) -> list[dict]:
items = data.get("item")
return [r for r in items if isinstance(r, dict)] if isinstance(items, list) else []
def _read_kline_closes(data_dir: Path, d: date_cls) -> dict[str, float]:
"""某交易日全市场 {symbol: close}; 无分区/读失败返回空。"""
root = data_dir / "kline_daily" / f"date={d.isoformat()}"
try:
files = sorted(root.glob("*.parquet"))
if not files:
return {}
df = pl.concat([pl.read_parquet(f, columns=["symbol", "close"]) for f in files])
return dict(zip(df["symbol"].to_list(), df["close"].to_list()))
except (OSError, pl.exceptions.PolarsError):
return {}
def _enrich(data_dir: Path, trade_date: date_cls, items: list[dict]) -> list[dict]:
"""名单叠加真实收益: day0_oc (开盘买→收盘卖) / day0_pct (全天) / d1_pct (次日)。
用相邻 kline_daily 分区现算, 不落缓存 次日分区晚到时先给 None, 到了自然补上
"""
root = data_dir / "kline_daily" / f"date={trade_date.isoformat()}"
day0: dict[str, tuple[float, float]] = {} # symbol -> (open, close)
try:
files = sorted(root.glob("*.parquet"))
if files:
df = pl.concat([pl.read_parquet(f, columns=["symbol", "open", "close"]) for f in files])
day0 = dict(zip(df["symbol"].to_list(), zip(df["open"].to_list(), df["close"].to_list())))
except (OSError, pl.exceptions.PolarsError):
day0 = {}
prev = _prev_trading_day(data_dir, trade_date)
prev_close = _read_kline_closes(data_dir, prev) if prev else {}
nxt = _next_trading_day(data_dir, trade_date)
next_close = _read_kline_closes(data_dir, nxt) if nxt else {}
out: list[dict] = []
for r in items:
sym = str(r.get("thscode") or "")
oc = pct = d1 = None
if sym in day0:
o, c = day0[sym]
if o and o > 0 and c is not None:
oc = c / o - 1.0
pc = prev_close.get(sym)
if pc and pc > 0 and c is not None:
pct = c / pc - 1.0
nc = next_close.get(sym)
if c and c > 0 and nc is not None:
d1 = nc / c - 1.0
out.append({
"thscode": sym,
"ticker": r.get("ticker"),
"name": r.get("name"),
"auction_pct": r.get("auction_pct"),
"tags": [str(t) for t in (r.get("tags") or [])],
"day0_oc": oc,
"day0_pct": pct,
"d1_pct": d1,
})
return out
def _base_payload(data_dir: Path, trade_date: date_cls, data: dict) -> dict:
return {
"state": "ok",
"requested_date": trade_date.isoformat(),
"trade_date": str(data.get("date") or trade_date.isoformat()),
"count": len(_raw_items(data)),
"raw_items": _raw_items(data),
}
def get_auction_benchmark(data_dir: Path, target: date_cls | None = None) -> dict:
"""取短线风向标名单 (含当日/次日真实收益)。返回给前端的统一容器。
state: ok (正常) | fallback_prev (目标日拉取失败, 已回退上一期)
| source_unavailable (未配置 fuyao) | no_data (拉取失败)
"""
days = _local_trading_days(data_dir)
if target is None:
target = max(days) if days else None
trade_date = resolve_trade_date(data_dir, target)
today = cn_today()
# 历史日缓存优先 (纯本地, 不触发插件注册表加载)
if trade_date is not None and trade_date < today:
cached = _load_cache(_cache_path(data_dir, trade_date))
if cached is not None:
return _respond(data_dir, trade_date, cached, cached.get("state") or "ok")
provider = _provider()
if provider is None:
return {"state": "source_unavailable"}
from app.plugins.fuyao.client import FuyaoError
explicit = trade_date.isoformat() if trade_date is not None else None
try:
data = provider.short_term_benchmark(explicit)
try:
actual = date_cls.fromisoformat(str(data.get("date")))
except ValueError:
actual = trade_date
base = _base_payload(data_dir, actual, data)
# 历史日不可变 → 落缓存; 当日不缓存 (竞价阶段名单可能变动)
if actual is not None and actual < today:
with contextlib.suppress(OSError):
_store_cache(_cache_path(data_dir, actual), base)
return _respond(data_dir, actual, base, "ok")
except FuyaoError as e:
# 显式日期失败 (非交易日/边界日) → 回退上一交易日一次
if trade_date is not None:
prev = _prev_trading_day(data_dir, trade_date)
if prev is not None:
try:
cached_prev = _load_cache(_cache_path(data_dir, prev))
if cached_prev is not None:
return _respond(data_dir, prev, cached_prev, "fallback_prev",
requested=explicit)
data_prev = provider.short_term_benchmark(prev.isoformat())
base = _base_payload(data_dir, prev, data_prev)
with contextlib.suppress(OSError):
_store_cache(_cache_path(data_dir, prev), base)
return _respond(data_dir, prev, base, "fallback_prev",
requested=explicit)
except FuyaoError:
pass
logger.warning("短线风向标拉取失败: %s", e)
return {"state": "no_data", "message": str(e)}
def _respond(
data_dir: Path,
trade_date: date_cls | None,
base: dict,
state: str,
requested: str | None = None,
) -> dict:
"""缓存/现拉的原始容器 → 叠加收益 enrich 后的前端容器。"""
if trade_date is None:
return {**base, "state": state}
payload = {
"state": state,
"requested_date": requested if requested is not None else base.get("requested_date"),
"trade_date": base.get("trade_date") or trade_date.isoformat(),
"count": base.get("count") or 0,
"items": _enrich(data_dir, trade_date, base.get("raw_items") or []),
}
return payload
def build_recap_context(data_dir: Path) -> str:
"""AI 复盘的盘前风向标摘要段 (纯文本, 失败返回空串不影响复盘)。"""
try:
payload = get_auction_benchmark(data_dir, None)
if payload.get("state") not in ("ok", "fallback_prev"):
return ""
items = payload.get("items") or []
if not items:
return ""
trade_date = payload.get("trade_date") or ""
lines = [f"(数据日期: {trade_date})"]
segs = []
for i in items:
seg = (f"{i.get('name')}({i.get('thscode')}) 竞价{(i.get('auction_pct') or 0):+.2f}%"
f"[{'·'.join(i.get('tags') or [])}]")
if i.get("day0_oc") is not None:
seg += f" → 当日开盘买{i['day0_oc']*100:+.2f}%"
if i.get("d1_pct") is not None:
seg += f", 次日{i['d1_pct']*100:+.2f}%"
segs.append(seg)
lines.append("盘前风向标名单: " + "; ".join(segs))
ocs = [i["day0_oc"] for i in items if i.get("day0_oc") is not None]
if ocs:
lines.append(f"名单当日(开盘买→收盘卖)均值 {sum(ocs)/len(ocs)*100:+.2f}%")
return "\n".join(lines)
except Exception as e: # noqa: BLE001 — 摘要失败不影响复盘主流程
logger.debug("盘前风向标复盘摘要构建失败: %s", e)
return ""
+227
View File
@@ -0,0 +1,227 @@
"""龙虎榜数据服务 (fuyao 专有) — 复盘页卡片 + AI 复盘上下文。
非路由数据集: tickflow 无对应能力, 直接经 custom_sources fuyao provider;
fuyao 未配置时返回 source_unavailable 状态, 前端降级提示
数据契约 (实测 2026-08, 文档的 limit_reason/amount 实际不返回):
- 三榜一次取齐: all/org 为股票表 (org 额外带机构 4 字段), hot_money 为席位表
- 历史榜单不可变 按日落 JSON 缓存 (data/dragon_tiger/date=YYYY-MM-DD.json)
- 当日榜单盘中未发布 自动回退上一交易日并标记 state=fallback_prev
日期解析: 接口对显式非交易日报 code=1002, 本层用本地 kline_daily 分区日期
把目标日回退到目标日的最近交易日, 规避报错
"""
from __future__ import annotations
import contextlib
import json
import logging
import re
from datetime import date as date_cls
from pathlib import Path
from app.market_time import cn_today
logger = logging.getLogger(__name__)
_BOARDS = ("all", "org", "hot_money")
_DATE_DIR_RE = re.compile(r"^date=(\d{4}-\d{2}-\d{2})$")
def _local_trading_days(data_dir: Path) -> list[date_cls]:
"""本地日K分区日期 = 已知交易日集合 (升序)。扫描失败返回空。"""
root = data_dir / "kline_daily"
out: list[date_cls] = []
try:
for d in root.iterdir():
m = _DATE_DIR_RE.match(d.name)
if d.is_dir() and m:
try:
out.append(date_cls.fromisoformat(m.group(1)))
except ValueError:
continue
except OSError:
return []
return sorted(out)
def resolve_trade_date(data_dir: Path, target: date_cls | None) -> date_cls | None:
"""目标日 → ≤目标日的最近本地交易日。None 或本地无更早分区 → None (由 fuyao 默认)。
目标日早于全部本地分区 (极老的历史复盘) 原样返回, 由调用方试显式日期,
失败如实报 no_data, 不静默给错日期的数据
"""
if target is None:
return None
days = _local_trading_days(data_dir)
if not days:
return target
candidates = [d for d in days if d <= target]
return max(candidates) if candidates else target
def _prev_trading_day(data_dir: Path, d: date_cls) -> date_cls | None:
days = _local_trading_days(data_dir)
earlier = [x for x in days if x < d]
return max(earlier) if earlier else None
def _provider():
from app.data_providers import custom as custom_sources
if not custom_sources.is_custom_provider("fuyao"):
return None
return custom_sources.get_provider("fuyao")
def _fetch_boards(provider, d: date_cls) -> dict:
"""三榜取齐。任一榜失败抛 FuyaoError (整日失败, 不留半套缓存)。"""
return {
bt: _boards_of(provider, bt, d.isoformat()) for bt in _BOARDS
}
def _boards_of(provider, board_type: str, iso: str | None) -> dict:
data = provider.dragon_tiger(board_type, iso)
return {
"trade_date": data.get("trade_date"),
"stock_count": data.get("stock_count"),
"count": data.get("count"),
"stock_items": data.get("stock_items") or [],
"hot_money_items": data.get("hot_money_items") or [],
}
def _cache_path(data_dir: Path, d: date_cls) -> Path:
return data_dir / "dragon_tiger" / f"date={d.isoformat()}.json"
def _load_cache(path: Path) -> dict | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _store_cache(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".part")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
def get_dragon_tiger(data_dir: Path, target: date_cls | None = None) -> dict:
"""取龙虎榜 (三榜)。返回给前端的统一容器。
state: ok (正常) | fallback_prev (当日未发布, 已回退上一期并带上一期数据)
| source_unavailable (未配置 fuyao) | no_data (拉取失败)
"""
# 无目标日 → 最近本地交易日 (走缓存判定; 本地无任何分区才让 fuyao 自取默认)
if target is None:
days = _local_trading_days(data_dir)
target = max(days) if days else None
trade_date = resolve_trade_date(data_dir, target)
today = cn_today()
# 历史日缓存优先 (纯本地, 不触发插件注册表加载)
if trade_date is not None and trade_date < today:
cached = _load_cache(_cache_path(data_dir, trade_date))
if cached is not None:
return cached
provider = _provider()
if provider is None:
return {"state": "source_unavailable"}
from app.plugins.fuyao.client import FuyaoError
# 日期未知 (target=None / 本地无分区) → 省略 date 让 fuyao 取最近已发布交易日
explicit = trade_date.isoformat() if trade_date is not None else None
try:
raw = {bt: _boards_of(provider, bt, explicit) for bt in _BOARDS}
try:
actual = date_cls.fromisoformat(str(raw["all"].get("trade_date")))
except ValueError:
actual = None
payload = {
"state": "ok",
"requested_date": explicit,
"trade_date": raw["all"].get("trade_date"),
**raw,
}
# 历史日不可变 → 落缓存; 当日不缓存 (盘中 fallback / 盘后补充都以现拉为准)
if actual is not None and actual < today:
with contextlib.suppress(OSError):
_store_cache(_cache_path(data_dir, actual), payload)
return payload
except FuyaoError as e:
# 显式日期失败 (当日未发布 / 边界日) → 回退上一交易日一次
if trade_date is not None:
prev = _prev_trading_day(data_dir, trade_date)
if prev is not None:
try:
cached_prev = _load_cache(_cache_path(data_dir, prev))
if cached_prev is not None:
base = dict(cached_prev)
base.pop("state", None)
return {**base, "state": "fallback_prev",
"requested_date": explicit}
raw_prev = _fetch_boards(provider, prev)
base = {
"requested_date": prev.isoformat(),
"trade_date": prev.isoformat(),
**raw_prev,
}
with contextlib.suppress(OSError):
_store_cache(_cache_path(data_dir, prev), {**base, "state": "ok"})
return {**base, "state": "fallback_prev",
"requested_date": explicit}
except FuyaoError:
pass
logger.warning("龙虎榜拉取失败: %s", e)
return {"state": "no_data", "message": str(e)}
def build_recap_context(data_dir: Path) -> str:
"""AI 复盘的龙虎榜摘要段 (纯文本, 失败返回空串不影响复盘)。"""
try:
payload = get_dragon_tiger(data_dir, None)
if payload.get("state") not in ("ok", "fallback_prev"):
return ""
items = payload.get("all", {}).get("stock_items") or []
org_items = payload.get("org", {}).get("stock_items") or []
hm_items = payload.get("hot_money", {}).get("hot_money_items") or []
trade_date = payload.get("trade_date") or ""
lines = [f"(数据日期: {trade_date})"]
top_buy = sorted(
[i for i in items if (i.get("net_value") or 0) > 0],
key=lambda x: x.get("net_value") or 0, reverse=True,
)[:5]
if top_buy:
lines.append("净买入居前: " + "; ".join(
f"{i.get('name')}({i.get('thscode')}) 净买{float(i.get('net_value') or 0)/1e8:.2f}亿"
f" 涨跌{(i.get('change') or 0)*100:.1f}%"
+ (" [3日榜]" if i.get("range_days") == 3 else "")
for i in top_buy))
top_sell = sorted(
[i for i in items if (i.get("net_value") or 0) < 0],
key=lambda x: x.get("net_value") or 0,
)[:5]
if top_sell:
lines.append("净卖出居前: " + "; ".join(
f"{i.get('name')} 净卖{abs(float(i.get('net_value') or 0))/1e8:.2f}亿"
for i in top_sell))
if org_items:
lines.append("机构净买居前: " + "; ".join(
f"{i.get('name')} 机构净买{float(i.get('org_net_value') or 0)/1e8:.2f}亿"
for i in sorted(org_items, key=lambda x: x.get("org_net_value") or 0, reverse=True)[:5]))
if hm_items:
lines.append("活跃游资: " + "; ".join(
f"{h.get('name')} 净买{float(h.get('buying') or 0)/1e8:.2f}亿"
for h in sorted(hm_items, key=lambda x: x.get('buying') or 0, reverse=True)[:5]))
return "\n".join(lines)
except Exception as e: # noqa: BLE001 — 摘要失败不影响复盘主流程
logger.debug("龙虎榜复盘摘要构建失败: %s", e)
return ""
-2
View File
@@ -167,8 +167,6 @@ def run_extend_history(
from app.services import preferences as _prefs
adj_provider = _prefs.get_adj_factor_provider()
if adj_provider == "same_as_daily":
adj_provider = _prefs.get_daily_data_provider()
can_sync_adj = capset.has(Cap.ADJ_FACTOR) or adj_provider != "tickflow"
if can_sync_adj:
emit("extend_history", 48, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…")
+16 -5
View File
@@ -155,6 +155,13 @@ def _sync_table(
def _merge_report_history(*frames: pl.DataFrame) -> pl.DataFrame:
"""按 (symbol, period_end) 合并各报告期, 同期多行逐列取最新非空值。
语义(区分"覆盖""填空"): 每列独立取 announce_date 最新的非空值
新同步行有值则覆盖旧值, 新行缺的列( fuyao 不提供的字段)由旧行补齐,
实现多数据源并集共存历史报告期不可变, 合并不会引入过期数据
announce_date 的帧按输入顺序, 后写优先(与旧行为 keep="last" 一致)
"""
valid = [
frame
for frame in frames
@@ -166,11 +173,15 @@ def _merge_report_history(*frames: pl.DataFrame) -> pl.DataFrame:
pl.concat(valid, how="diagonal_relaxed")
.filter(pl.col("symbol").is_not_null() & pl.col("period_end").is_not_null())
)
# 同一 (symbol, period_end) 多条时保留 announce_date 最新一条 (业绩修正以最新公告为准)。
if "announce_date" in merged.columns:
merged = merged.sort(["symbol", "period_end", "announce_date"], nulls_last=True)
return merged.unique(subset=["symbol", "period_end"], keep="last").sort(
["symbol", "period_end"]
sort_keys = ["symbol", "period_end"] + (
["announce_date"] if "announce_date" in merged.columns else []
)
merged = merged.sort(sort_keys, nulls_last=True)
value_cols = [c for c in merged.columns if c not in ("symbol", "period_end")]
return (
merged.group_by("symbol", "period_end")
.agg([pl.col(c).drop_nulls().last() for c in value_cols])
.sort(["symbol", "period_end"])
)
+201 -9
View File
@@ -20,7 +20,7 @@ from app.services import preferences
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.client import get_client
from app.tickflow.rate_limits import chunked, resolve_limit, sleep_between_batches
from app.tickflow.repository import KlineRepository
from app.tickflow.repository import KlineRepository, replace_with_retry
logger = logging.getLogger(__name__)
@@ -32,11 +32,12 @@ def _atomic_write_parquet(df: pl.DataFrame, out) -> None:
单文件每次concat原地写, 直接 write_parquet(out) 在进程被 kill
(dev.sh 清端口用 kill -9)reap 超时或断电时会留下半截文件, 之后复权视图
scan_parquet 整条链路报错enriched 全市场重算不出临时文件后缀 .tmp 不匹配
*.parquet glob, 不会被扫描误读
*.parquet glob, 不会被扫描误读Windows 下目标正被并发读取时由
replace_with_retry 短退避穿过
"""
tmp = out.with_name(out.name + ".tmp")
df.write_parquet(tmp)
tmp.replace(out) # 同目录 rename, POSIX/NTFS 均为原子操作
replace_with_retry(tmp, out)
# 标准列(无论 SDK 返回什么形状,我们把它规范成这套)
@@ -335,8 +336,6 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
return 0, []
provider_name = preferences.get_adj_factor_provider()
if provider_name == "same_as_daily":
provider_name = preferences.get_daily_data_provider()
if provider_name != "tickflow":
from app.data_providers import custom as custom_sources
if custom_sources.provider_has_dataset(provider_name, "adj_factor"):
@@ -446,8 +445,80 @@ CANONICAL_MINUTE_COLS = [
]
# 北京墙钟特征时段(含集合竞价 09:15 与收盘 15:00): 上午 09-11, 下午 13-15
_BJ_HOURS = [9, 10, 11, 13, 14, 15]
# 上述时段 -8h 的 UTC 墙钟特征: 上午 01-03, 下午 05-07
_UTC_SHIFTED_HOURS = [1, 2, 3, 5, 6, 7]
def _enforce_minute_beijing_wallclock(df: pl.DataFrame, *, source: str) -> pl.DataFrame:
"""分钟 K datetime 时区契约守卫: 统一为北京墙钟 (naive)。
契约 (CONTRIBUTING §3.3): kline_minute.datetime 必须是北京时间墙钟, 09:35:00
在两个源头入口强制 _normalize_minute (TickFlow ) _try_custom_minute
(插件/自定义源帧); 落盘 (_write_minute_partition) 与内存消费 (监控/补拉/脉冲)
均在其下游, 这里收口即全覆盖:
- tz-aware Asia/Shanghai 后去时区;
- naive 且时刻落在 A 股交易时段 直通 (已是北京墙钟);
- naive 且整体呈"交易时段 -8h" UTC 特征 自动 +8 纠偏并记日志;
- 无法识别的口径 fail-closed ValueError, 不让脏时间入库或下发
幂等: 纠偏后的帧再过守卫直通, 不会二次改写
"""
if df.is_empty() or "datetime" not in df.columns:
return df
dtype = df.schema["datetime"]
if not isinstance(dtype, pl.Datetime):
# trade_time 等字符串路径: 先解析成 Datetime (失败置 null), 再做时段分类
if dtype == pl.Utf8:
df = df.with_columns(pl.col("datetime").str.to_datetime(strict=False))
else:
df = df.with_columns(pl.col("datetime").cast(pl.Datetime("us"), strict=False))
dtype = df.schema["datetime"]
if not isinstance(dtype, pl.Datetime):
return df # 仍非 Datetime: 维持原行为交由下游处理
if isinstance(dtype, pl.Datetime) and dtype.time_zone is not None:
df = df.with_columns(
pl.col("datetime")
.dt.convert_time_zone("Asia/Shanghai")
.dt.replace_time_zone(None)
.cast(pl.Datetime("us"))
)
logger.info("minute datetime tz-aware input converted to Beijing wallclock (source=%s)", source)
return df
hour = pl.col("datetime").dt.hour()
beijing = int(df.select(hour.is_in(_BJ_HOURS).sum()).item() or 0)
utc_shifted = int(df.select(hour.is_in(_UTC_SHIFTED_HOURS).sum()).item() or 0)
if beijing == 0 and utc_shifted == 0:
if df["datetime"].null_count() == df.height:
return df # 全 null: 维持原行为, 由下游落盘过滤
raise ValueError(
f"minute datetime 口径无法识别 (source={source}, rows={df.height}, "
f"sample={df['datetime'].drop_nulls().head(2).to_list()}): "
"契约要求北京墙钟 (09:30-15:00), 既非交易时段也非 UTC 平移特征"
)
if utc_shifted > beijing:
if beijing:
logger.warning(
"minute datetime mixed convention, shifting all by +8h per UTC majority "
"(source=%s, utc=%d, beijing=%d)", source, utc_shifted, beijing,
)
else:
logger.info(
"minute datetime UTC wallclock detected, shifted +8h to Beijing "
"(source=%s, rows=%d)", source, utc_shifted,
)
return df.with_columns(pl.col("datetime") + pl.duration(hours=8))
if utc_shifted:
logger.warning(
"minute datetime has %d UTC-like rows among %d Beijing rows, left as-is "
"(source=%s)", utc_shifted, beijing, source,
)
return df
def _normalize_minute(df_in, default_symbol: str | None = None) -> pl.DataFrame:
"""把 SDK 返回的分钟 K 数据规范成 canonical 列。"""
"""把 SDK 返回的分钟 K 数据规范成 canonical 列 (datetime 收口为北京墙钟)"""
if df_in is None or len(df_in) == 0:
return pl.DataFrame()
@@ -465,8 +536,14 @@ def _normalize_minute(df_in, default_symbol: str | None = None) -> pl.DataFrame:
# datetime 列:优先用 timestamp(毫秒精度),其次 trade_time
if "timestamp" in df.columns:
# TickFlow 毫秒时间戳为 UTC 基准; 契约要求北京墙钟 naive
# (与 stock-sdk provider 归一口径一致, 见 CONTRIBUTING §3.3)
df = df.with_columns(
pl.from_epoch("timestamp", time_unit="ms").alias("datetime"),
pl.from_epoch(pl.col("timestamp").cast(pl.Int64), time_unit="ms")
.dt.replace_time_zone("UTC")
.dt.convert_time_zone("Asia/Shanghai")
.dt.replace_time_zone(None)
.alias("datetime")
).drop("timestamp")
for drop_col in ("trade_time", "trade_date"):
if drop_col in df.columns:
@@ -478,6 +555,10 @@ def _normalize_minute(df_in, default_symbol: str | None = None) -> pl.DataFrame:
elif "trade_date" in df.columns:
df = df.rename({"trade_date": "datetime"})
if "datetime" in df.columns:
# 时区契约守卫: 须在下方通用 us-cast 之前, 避免带时区列被静默剥成 UTC-naive
df = _enforce_minute_beijing_wallclock(df, source="tickflow")
if "symbol" not in df.columns and default_symbol is not None:
df = df.with_columns(pl.lit(default_symbol).alias("symbol"))
@@ -606,11 +687,18 @@ def _try_custom_minute(
symbols, start_time=start_time, end_time=end_time,
asset_type=asset_type, freq=freq, on_chunk_done=wrapped_cb,
)
return (df, False)
except Exception as e: # noqa: BLE001
except Exception as e:
logger.warning("custom minute provider %s call failed, falling back to TickFlow: %s",
provider_name, e)
return (None, True)
try:
# 时区契约守卫: 插件/自定义源帧同样收口为北京墙钟 (CONTRIBUTING §3.3)
df = _enforce_minute_beijing_wallclock(df, source=provider_name)
except Exception as e:
logger.warning("custom minute provider %s datetime 契约校验失败, falling back to TickFlow: %s",
provider_name, e)
return (None, True)
return (df, False)
def sync_minute_batch(
@@ -828,6 +916,110 @@ def fetch_intraday_monitor_batch(
return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
def fetch_intraday_full_market_burst(
symbols: list[str],
capset: CapabilitySet | None,
*,
count: int = 300,
) -> tuple[pl.DataFrame, int]:
"""全市场当日分钟K并发脉冲拉取 (盘中增量刷新专用, 不落盘)。
fetch_intraday_monitor_batch 的区别:
- 监控路径每轮只拉少量标的 ( batch 上限, 单请求);
本函数按 batch_size 把全市场切块后用线程池一次全部打出
(5546/200 = 28 并发), 配合 >=60s 的固定轮节奏, 任何 60s
滑动窗口至多一个脉冲 (28 < 48 安全 rpm)
- 单块失败不拖垮整轮: 成功块照常返回落盘; 失败块立即单独重试一次,
仍失败则跳过该块 (本函数每轮拉全天, 下一轮天然自愈)失败块过多
(>4, 系统性故障/限流风暴) 时跳过重试, 避免向已过载的服务端加压
限流口径: 只用 intraday.batch 独立池 (Cap.INTRADAY_BATCH, Expert 专有),
不与 kline.minute.batch (盘后分钟同步) 共享配额
返回 (当日全市场分钟K, 请求数)
"""
if not symbols:
return (pl.DataFrame(), 0)
limits = capset.limits(Cap.INTRADAY_BATCH) if capset and capset.has(Cap.INTRADAY_BATCH) else None
batch_size = max(1, int(limits.batch) if limits and limits.batch else 200)
chunks = list(chunked(symbols, batch_size))
if not chunks:
return (pl.DataFrame(), 0)
from concurrent.futures import ThreadPoolExecutor
tf = get_client()
def _fetch(chunk: list[str]) -> tuple[list[pl.DataFrame], Exception | None]:
# 单块独立容错: 异常作为返回值上交而不是抛出, 避免一个块把整轮
# 已成功的数据一起拖垮 (pool.map 迭代中抛异常会废弃全部已收 frames)
try:
raw = tf.klines.intraday_batch(
chunk, count=count, as_dataframe=True, show_progress=False,
batch_size=len(chunk),
)
return (_normalize_intraday_raw(raw), None)
except Exception as e:
return ([], e)
frames: list[pl.DataFrame] = []
failed: list[list[str]] = []
with ThreadPoolExecutor(max_workers=min(len(chunks), 32)) as pool:
for chunk, (sub, err) in zip(chunks, pool.map(_fetch, chunks), strict=True):
if err is not None:
failed.append(chunk)
else:
frames.extend(sub)
requests = len(chunks)
if failed:
if len(failed) > 4:
missed = sum(len(chunk) for chunk in failed)
logger.warning(
"intraday burst: %d/%d chunks failed (%d symbols), systemic — skip retry, next round re-pulls full day",
len(failed), len(chunks), missed,
)
else:
logger.warning("intraday burst: %d/%d chunks failed, retrying once", len(failed), len(chunks))
for chunk in failed:
sub, err = _fetch(chunk)
requests += 1
if err is None:
frames.extend(sub)
else:
logger.warning(
"intraday burst: chunk retry still failed, skip %d symbols this round: %s",
len(chunk), err,
)
if not frames:
return (pl.DataFrame(), requests)
return (pl.concat(frames, how="diagonal_relaxed"), requests)
def fetch_intraday_universe_increment(
universe: str = "CN_Equity_A",
*,
count: int = 3,
) -> tuple[pl.DataFrame, int]:
"""全市场当日分钟K增量拉取 (盘中稳态轮专用, 不落盘)。
/v1/klines/intraday/universe: universe ID 一次请求返回全市场每只标的
最新 count 根分钟K (服务端实测上限 3 /标的), 替代稳态场景下 28 块并发
intraday.batch 脉冲 (请求量 281, 传输量 ~40 倍降)缺口回补
(冷启动/长时间断档/全天修复) 仍走 fetch_intraday_full_market_burst
返回 (增量分钟K, 请求数); 拉取失败返回空 df 由调用方按失败轮处理
"""
tf = get_client()
try:
raw = tf.klines.intraday_universe(universe, count=count, as_dataframe=True)
except Exception as e:
logger.warning("intraday universe fetch failed (%s): %s", universe, e)
return (pl.DataFrame(), 0)
frames = _normalize_intraday_raw(raw)
if not frames:
return (pl.DataFrame(), 0)
return (pl.concat(frames, how="diagonal_relaxed"), 1)
def fetch_minute_single(
symbol: str,
trade_date: date,
@@ -258,10 +258,12 @@ def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: in
store = ExtConfigStore(repo.store.data_dir)
groups: dict[str, dict[str, dict]] = {}
group_source: dict[str, str] = {} # 组名 → 首个命中的扩展字段 "configId.field" (看板成分股弹窗用)
for config in store.load_all():
field = _dimension_field(config, kind)
if not field:
continue
source_field = f"{config.id}.{field}"
for ext_row in _read_ext_rows(repo.store.data_dir, config, field):
quote = None
for key in _symbol_keys(ext_row, config):
@@ -277,6 +279,7 @@ def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: in
parts = value.split("-")
value = parts[level - 1] if level <= len(parts) else parts[-1]
groups.setdefault(value, {})[symbol] = quote
group_source.setdefault(value, source_field)
items = []
for name, by_symbol in groups.items():
@@ -293,6 +296,7 @@ def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: in
"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),
"source_field": group_source.get(name),
"leader": {
"symbol": leader.get("symbol"),
"name": leader.get("name"),
+29 -3
View File
@@ -179,8 +179,9 @@ def _build_emotion_block(overview: dict) -> str:
return "\n".join(lines)
def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
"""构建用户消息:复盘日期 + 市场数据精简切片 + 新闻 + 关注点。"""
def _build_user_prompt(overview: dict, news: list[dict], focus: str, lhb_context: str = "",
bench_context: str = "") -> str:
"""构建用户消息:复盘日期 + 市场数据精简切片 + 龙虎榜(可选) + 盘前风向标(可选) + 新闻 + 关注点。"""
as_of = overview.get("as_of") or "今日"
parts: list[str] = [
@@ -202,6 +203,22 @@ def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
_build_sector_block(overview.get("industry_rank"), "行业"),
]
# 龙虎榜资金动向 (fuyao 数据源, 摘要自带数据日期; 无数据源/失败时为空不占段)
if lhb_context:
parts.extend([
"",
"## 龙虎榜资金动向",
lhb_context,
])
# 盘前风向标 (fuyao 竞价筛选名单 + 当日实际表现对照; 失败为空不占段)
if bench_context:
parts.extend([
"",
"## 盘前风向标(竞价)",
bench_context,
])
if news:
news_lines = []
for i, n in enumerate(news[:8], 1):
@@ -300,7 +317,16 @@ async def recap_market_stream(
try:
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(overview, news or [], focus)
# 龙虎榜摘要 (fuyao 专有): 拉取失败/未配置 → 空串, 复盘主流程不受影响
from app.services import dragon_tiger as dragon_tiger_svc
lhb_ctx = dragon_tiger_svc.build_recap_context(repo.store.data_dir)
# 盘前风向标摘要 (fuyao 专有): 失败/未配置 → 空串
from app.services import auction_benchmark as auction_benchmark_svc
bench_ctx = auction_benchmark_svc.build_recap_context(repo.store.data_dir)
user_prompt = _build_user_prompt(overview, news or [], focus, lhb_ctx, bench_ctx)
got_content = False
async for delta in stream_ai_text(
[
+305
View File
@@ -0,0 +1,305 @@
"""盘中分钟K增量落盘服务 (Expert 专有)。
两段式拉取 ( feat/minute-strategy 方案):
- 全天修复轮: intraday.batch (日内分时批量) 并发脉冲一次拉全市场当日全部
分钟K 冷启动 ( 10 点才开服务, 9:30 起缺口) / 覆盖滞后超阈值 /
连续空轮自愈时触发
- 稳态增量轮: intraday.universe CN_Equity_A 标的池, 单请求返回全市场
每只最新 3 (服务端上限), _write_minute_partition
unique(symbol,datetime) 幂等合并滚出全天
单轮合并写入当日 kline_minute 分区, 供分钟策略 (minute_filter) 读到新鲜数据
设计约束:
- Expert 专有: 能力门控 Cap.INTRADAY_UNIVERSE (全量分钟) TickFlow
Expert 档具备, 天然排他 (自定义分钟源无此能力, 且服务本就让位插件)
- 修复轮并发脉冲: 全市场按 batch_size 分块 (5546/200 = 28 ) 一次打出
任何 60s 滑动窗口至多一个脉冲 (28 < 48 安全 rpm); 单块失败不拖垮整轮,
失败块单独重试一次 ( fetch_intraday_full_market_burst)
- 稳态轮单请求: 无脉冲并发, 间隔可低至 3s; 实际节奏 = max(间隔, 单轮完成),
服务端响应 ~5s 时自动退化为响应节奏, 不会重叠请求
- 固定节奏: 默认 6s 一轮 (clamp [3, 300]), 不补跑 (missed 轮次直接跳过)
- 仅连续竞价时段运行 (9:30-11:30 / 13:00-15:00), 午休/收盘自动暂停与恢复;
午休后恢复因覆盖滞后会多跑一次修复轮, 幂等无害
- 不与其他分钟能力冲突: 盘后分钟同步 (kline.minute.batch) / 分时监控路径
分属不同限流池; 落盘走 _write_minute_partition unique(symbol,datetime)
合并, 与盘后同步写同一分区安全幂等
- 数据源插件化让位: 配置了自定义分钟源 (minute_data_provider != tickflow)
服务不启动 盘中增量交由插件自管, 本服务不抢占
分层: 本模块只做调度/落盘/状态; TickFlow SDK 调用全部在 kline_sync 边界层
(fetch_intraday_full_market_burst / fetch_intraday_universe_increment),
保持插件化边界不泄漏
"""
from __future__ import annotations
import contextlib
import threading
import time
from dataclasses import dataclass, field
from typing import Any
import polars as pl
from app.market_time import cn_now, cn_today, in_continuous_session
from app.services import preferences
# 轮询间隔允许范围 (秒): 稳态轮单请求无并发脉冲, 下限 3s; 上限防误配。
REFRESH_INTERVAL_MIN = 3
REFRESH_INTERVAL_MAX = 300
# 等待步长 (秒): 循环小步睡眠, 便于快速停止与偏好热生效。
_LOOP_STEP_S = 2.0
# 当日覆盖滞后超过该分钟数 (≈ universe 单请求 3 根余量) → 触发全天修复轮。
_REPAIR_LAG_MINUTES = 3.0
# 连续空轮达到该次数 → 强制全天修复轮 (自愈 universe 端点持续异常)。
_EMPTY_ROUNDS_TO_REPAIR = 2
def _in_continuous_session(now=None) -> bool:
"""A股连续竞价时段 (北京时间): 9:30-11:30 / 13:00-15:00, 仅工作日。"""
return in_continuous_session(now)
@dataclass
class _RefreshState:
"""服务运行状态 (status() 的内存镜像, 循环线程内更新)。"""
rounds: int = 0
last_round_at: float | None = None # epoch 秒
last_round_ms: float | None = None # 单轮耗时
last_rows: int = 0 # 上轮写入行数 (合并后)
last_symbols: int = 0 # 上轮覆盖标的数
last_requests: int = 0 # 上轮请求数 (增量恒 1, 修复=分块数+重试)
last_mode: str | None = None # 上轮模式: "increment" / "full"
last_error: str | None = None
next_round_at: float | None = None # epoch 秒
extra: dict[str, Any] = field(default_factory=dict)
class MinuteRefreshService:
"""盘中分钟增量刷新: 单实例挂 app.state.minute_refresh, 后台守护线程。"""
def __init__(self, repo) -> None:
self._repo = repo
self._app_state: Any | None = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._state = _RefreshState()
self._round_lock = threading.Lock() # 同时只允许一轮 (手动触发与定时轮互斥)
self._empty_rounds = 0 # 连续空轮计数 (escalate 到全天修复)
# ------------------------------------------------------------------
# 生命周期
# ------------------------------------------------------------------
def set_repo(self, repo) -> None:
self._repo = repo
def set_app_state(self, app_state: Any) -> None:
self._app_state = app_state
def start(self) -> bool:
"""启动后台线程 (幂等)。开关/时段/能力判断都在循环内每轮做, 热生效。"""
if self._thread is not None and self._thread.is_alive():
return True
self._stop.clear()
self._thread = threading.Thread(
target=self._loop, name="minute-refresh", daemon=True,
)
self._thread.start()
return True
def stop(self) -> None:
self._stop.set()
# ------------------------------------------------------------------
# 门控
# ------------------------------------------------------------------
def capability_ok(self) -> bool:
"""Cap.INTRADAY_UNIVERSE (全量分钟) 存在。能力探测结果缓存在 app.state。
门控挂在稳态增量的主能力上; 全天修复轮用的 intraday.batch 与其
同属 Expert (tiers.yaml), 目前两者必然同时持有
"""
capset = getattr(self._app_state, "capabilities", None) if self._app_state else None
if capset is None:
return False
try:
from app.tickflow.capabilities import Cap
return capset.has(Cap.INTRADAY_UNIVERSE)
except Exception:
return False
def custom_provider_active(self) -> bool:
"""配置了自定义分钟源 → 让位插件, 本服务不启动。"""
try:
return preferences.get_minute_data_provider() != "tickflow"
except Exception:
return False
def _gate_reason(self) -> str | None:
"""返回本轮不执行的原因 (None = 放行)。"""
if not preferences.get_minute_refresh_enabled():
return "disabled"
if self.custom_provider_active():
return "custom_minute_provider"
if not self.capability_ok():
return "capability"
if not _in_continuous_session():
return "outside_trading_hours"
# 节假日 (工作日但休市): 周几门控覆盖不到, 由交易日探针剔除。
# 未知 (None) 维持现状 — 空轮升级机制兜底 (探针失灵时的第二道防线)。
from app.services import trading_day
if trading_day.is_trading_day() is False:
return "holiday"
return None
# ------------------------------------------------------------------
# 主循环
# ------------------------------------------------------------------
def _loop(self) -> None:
while not self._stop.is_set():
try:
reason = self._gate_reason()
if reason is None:
interval = preferences.get_minute_refresh_interval()
started = time.time()
self._run_round()
# 固定节奏: 下一轮 = max(本轮起点+间隔, 本轮完成), 不补跑
finish = time.time()
self._state.next_round_at = max(started + interval, finish)
# 等到下一轮 (小步睡眠保持可停/偏好热切换)
while not self._stop.is_set():
now = time.time()
gate = self._gate_reason()
if gate is not None:
self._state.next_round_at = None
break # 门控关闭 → 回外层等待重评估
if now >= self._state.next_round_at:
break
self._stop.wait(min(_LOOP_STEP_S, max(0.0, self._state.next_round_at - now)))
continue
except Exception as e:
self._state.last_error = f"round failed: {e}"
self._stop.wait(_LOOP_STEP_S)
# ------------------------------------------------------------------
# 单轮
# ------------------------------------------------------------------
def _today_coverage_lag_minutes(self) -> float | None:
"""当日分区最新K距现在的分钟数; None = 当日无数据。
覆盖度探测只读当日分区文件 (毫秒级); 任何异常按无数据处理
本轮走全天修复, 不会因探测失败而丢增量
"""
with contextlib.suppress(Exception):
part = (
self._repo.store.data_dir / "kline_minute"
/ f"date={cn_today().isoformat()}" / "part.parquet"
)
if not part.exists():
return None
mx = pl.read_parquet(part, columns=["datetime"])["datetime"].max()
if mx is None:
return None
# 分区 datetime 为北京墙钟 naive, cn_now 带时区 → 剥齐再比
return (cn_now().replace(tzinfo=None) - mx).total_seconds() / 60
return None
def _select_mode(self) -> str:
"""选轮次模式: 稳态增量 (universe 单请求) vs 全天修复 (burst 脉冲)。
当日已有数据且覆盖滞后 _REPAIR_LAG_MINUTES ( 3 根余量) 增量;
冷启动 / 断档超阈值 / 连续空轮 全天修复
"""
if self._empty_rounds >= _EMPTY_ROUNDS_TO_REPAIR:
return "full"
lag = self._today_coverage_lag_minutes()
if lag is None or lag > _REPAIR_LAG_MINUTES:
return "full"
return "increment"
def _run_round(self) -> None:
from app.services import kline_sync
t0 = time.perf_counter()
mode = self._select_mode()
with self._round_lock:
if mode == "increment":
df, requests = kline_sync.fetch_intraday_universe_increment()
self._state.last_symbols = (
df["symbol"].n_unique() if not df.is_empty() else 0
)
else:
symbols = self._universe()
self._state.last_symbols = len(symbols)
if not symbols:
self._state.last_error = "empty universe (instruments 未加载)"
return
capset = getattr(self._app_state, "capabilities", None) if self._app_state else None
df, requests = kline_sync.fetch_intraday_full_market_burst(symbols, capset)
self._state.last_requests = requests
if df.is_empty():
self._empty_rounds += 1
self._state.last_error = f"intraday {mode} returned no data"
return
self._empty_rounds = 0
written = kline_sync._write_minute_partition(
df, self._repo.store.data_dir / "kline_minute",
)
self._state.rounds += 1
self._state.last_round_at = time.time()
self._state.last_round_ms = (time.perf_counter() - t0) * 1000
self._state.last_rows = written
self._state.last_mode = mode
self._state.last_error = None
def _universe(self) -> list[str]:
"""全市场 A 股标的 (instruments 维表, 与盘后分钟同步同一来源)。"""
inst = self._repo.get_instruments()
if inst.is_empty() or "symbol" not in inst.columns:
return []
return inst["symbol"].cast(pl.Utf8).unique().sort().to_list()
# ------------------------------------------------------------------
# 状态
# ------------------------------------------------------------------
def status(self) -> dict[str, Any]:
import contextlib
with contextlib.suppress(Exception):
enabled = preferences.get_minute_refresh_enabled()
running = self._thread is not None and self._thread.is_alive()
gate = self._gate_reason()
return {
"enabled": enabled,
"running": running,
"interval_seconds": preferences.get_minute_refresh_interval(),
"capability_ok": self.capability_ok(),
"custom_provider_active": self.custom_provider_active(),
"in_trading_hours": _in_continuous_session(),
"gate_reason": gate if (enabled and running) else (gate or "disabled"),
"rounds": self._state.rounds,
"last_round_at": self._state.last_round_at,
"last_round_ms": self._state.last_round_ms,
"last_rows": self._state.last_rows,
"last_symbols": self._state.last_symbols,
"last_requests": self._state.last_requests,
"last_mode": self._state.last_mode,
"next_round_at": self._state.next_round_at,
"last_error": self._state.last_error,
}
def trigger_manual_round(self) -> dict[str, Any]:
"""手动触发一轮 (无视时段门控, 但仍受能力/插件门控); 供状态页「立即刷新」。"""
if self.custom_provider_active() or not self.capability_ok():
return {"ok": False, "reason": self._gate_reason() or "capability"}
threading.Thread(target=self._run_round, daemon=True, name="minute-refresh-manual").start()
return {"ok": True}
+52 -5
View File
@@ -2,8 +2,12 @@
设计:
- job_store/ 文件夹,每个 job 一个 {id}.json,最多保留 max_jobs 个文件
- running/pending 状态的 job 仅存内存(高频读写)
- succeeded/failed 后写入独立文件并从内存释放
- create()/start() 即落盘 pending/running 快照(进度只更新内存)
进程意外死亡(uvicorn --reload 热重载 / kill)时记录不蒸发
- succeeded/failed 后写入终态并从内存释放
- 实例化时扫描磁盘,把上个进程遗留的 pending/running 孤儿记录补标为
failed(中断);finished_at 取文件 mtime(最后已知存活时刻),不用下次
开机时间虚增时长
- 列表查询 = 内存中的活跃 job + 磁盘文件扫描,按时间排序
- 单个查询 = 内存优先,没有则读磁盘
- 创建新 job 前检查文件数量,>= max_jobs 时删除最老的文件
@@ -15,7 +19,7 @@ import logging
import os
import threading
import uuid
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal
@@ -105,11 +109,12 @@ class JobStore:
self._active_id: str | None = None
self._lock = threading.Lock()
self._store_dir.mkdir(parents=True, exist_ok=True)
self._reap_orphans()
# ===== persistence =====
def _write_file(self, job: dict[str, Any]) -> None:
"""终态 job 写入独立 JSON 文件。"""
"""将 job 快照写入独立 JSON 文件(create/start/终态均落盘)"""
path = self._store_dir / f"{job['id']}.json"
try:
path.write_text(
@@ -154,6 +159,42 @@ class JobStore:
jobs.sort(key=lambda j: j.get("started_at") or "", reverse=True)
return jobs
def _reap_orphans(self) -> None:
"""启动补录: 把上个进程遗留的 pending/running 记录标为中断。
单例在进程启动时实例化,此时磁盘上的 pending/running 必然来自已死
亡的进程(uvicorn --reload 热重载 / kill) 不补录则这些记录
永远停留在运行中, 同步历史里既看不到结果也看不到失败,
数据在记录丢finished_at 取文件 mtime(最后一次落盘 = 最后
已知存活时刻), 避免拿下次开机时间虚增 duration
"""
for f in self._store_dir.glob("*.json"):
try:
j = json.loads(f.read_text("utf-8"))
except Exception:
continue
orig_status = j.get("status")
if not j.get("id") or orig_status not in ("pending", "running"):
continue
j["status"] = "failed"
j["error"] = "后端重启,任务中断(启动时补录)"
try:
mtime = datetime.fromtimestamp(f.stat().st_mtime, tz=UTC)
end = mtime.strftime("%Y-%m-%dT%H:%M:%SZ")
except Exception:
end = datetime.utcnow().isoformat(timespec="seconds") + "Z"
# mtime 早于 started_at(时钟回拨等)时夹住, 避免 duration 为负
if j.get("started_at") and end < j["started_at"]:
end = j["started_at"]
j["finished_at"] = end
j["duration_s"] = _duration_s(j)
logger.warning(
"job_store: 补录中断任务 %s (上个进程遗留 %s 记录)",
j["id"],
orig_status,
)
self._write_file(j)
# ===== lifecycle =====
def create(
@@ -189,7 +230,7 @@ class JobStore:
return self._active_id, False
job_id = uuid.uuid4().hex[:10]
self._active_jobs[job_id] = {
job = {
"id": job_id,
"status": "pending",
"stage": "init",
@@ -204,7 +245,11 @@ class JobStore:
"error": None,
"timeout_s": timeout_s,
}
self._active_jobs[job_id] = job
self._active_id = job_id
# pending 即落盘: 进程在 start() 前死亡时记录也不丢
self._delete_oldest()
self._write_file(job)
_register_cancel_flag(job_id)
return job_id, True
@@ -218,6 +263,8 @@ class JobStore:
# 心跳基准初始化为启动时刻: start() 到首次 progress() 之间的
# 初始化阶段(解析标的池等)同样计入停滞计时。
j["last_progress_at"] = j["started_at"]
# running 快照落盘: 进程死亡后由下次启动的 _reap_orphans 补录
self._write_file(j)
def succeed(self, job_id: str, result: Any) -> None:
with self._lock:
+38 -27
View File
@@ -84,29 +84,6 @@ def get_realtime_quote_interval() -> float:
return load().get("realtime_quote_interval", 6.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()
@@ -214,6 +191,27 @@ def get_minute_sync_segment_days() -> int:
"""
return max(5, min(30, load().get("minute_sync_segment_days", 20)))
# ===== 盘中分钟增量刷新 (Expert 专有) =====
# 稳态轮为 intraday.universe 单请求增量, 无脉冲并发, 间隔可低至 3s;
# 全天修复轮 (intraday.batch 28 块爆发) 的 rpm 安全与间隔无关, 由轮次
# 调度 max(间隔, 单轮完成) 天然防重叠。
_MINUTE_REFRESH_INTERVAL_MIN = 3
_MINUTE_REFRESH_INTERVAL_MAX = 300
def get_minute_refresh_enabled() -> bool:
"""盘中分钟K增量落盘开关。默认关闭; 能力门控 (Expert) 在服务层判断。"""
return bool(load().get("minute_refresh_enabled", False))
def get_minute_refresh_interval() -> int:
"""盘中分钟增量刷新间隔(秒)。默认 6,范围 [3, 300]。"""
return max(
_MINUTE_REFRESH_INTERVAL_MIN,
min(_MINUTE_REFRESH_INTERVAL_MAX, int(load().get("minute_refresh_interval", 6))),
)
# ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) =====
@@ -260,10 +258,9 @@ def get_daily_data_provider() -> str:
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"
# 「跟随日K」(same_as_daily) 特殊值已下线: 存量配置里的旧值按非法值回退 tickflow
provider = str(load().get("adj_factor_provider", "tickflow") or "tickflow").lower()
return provider if provider in _allowed_data_providers() else "tickflow"
def get_minute_data_provider() -> str:
@@ -271,6 +268,11 @@ def get_minute_data_provider() -> str:
return provider if provider in _allowed_data_providers() else "tickflow"
def get_depth5_data_provider() -> str:
provider = str(load().get("depth5_data_provider", "tickflow") or "tickflow").lower()
return provider if provider in _allowed_data_providers() else "tickflow"
def get_realtime_data_provider() -> str:
provider = str(load().get("realtime_data_provider", "tickflow") or "tickflow").lower()
return provider if provider in _allowed_data_providers() else "tickflow"
@@ -921,6 +923,13 @@ def set_realtime_monitor_config(cfg: dict) -> dict:
updates["minute_intraday_refresh_interval"] = max(
_INTRADAY_REFRESH_INTERVAL_MIN,
min(_INTRADAY_REFRESH_INTERVAL_MAX, int(cfg["minute_intraday_refresh_interval"])))
if "minute_refresh_enabled" in cfg:
updates["minute_refresh_enabled"] = bool(cfg["minute_refresh_enabled"])
if "minute_refresh_interval" in cfg:
# clamp 到 [3, 300], 与 getter 一致, 防前端传越界值
updates["minute_refresh_interval"] = max(
_MINUTE_REFRESH_INTERVAL_MIN,
min(_MINUTE_REFRESH_INTERVAL_MAX, int(cfg["minute_refresh_interval"])))
if "monitor_ext_fields" in cfg:
raw = cfg["monitor_ext_fields"] or {}
updates["monitor_ext_fields"] = {
@@ -942,6 +951,8 @@ def get_realtime_monitor_config() -> dict:
"screener_auto_run": get_screener_auto_run(),
"minute_intraday_refresh": get_minute_intraday_refresh(),
"minute_intraday_refresh_interval": get_minute_intraday_refresh_interval(),
"minute_refresh_enabled": get_minute_refresh_enabled(),
"minute_refresh_interval": get_minute_refresh_interval(),
"monitor_ext_fields": get_monitor_ext_fields(),
}
+140 -154
View File
@@ -28,7 +28,7 @@ import threading
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from datetime import date, time as dt_time
from datetime import date, datetime, time as dt_time
import polars as pl
@@ -167,13 +167,15 @@ class QuoteService:
CORE_INDEX_SYMBOLS = ("000001.SH", "399001.SZ", "399006.SZ", "000680.SH")
# 档位 → 最小轮询间隔 (秒)
# 档位 → 最小轮询间隔 (秒) — TickFlow 档位限速保护, 仅实时源为 tickflow 时适用
TIER_MIN_INTERVAL = {
"expert": 1.0,
"pro": 3.0,
"starter": 6.0,
"free": 6.0,
}
# 插件/自定义源: 不受 TickFlow 档位保护约束, 通用下限 1s (默认间隔仍为 DEFAULT_INTERVAL)
CUSTOM_PROVIDER_MIN_INTERVAL = 1.0
DEFAULT_INTERVAL = 6.0
MAX_INTERVAL = 60.0
@@ -218,6 +220,17 @@ class QuoteService:
# 午休/收盘最终同步状态: 到边界后必须成功拉取一版行情, 再进入休盘态。
self._final_sync_done: set[tuple[date, str]] = set()
self._final_sync_failed: dict[tuple[date, str], str] = {}
self._holiday_active = False # 交易日探针当前是否判休市 (日志去重)
# 轮询放量 (volume_delta 规则): 上一轮全市场股票快照的 (累计成交量[手], 累计成交额[元])。
# 每轮全量快照后更新 (含非连续竞价时段, 保证 13:00 恢复时 prev 是 12:59
# 而非 11:30); 跨交易日清空; cur < prev (数据源重置) 时丢弃该轮差值。
self._prev_stock_volume: dict[str, tuple[float, float]] | None = None
self._prev_volume_fetched_at: float | None = None # epoch 毫秒
self._prev_volume_date: date | None = None
# 最近一轮的有效差值 (vol_delta[手], amt_delta[元]) - 仅连续竞价时段内、
# prev 不早于本时段开盘时计算
self._volume_delta: dict[str, tuple[float, float]] = {}
self._volume_delta_span_s: float = 0.0
# ================================================================
# 生命周期
@@ -423,15 +436,18 @@ class QuoteService:
@classmethod
def realtime_mode(cls) -> str:
"""当前实时行情模式: none / watchlist / full_market。"""
"""当前实时行情模式: none / full_market。
TickFlow 免费档不再提供"自选前 5 只"降级实时(自定义源 fuyao 的全市场
快照已全面覆盖且免费); TickFlow 免费档 = 无实时, 接入自定义实时源
( fuyao)或升级 TickFlow 后恢复全市场模式
"""
from app.services import preferences
if preferences.get_realtime_data_provider() != "tickflow":
return "full_market"
tier = cls._current_tier()
if tier == "none":
if tier in ("none", "free"):
return "none"
if tier == "free":
return "watchlist"
return "full_market"
@classmethod
@@ -441,6 +457,11 @@ class QuoteService:
@classmethod
def _tier_min_interval(cls) -> float:
# 实时源路由到插件/自定义源时, TickFlow 档位限速不适用 (中立能力原则):
# 下限放宽到通用 1s, 默认/已保存间隔不变
from app.services import preferences
if preferences.get_realtime_data_provider() != "tickflow":
return cls.CUSTOM_PROVIDER_MIN_INTERVAL
tier = cls._current_tier()
return cls.TIER_MIN_INTERVAL.get(tier, cls.DEFAULT_INTERVAL)
@@ -494,7 +515,6 @@ class QuoteService:
def status(self) -> dict:
"""返回行情服务状态。"""
from app.services import preferences
age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1
mode = self.realtime_mode()
phase = self._market_phase()
@@ -507,7 +527,6 @@ class QuoteService:
"paused": self._paused,
"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,
@@ -561,14 +580,11 @@ class QuoteService:
waited += 0.5
def _fetch_quotes(self, *, final: bool = False) -> bool:
"""按当前档位拉取行情。加锁串行化 (后台轮询 vs 手动 refresh)。返回本轮是否成功更新。"""
"""拉取行情。加锁串行化 (后台轮询 vs 手动 refresh)。返回本轮是否成功更新。"""
with self._fetch_lock:
before = self._fetched_at
if final:
logger.info("最终行情同步开始")
if self.realtime_mode() == "watchlist":
self._fetch_watchlist_quotes()
else:
self._fetch_full_market_quotes()
return self._fetched_at > before
@@ -719,6 +735,9 @@ class QuoteService:
_persist_last_fetch(fetched_at)
logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms)
# 轮询放量状态更新 (volume_delta 规则的差值来源)
self._update_volume_delta(stock_records, fetched_at)
# ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ----
daily_df = self._build_daily(stock_records)
if not daily_df.is_empty() and self._repo:
@@ -765,150 +784,11 @@ class QuoteService:
# ---- 策略监控 + 告警评估 ----
self._evaluate_monitors(daily_df, quote_extra)
def _fetch_watchlist_quotes(self) -> None:
"""Free 档自选股实时: 按 capability batch 上限分批拉取。"""
from app.services import preferences
from app.tickflow.client import get_paid_realtime_client
from app.tickflow.capabilities import Cap
from app.tickflow.policy import detect_capabilities
from app.tickflow.rate_limits import chunked, resolve_limit, sleep_between_batches
symbols = preferences.get_realtime_watchlist_symbols()
# 指数监控规则标的并入轮询 (与股票共享 batch 额度)
engine = getattr(self._app_state, "monitor_engine", None) if self._app_state else None
if engine:
for _r in list(engine.rules.values()):
if _r.get("enabled", True) and _r.get("asset_type") == "index" and _r.get("scope") == "symbols":
for _s in _r.get("symbols", []):
if _s and _s not in symbols:
symbols.append(_s)
if not symbols:
logger.info("自选实时未配置标的, 跳过行情拉取")
return
tf = get_paid_realtime_client()
if tf is None:
logger.warning("自选实时拉取失败:未配置付费服务器 API Key")
return
# 按 capability batch 上限分批: 股票+指数共享额度, 超过上限会导致整轮失败
capset = detect_capabilities()
lim = resolve_limit(capset, Cap.QUOTE_BY_SYMBOL, default_batch=5)
batches = chunked(symbols, lim.batch)
t0 = time.perf_counter()
now_ts = time.perf_counter()
resp = []
for i, batch in enumerate(batches):
sleep_between_batches(i, lim.rpm)
try:
resp.extend(tf.quotes.get(symbols=batch) or [])
except Exception as e: # noqa: BLE001
logger.warning("自选实时批次 %d/%d 拉取失败: %s", i + 1, len(batches), e)
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):
# 小数制, 与 ext.change_pct / enriched 口径一致 (不乘 100)
change_pct = float(change_amount) / float(prev_close)
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_set = self._repo.get_index_symbol_set() if self._repo else set()
etf_set = self._repo.get_etf_symbol_set() if self._repo else set()
index_records, etf_records, stock_records = self._split_records_by_asset(records, index_set, etf_set)
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) if index_records else None
_persist_last_fetch(fetched_at)
logger.info("自选实时刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms",
len(stock_records), len(etf_records), len(index_records), fetch_ms)
daily_df = self._build_daily(stock_records)
quote_extra = self._build_quote_extra(stock_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)
# ETF/指数进自选前5时按各自资产落盘, 不污染股票表
etf_daily_df = self._build_daily(etf_records)
if not etf_daily_df.is_empty() and self._repo:
try:
self._repo.merge_live_daily_asset("etf", etf_daily_df)
except Exception as e: # noqa: BLE001
logger.warning("自选实时 ETF 日K写盘失败: %s", e)
self._flush_live_enriched(etf_daily_df, self._build_quote_extra(etf_records), asset_type="etf", merge=True)
index_daily_df = self._build_daily(index_records)
if not index_daily_df.is_empty() and self._repo:
try:
self._repo.merge_live_daily_asset("index", index_daily_df)
except Exception as e: # noqa: BLE001
logger.warning("自选实时指数日K写盘失败: %s", e)
self._flush_live_enriched(index_daily_df, self._build_quote_extra(index_records), asset_type="index", merge=True)
self._broadcast_quote_updated()
self._evaluate_monitors(daily_df, quote_extra)
# ================================================================
# 工具
# ================================================================
@staticmethod
def _split_records_by_asset(
records: list[dict], index_set: set[str], etf_set: set[str],
) -> tuple[list[dict], list[dict], list[dict]]:
"""把行情 records 按资产拆成 (index, etf, stock)。判定顺序与 resolve_asset_type 一致: 先 ETF 后指数。"""
index_records: list[dict] = []
etf_records: list[dict] = []
stock_records: list[dict] = []
for r in records:
sym = r.get("symbol")
if sym in etf_set:
etf_records.append(r)
elif sym in index_set:
index_records.append(r)
else:
stock_records.append(r)
return index_records, etf_records, stock_records
@staticmethod
def _build_daily(records: list[dict]) -> pl.DataFrame:
"""将 API records 转为日K格式 DataFrame (OHLCV + quote_ts, 写 kline_daily 用)。"""
@@ -1041,8 +921,29 @@ class QuoteService:
return (cn_today(), "close")
return None
def _holiday_gate(self) -> bool:
"""交易日探针门控: 确定休市 → False (停止轮询, 含 final 定版)。
探针未知 (None, 未配置 fuyao tickflow 不可用/开盘缓冲窗内) True,
维持周几近似现状行为探针是纯读, 不落盘; 休市结论带 TTL 定期复探,
误判自愈首次判定变化打一条日志, 避免每拍刷屏
"""
from app.services import trading_day
holiday = trading_day.is_trading_day() is False
if holiday != self._holiday_active:
self._holiday_active = holiday
if holiday:
logger.info("交易日探针判定休市, 行情轮询暂停 (30 分钟复探)")
return not holiday
def _should_poll_for_phase(self, phase: str) -> bool:
"""是否处于会主动拉行情的阶段。final 阶段成功后即停止。"""
"""是否处于会主动拉行情的阶段。final 阶段成功后即停止。
节假日 (工作日但休市) 由交易日探针剔除 周几门控覆盖不到的部分
"""
if not self._holiday_gate():
return False
if phase in {"preopen", "morning", "pre_afternoon", "afternoon"}:
return True
key = self._final_sync_key(phase)
@@ -1117,6 +1018,8 @@ class QuoteService:
eval_df = enriched_today
if engine.has_rule_type("ladder"):
eval_df = self._inject_sealed_vol(enriched_today, enriched_date)
if engine.has_rule_type("volume_delta"):
eval_df = self._inject_volume_delta(eval_df)
eval_df = self._inject_intraday_signals(eval_df, engine, "stock")
rule_events = engine.evaluate(eval_df, asset_type="stock")
if engine.consume_strategy_result_updates():
@@ -1203,7 +1106,8 @@ class QuoteService:
"window_change_pct", "coverage_ratio", "valid_count",
"total_count", "up_count", "down_count", "leader",
"abnormal_window", "abnormal_value", "abnormal_threshold",
"abnormal_closeness",
"abnormal_closeness", "volume_delta", "volume_delta_span",
"volume_delta_amount",
):
if key in ev:
alert[key] = ev[key]
@@ -1352,6 +1256,88 @@ class QuoteService:
)
return self._intraday_signal_evaluator.inject(enriched, signals)
@staticmethod
def _continuous_session_start_ms() -> float:
"""当前连续竞价时段的起点 (北京时间 9:30 或 13:00) 的 epoch 毫秒。"""
now = cn_now()
start_time = dt_time(13, 0) if now.time() >= dt_time(13, 0) else dt_time(9, 30)
return datetime.combine(now.date(), start_time, tzinfo=now.tzinfo).timestamp() * 1000.0
def _update_volume_delta(self, stock_records: list[dict], fetched_at_ms: float) -> None:
"""全市场相邻两次快照的股票累计成交量差值 (手), 供 volume_delta 规则。
- prev 每轮都更新 (含非连续竞价时段); 差值只在连续竞价时段内计算
- 开盘保护: prev 早于本时段起点 (9:30/13:00) 时本轮差值无效 -- 避免
9:25 集合竞价撮合量 / 午休缺口被当成"突然放量"
- cur < prev (数据源重置/口径跳变) 的个股丢弃差值; 跨交易日清空
"""
today = cn_today()
if self._prev_volume_date != today:
self._prev_stock_volume = None
self._prev_volume_fetched_at = None
self._prev_volume_date = today
self._volume_delta = {}
cur: dict[str, tuple[float, float]] = {}
for r in stock_records:
sym = r.get("symbol")
vol = r.get("volume")
amt = r.get("amount")
if not sym or not isinstance(vol, (int, float)):
continue
cur[str(sym)] = (
float(vol),
float(amt) if isinstance(amt, (int, float)) else 0.0,
)
prev = self._prev_stock_volume
prev_ts = self._prev_volume_fetched_at
if (
prev is not None
and prev_ts is not None
and self._is_continuous_trading()
and prev_ts >= self._continuous_session_start_ms()
):
delta = {
sym: (v - prev[sym][0], a - prev[sym][1])
for sym, (v, a) in cur.items()
if sym in prev and v >= prev[sym][0] and a >= prev[sym][1] and v - prev[sym][0] > 0
}
self._volume_delta = delta
self._volume_delta_span_s = max((fetched_at_ms - prev_ts) / 1000.0, 0.001)
else:
self._volume_delta = {}
self._prev_stock_volume = cur
self._prev_volume_fetched_at = fetched_at_ms
def _inject_volume_delta(self, enriched_today: pl.DataFrame) -> pl.DataFrame:
"""把最近一轮快照差值作为临时列注入 enriched 副本。
_volume_delta () / _volume_delta_amount () / _volume_delta_span (, 快照间隔)
无有效差值 (首轮/开盘保护/暂停后恢复) 时返回原 df, 规则安全降级不触发
"""
try:
delta = self._volume_delta
if not delta:
return enriched_today
span = self._volume_delta_span_s
delta_df = pl.DataFrame({
"symbol": list(delta.keys()),
"_volume_delta": [v for v, _ in delta.values()],
"_volume_delta_amount": [a for _, a in delta.values()],
"_volume_delta_span": [span] * len(delta),
})
drop_cols = [
c for c in ("_volume_delta", "_volume_delta_amount", "_volume_delta_span")
if c in enriched_today.columns
]
df = enriched_today.drop(drop_cols) if drop_cols else enriched_today
return df.join(delta_df, on="symbol", how="left")
except Exception as e: # noqa: BLE001
logger.debug("快照差值注入失败 (volume_delta 规则将不触发): %s", e)
return enriched_today
def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame:
"""从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。
@@ -1414,7 +1400,7 @@ class QuoteService:
source_labels = {
"strategy": "策略", "signal": "信号",
"price": "价格", "market": "异动", "ladder": "连板梯队",
"sector": "板块",
"sector": "板块", "volume_delta": "放量",
}
rules = engine.rules if engine is not None else {}
enqueued = 0
+46
View File
@@ -386,6 +386,27 @@ class ScreenerService:
if current is None:
current = self._load_enriched_for_date(as_of)
if timeframe == "1m":
# 分钟策略数据源是本地当日分钟K分区 (单分区文件直读), 与日线
# enriched 历史窗口无关, 不走 required_history_bars 日线路径。
history = self._load_minute_history(as_of, current)
# 策略声明 META["daily_history_bars"] 时额外装配日线 enriched 窗口,
# 供分钟策略叠加日线维度条件 (如 N 日内涨停过)。
daily_history = None
if engine is not None:
daily_bars = engine.minute_daily_history_bars(strategy_ids)
if daily_bars > 0:
daily_history = self._load_enriched_history(as_of, daily_bars)
return StrategyDataContext(
asset_type=self.asset_type,
timeframe=timeframe,
as_of=as_of,
current=current,
history=history,
daily_history=daily_history,
market=None,
cache_key=cache_key,
)
history_bars = engine.required_history_bars(
strategy_ids,
params_map=params_map,
@@ -404,6 +425,31 @@ class ScreenerService:
cache_key=cache_key,
)
def _load_minute_history(self, as_of: date, current: pl.DataFrame | None) -> pl.DataFrame:
"""分钟策略数据源: 优先 as_of 当日分钟分区, 缺失时回退全市场最近分区。
只按日期直读单个分区文件 (get_minute_by_dates), 与全量 glob 扫描解耦,
内存只随当日分区大小 (~67万行) 标的池限定为 enriched 快照 universe;
分区与快照的日期差是允许的 (分钟分区可能比 enriched 更新, 行自带时间戳)
"""
if self.asset_type != "stock":
raise ValueError("分钟策略当前仅支持 A 股")
symbols: list[str] = []
if current is not None and not current.is_empty():
symbols = current["symbol"].cast(pl.Utf8).unique().to_list()
if not symbols:
return pl.DataFrame()
df = self.repo.get_minute_by_dates(symbols, [as_of])
if df.is_empty():
fallback = self.repo.latest_minute_date_global()
if fallback is None:
raise ValueError(
"无分钟K数据 — 请先在 数据→分钟K 完成同步, 或开启盘中增量刷新"
)
if fallback != as_of:
df = self.repo.get_minute_by_dates(symbols, [fallback])
return df
def latest_date(self) -> date | None:
if self.asset_type != "stock":
_, d = self.repo.get_enriched_latest_asset(self.asset_type)
+139
View File
@@ -0,0 +1,139 @@
"""交易日探针 (oracle) — 回答「今天是否 A 股交易日」。
消费方 (实时行情轮询 / 盘中分钟增量) 在周几+时段门控之后调用, 用于把
工作日但休市的节假日从轮询窗口里剔除; 返回 None (未知) 时调用方
维持现状行为 (周几近似 + 快照新鲜度判据兜底), 不引入新依赖
探测链 (按确定性排序, 先到先得):
1. fuyao 交易日历 (已配置 fuyao ): GET /api/a-share/calendar/trading-days,
今天在近一年交易日列表内 交易日权威日历, 无时段依赖, 无开盘缓冲问题
2. tickflow 实时行情时间戳: 拉一篮流动性票快照 (单请求), max(timestamp)
日期 == 今天 交易日非交易日全市场戳停在上一交易日 (2026-08-29 周六
实测 5551/5551, 含停牌股 戳是快照定版时刻, 非最后成交时刻);
交易日集合竞价阶段 (9:15-9:30) 戳是否已翻新未实测 开盘缓冲窗内
戳过期不作数, 保守视为未知
3. 均不可用 None: 调用方按周几近似继续
安全约束:
- 周末直接返回 False (周几判断零成本, 不打任何请求)
- 探针是纯读: 只产出一个布尔判定, 不落盘不进行情管道不碰归属链路
- 只用于降档(休市不轮询); 休市结论 TTL 较短 (30 分钟) 定期复探,
探针误判最坏损失一段快照且可自愈; 未知结论短 TTL (5 分钟) 防止
轮询循环每拍重打失败的探测
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from datetime import datetime, time as dt_time
from app.market_time import CN_TZ, cn_now
# tickflow 戳探针的开盘缓冲窗: 此时刻之前戳仍是上一交易日属正常 (集合竞价),
# 不据此判休市。周一实测竞价戳翻新时机后可收紧。仅上午首个窗口需要。
_STALE_BUFFER_UNTIL = dt_time(9, 40)
# 一篮流动性票: 探 max(timestamp), 任一戳为今日即交易日 (OR 语义)。
# 大盘蓝筹同日全部停牌 = 市场性事件, 与休市同处理无碍。
_BASKET = ("000001.SZ", "600519.SH", "600036.SH", "601318.SH", "000651.SZ")
_TTL_TRADING_S = 3600.0 # 交易日结论每小时复探 (跨日天然失效)
_TTL_HOLIDAY_S = 1800.0 # 休市结论 30 分钟复探, 误判自愈上限
_TTL_UNKNOWN_S = 300.0 # 未知结论 5 分钟后重试探测
_CACHE_LOCK = threading.Lock()
@dataclass
class _Cache:
day: object | None = None
verdict: bool | None = None
probed_at: float = 0.0
_CACHE = _Cache()
def reset_cache() -> None:
"""清空探针缓存 (测试用)。"""
with _CACHE_LOCK:
_CACHE.day = None
_CACHE.verdict = None
_CACHE.probed_at = 0.0
def _probe_fuyao(now: datetime) -> bool | None:
"""fuyao 交易日历: 今天在列表内 ⇔ 交易日。未配置 fuyao / 失败 → None。"""
try:
from app.data_providers import custom as custom_sources
if not custom_sources.is_custom_provider("fuyao"):
return None
provider = custom_sources.get_provider("fuyao")
days = provider.trading_days()
return now.date() in days if days else None
except Exception: # noqa: BLE001 — 探针失败按未知处理, 不上抛
return None
def _probe_tickflow(now: datetime) -> bool | None:
"""tickflow 行情时间戳: max(timestamp) 日期 == 今天 ⇔ 交易日。
戳停在上一交易日: 开盘缓冲窗内 None (可能是竞价未翻新), 窗后 False
无实时权限 / 网络失败 / 无有效戳 None
"""
try:
from app.tickflow.client import get_client
rows = get_client().quotes.get(symbols=list(_BASKET)) or []
stamps = [r.get("timestamp") for r in rows if isinstance(r, dict)]
valid = [int(t) for t in stamps if isinstance(t, (int, float)) and t]
if not valid:
return None
latest_day = datetime.fromtimestamp(max(valid) / 1000, tz=CN_TZ).date()
if latest_day == now.date():
return True
if now.time() < _STALE_BUFFER_UNTIL:
return None
return False
except Exception: # noqa: BLE001 — 无权限/网络失败按未知处理
return None
def is_trading_day(now: datetime | None = None) -> bool | None:
"""今天是否 A 股交易日。True=交易日, False=确定休市, None=未知 (维持周几近似)。
周末零成本直判; 工作日走探测链 (fuyao 日历 tickflow 时间戳),
结论按 TTL 缓存线程安全: 实时行情与分钟增量两个线程共用
"""
now = now or cn_now()
if now.weekday() >= 5:
return False
with _CACHE_LOCK:
if (
_CACHE.day == now.date()
and _CACHE.verdict is not None
and (time.monotonic() - _CACHE.probed_at) < _ttl_of(_CACHE.verdict)
):
return _CACHE.verdict
verdict = _probe_fuyao(now)
if verdict is None:
verdict = _probe_tickflow(now)
with _CACHE_LOCK:
_CACHE.day = now.date()
_CACHE.verdict = verdict
_CACHE.probed_at = time.monotonic()
return verdict
def _ttl_of(verdict: bool | None) -> float:
if verdict is True:
return _TTL_TRADING_S
if verdict is False:
return _TTL_HOLIDAY_S
return _TTL_UNKNOWN_S
+100 -4
View File
@@ -155,6 +155,9 @@ class StrategyDataContext:
as_of: date
current: pl.DataFrame | None = None
history: pl.DataFrame | None = None
# 仅 1m 分支: 策略声明 META["daily_history_bars"] 时注入的日线 enriched 窗口,
# 供分钟策略叠加日线维度条件 (如 N 日内涨停过); 未声明时为 None。
daily_history: pl.DataFrame | None = None
market: Any | None = None
cache_key: str | None = None
@@ -199,6 +202,11 @@ class StrategyDef:
execution_backend: str = "polars_expr"
matrix_strategy: Any | None = None
composite: CompositeSpec | None = None # 仅 backend=="composite" 时非空
# 仅 backend=="minute_filter" 时非空: 输入为当日分钟K窗口, 输出为命中标的行
filter_minute_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None = None
# 仅 minute_filter: META["daily_history_bars"] 声明需要的日线历史窗口 (0=不需要;
# >0 时 filter_minute_history 必须接受 daily 关键字, 引擎注入 context.daily_history)
minute_daily_bars: int = 0
@dataclass
@@ -471,6 +479,7 @@ class StrategyEngine:
filter_fn = getattr(mod, "filter", None)
filter_history_fn = getattr(mod, "filter_history", None)
filter_minute_history_fn = getattr(mod, "filter_minute_history", None)
execution_backend = str(
getattr(
mod,
@@ -481,7 +490,7 @@ class StrategyEngine:
),
)
)
valid_backends = {"polars_expr", "matrix_native", "python_history_legacy", "composite"}
valid_backends = {"polars_expr", "matrix_native", "python_history_legacy", "composite", "minute_filter"}
if execution_backend not in valid_backends:
raise ValueError(
f"unsupported execution backend {execution_backend!r}; "
@@ -490,6 +499,7 @@ class StrategyEngine:
matrix_strategy = getattr(mod, "MATRIX_STRATEGY", None)
composite_spec: CompositeSpec | None = None
minute_daily_bars = 0
if execution_backend == "matrix_native":
from app.backtest.matrix import MatrixStrategy
@@ -515,6 +525,39 @@ class StrategyEngine:
"composite strategy must not declare filter, filter_history or MATRIX_STRATEGY"
)
composite_spec = _parse_composite_children(meta.get("children"))
elif execution_backend == "minute_filter":
# 分钟形态策略: 只声明 filter_minute_history; 数据源是本地当日分钟K分区
# (由 ScreenerService.build_strategy_context 的 1m 分支注入), 因此 timeframes
# 必须且只能是 ["1m"] — 混入 1d 会让日线 context 走错数据路径。
if (
filter_minute_history_fn is None
or filter_fn is not None
or filter_history_fn is not None
or matrix_strategy is not None
):
raise ValueError(
"minute_filter strategy must declare only filter_minute_history"
)
if meta.get("timeframes") != ["1m"]:
raise ValueError(
"minute_filter strategy must declare timeframes == ['1m']"
)
# 可选日线历史窗口: 声明 daily_history_bars 时 fn 必须接受 daily 关键字,
# 引擎会把 context.daily_history (enriched 日线窗口) 注入进来。
minute_daily_bars = int(meta.get("daily_history_bars") or 0)
if minute_daily_bars < 0 or minute_daily_bars > 250:
raise ValueError(
"minute_filter daily_history_bars must be within [0, 250]"
)
if minute_daily_bars > 0:
import inspect
sig = inspect.signature(filter_minute_history_fn)
if "daily" not in sig.parameters:
raise ValueError(
"minute_filter daily_history_bars requires "
"filter_minute_history to accept a 'daily' keyword"
)
elif filter_history_fn is None or filter_fn is not None:
raise ValueError("python_history_legacy strategy must declare only filter_history")
@@ -538,6 +581,8 @@ class StrategyEngine:
execution_backend=execution_backend,
matrix_strategy=matrix_strategy,
composite=composite_spec,
filter_minute_history_fn=filter_minute_history_fn,
minute_daily_bars=minute_daily_bars,
)
def reload(self) -> None:
@@ -652,6 +697,16 @@ class StrategyEngine:
return None
return max(0, int(value))
def minute_daily_history_bars(self, strategy_ids: list[str]) -> int:
"""1m 分支需要的日线 enriched 窗口大小: 各 minute_filter 策略声明的
META["daily_history_bars"] max, 未声明 (纯分钟策略) 0"""
required = 0
for strategy_id in strategy_ids:
strategy = self.get(strategy_id)
if strategy.execution_backend == "minute_filter":
required = max(required, strategy.minute_daily_bars)
return required
def required_history_bars(
self,
strategy_ids: list[str],
@@ -885,7 +940,26 @@ class StrategyEngine:
exit_signal_hits = self._collect_signal_hits(signal_df, exit_signals)
# 普通策略只读目标日期;历史策略读取调用方注入的历史窗口。
if s.filter_history_fn:
if s.execution_backend == "minute_filter":
# 分钟策略: 读取调用方注入的当日分钟K窗口。无 date 列, 不按 as_of 过滤,
# 每个命中行自带最后K线时间戳 (last_datetime)。
if history is None:
raise ValueError(f"strategy {strategy_id} requires minute history data")
if history.is_empty():
return StrategyResult(
as_of=as_of,
strategy_id=strategy_id,
exit_signal_hits=exit_signal_hits,
)
if s.minute_daily_bars > 0:
df = s.filter_minute_history_fn(history, params, daily=context.daily_history)
else:
df = s.filter_minute_history_fn(history, params)
# 基础过滤/展示列 (name/total_shares/change_pct 等) 来自 enriched 快照,
# 在命中结果上事后联表, 避免把 enriched 列铺到全市场分钟行上。
if current is not None and not current.is_empty():
df = self._join_basic_columns(df, current)
elif s.filter_history_fn:
if history is None:
raise ValueError(f"strategy {strategy_id} requires history data")
df = history
@@ -945,7 +1019,9 @@ class StrategyEngine:
# Stage 3: 评分
df = self._apply_scoring(df, scoring, scoring_directions)
entry_signal_hits = self._collect_signal_hits(df, entry_signals)
if not entry_signals and (s.filter_history_fn or s.filter_fn):
if not entry_signals and (
s.filter_history_fn or s.filter_fn or s.execution_backend == "minute_filter"
):
entry_signal_hits = [
{"symbol": str(symbol), "signals": []}
for symbol in df["symbol"].cast(pl.Utf8).unique().to_list()
@@ -1036,7 +1112,8 @@ class StrategyEngine:
history_strats = [
(sid, strategy)
for sid, strategy in selected
if strategy.filter_history_fn or strategy.execution_backend == "matrix_native"
if strategy.filter_history_fn
or strategy.execution_backend in ("matrix_native", "minute_filter")
]
shared_history = context.history
if history_strats and shared_history is None:
@@ -1466,6 +1543,25 @@ class StrategyEngine:
return df.filter(expr)
return df
# 分钟策略命中行需要从事后联表补齐的 enriched 列: 基础过滤引用 + 前端展示。
# close 不在列 — 分钟策略输出的 close 是最后一根分钟K收盘价, 优先于日线快照。
MINUTE_JOIN_COLUMNS: tuple[str, ...] = (
"name", "total_shares", "float_shares", "amount",
"turnover_rate", "change_pct", "pre_close",
)
@staticmethod
def _join_basic_columns(df: pl.DataFrame, current: pl.DataFrame) -> pl.DataFrame:
"""把 enriched 快照列按 symbol 联到分钟策略输出上, 只补 df 缺失的列。"""
cols = [
c for c in StrategyEngine.MINUTE_JOIN_COLUMNS
if c in current.columns and c not in df.columns
]
if not cols:
return df
extra = current.select(["symbol", *cols]).unique(subset=["symbol"], keep="last")
return df.join(extra, on="symbol", how="left")
# ================================================================
# 内部: 评分
# ================================================================
+138
View File
@@ -47,6 +47,7 @@ _SIGNAL_CN: dict[str, str] = {
"close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价",
"change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅",
"turnover_rate": "换手率", "volume": "成交量", "amount": "成交额",
"_volume_delta": "轮询成交量差值(手)", "_sealed_vol": "封单量(手)",
# 均线
"ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60",
"ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20",
@@ -983,6 +984,9 @@ class MonitorRuleEngine:
elif rtype == "ladder":
# 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message)
return self._evaluate_ladder(scoped, rule, now)
elif rtype == "volume_delta":
# 轮询放量监控: 相邻两次全市场快照的成交量差值, 独立处理走专属 message
return self._evaluate_volume_delta(scoped, rule, now)
else:
# signal / price / market: 通用条件匹配
for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule):
@@ -1364,6 +1368,140 @@ class MonitorRuleEngine:
results.append((sym, name, price, pct, hit_sigs))
return results
@staticmethod
def _volume_delta_basic_mask(df: pl.DataFrame, bf: dict, name_map: dict[str, str]) -> pl.Expr | None:
"""轮询放量基础过滤掩码 (与策略 basic_filter 语义对齐, 字段缺失时该项跳过)。
支持: price_min/max (收盘价), market_cap_min (总市值=close x total_shares),
float_cap_min/max (流通市值), amount_min (当日累计成交额), exclude_st (名称含 ST)
"""
masks: list[pl.Expr] = []
if bf.get("price_min") is not None:
masks.append(pl.col("close") >= float(bf["price_min"]))
if bf.get("price_max") is not None:
masks.append(pl.col("close") <= float(bf["price_max"]))
if bf.get("amount_min") is not None and "amount" in df.columns:
masks.append(pl.col("amount") >= float(bf["amount_min"]))
if bf.get("market_cap_min") is not None and "total_shares" in df.columns:
masks.append((pl.col("close") * pl.col("total_shares")) >= float(bf["market_cap_min"]))
if bf.get("float_cap_min") is not None and "float_shares" in df.columns:
masks.append((pl.col("close") * pl.col("float_shares")) >= float(bf["float_cap_min"]))
if bf.get("float_cap_max") is not None and "float_shares" in df.columns:
masks.append((pl.col("close") * pl.col("float_shares")) <= float(bf["float_cap_max"]))
if bf.get("exclude_st") and name_map:
st_symbols = [
sym for sym, name in name_map.items()
if name and "ST" in str(name).upper()
]
if st_symbols:
masks.append(~pl.col("symbol").is_in(st_symbols))
if not masks:
return None
return pl.all_horizontal(masks)
def _evaluate_volume_delta(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]:
"""评估轮询放量监控: 相邻两次全市场快照的成交量/成交额差值。
差值列 _volume_delta()/_volume_delta_amount()/间隔列 _volume_delta_span
quote_service 评估前注入metric=volume 按手数amount 按金额比较阈值;
basic_filter 先行过滤 (股价/市值/成交额/ST, 与策略 basic_filter 语义对齐)
命中 >5 只时合并为一条批量事件防刷屏
"""
if "_volume_delta" not in scoped.columns:
return [] # 无差值数据 (首轮/开盘保护/非全市场轮询), 安全降级
metric = rule.get("metric", "volume")
if metric == "amount" and "_volume_delta_amount" in scoped.columns:
cmp_col, threshold = "_volume_delta_amount", rule.get("threshold_amount", 1e6)
th_text = f"{threshold / 1e4:,.0f} 万元"
else:
cmp_col, threshold = "_volume_delta", rule.get("threshold_volume", 9000)
th_text = f"{threshold:,.0f}"
cooldown = rule.get("cooldown_seconds", 300)
severity = rule.get("severity", "warn")
span_s = 0.0
if "_volume_delta_span" in scoped.columns and scoped.height > 0:
v = scoped["_volume_delta_span"][0]
span_s = float(v) if v is not None else 0.0
span_text = f" (间隔 {span_s:.0f}s)" if span_s > 0 else ""
candidate = scoped
bf = rule.get("basic_filter") or {}
if bf:
mask = self._volume_delta_basic_mask(candidate, bf, self._name_map)
if mask is not None:
candidate = candidate.filter(mask)
hit = candidate.filter(
pl.col(cmp_col).is_not_null() & (pl.col(cmp_col) >= threshold)
).sort(cmp_col, descending=True)
if hit.is_empty():
return []
hit_rows = list(hit.iter_rows(named=True))
def _name_of(row: dict) -> str:
sym = row.get("symbol", "")
return row.get("name") or self._name_map.get(sym) or sym
def _fmt(v) -> str:
if metric == "amount":
return f"{v / 1e4:,.0f} 万元"
return f"{v:,.0f}"
def _event(symbol: str, name: str, message: str, *, delta=None, price=None, pct=None) -> dict:
ev = {
"ts": int(now * 1000),
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"source": "volume_delta",
"type": "轮询放量",
"symbol": symbol,
"name": name,
"message": message,
"price": price,
"change_pct": pct,
"signals": [],
"severity": severity,
"conditions": [],
"logic": "and",
"volume_delta": delta,
"volume_delta_span": round(span_s, 1),
}
if metric == "amount":
ev["volume_delta_amount"] = delta
return ev
if len(hit_rows) > 5:
top = "".join(_name_of(r) for r in hit_rows[:8])
suffix = "" if len(hit_rows) > 8 else ""
message = (
f"放量 · 单轮增量 >= {th_text}{span_text} · "
f"{len(hit_rows)} 只: {top}{suffix}"
)
key = (rule["id"], "_volume_delta_batch", "volume_delta")
last = self._last_fire.get(key)
if last is not None and (now - last) < cooldown:
return []
self._last_fire[key] = now
return [_event("", "", message)]
events: list[dict] = []
for row in hit_rows:
sym = row.get("symbol", "")
key = (rule["id"], sym, "volume_delta")
last = self._last_fire.get(key)
if last is not None and (now - last) < cooldown:
continue
self._last_fire[key] = now
delta = row.get(cmp_col)
message = f"放量 · 单轮增量 {_fmt(delta)} >= {th_text}{span_text}"
events.append(_event(
sym, _name_of(row), message,
delta=delta, price=row.get("close"), pct=row.get("change_pct"),
))
return events
def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]:
"""评估连板梯队封单监控规则。
+57 -2
View File
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
# ── 常量 ────────────────────────────────────────────────
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal"}
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal", "volume_delta"}
SCOPES = {"symbols", "all", "sector", "watchlist_group"}
LOGICS = {"and", "or"}
DIRECTIONS = {"entry", "exit", "both"}
@@ -45,6 +45,19 @@ SECTOR_WINDOWS = {1, 3, 5, 10, 15}
# abnormal 规则 (异动边缘): 接近度方向 / 关注窗口
ABNORMAL_DIRECTIONS = {"up", "down", "both"}
ABNORMAL_WINDOWS = {"any", "3d", "10d", "30d"}
# volume_delta 规则 (轮询放量): 阈值口径 (手数 / 成交额)
VD_METRICS = {"volume", "amount"}
# volume_delta 基础过滤默认值 (与策略 DEFAULT_BASIC_FILTER 核心子集对齐:
# 价格 3-300 元, 总市值 >=10 亿, 当日成交额 >=2000 万, 剔除 ST)
VD_BASIC_FILTER_DEFAULTS: dict = {
"price_min": 3,
"price_max": 300,
"market_cap_min": 10e8,
"float_cap_min": None,
"float_cap_max": None,
"amount_min": 0.2e8,
"exclude_st": True,
}
# 布尔信号列前缀 (op=truth 时 field 取这些)
_SIGNAL_PREFIXES = ("signal_", "csg_")
@@ -190,6 +203,39 @@ def validate(rule: dict) -> None:
threshold_pct = rule.get("threshold_pct")
if not isinstance(threshold_pct, (int, float)) or not 1 <= threshold_pct <= 150:
raise ValueError("异动接近度阈值必须是 1 到 150 之间的百分比数字")
elif rule.get("type") == "volume_delta":
# 轮询放量监控: 相邻两次全市场快照的成交量/成交额差值, 不用 conditions
if rule.get("asset_type", "stock") != "stock":
raise ValueError("轮询放量监控仅支持个股 (依赖全市场股票快照)")
if rule.get("scope", "all") == "sector":
raise ValueError("轮询放量监控不支持板块作用域")
if rule.get("metric", "volume") not in VD_METRICS:
raise ValueError(f"metric 必须是 {VD_METRICS} 之一 (volume=手数, amount=金额)")
if rule.get("metric", "volume") == "amount":
thr = rule.get("threshold_amount")
if isinstance(thr, bool) or not isinstance(thr, (int, float)) or not math.isfinite(thr) or thr < 1:
raise ValueError("threshold_amount 必须是 >=1 的数字 (单轮成交额增量, 单位元)")
else:
thr = rule.get("threshold_volume")
if isinstance(thr, bool) or not isinstance(thr, (int, float)) or not math.isfinite(thr) or thr < 1:
raise ValueError("threshold_volume 必须是 >=1 的数字 (单轮成交量增量, 单位手)")
bf = rule.get("basic_filter")
if bf is not None:
if not isinstance(bf, dict):
raise ValueError("basic_filter 必须是对象")
for key, value in bf.items():
if key == "exclude_st":
if not isinstance(value, bool):
raise ValueError("basic_filter.exclude_st 必须是布尔值")
elif key in ("price_min", "price_max", "market_cap_min", "float_cap_min",
"float_cap_max", "amount_min"):
if value is not None and (
isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) or value <= 0
):
raise ValueError(f"basic_filter.{key} 必须是正数字或 null")
else:
raise ValueError(f"basic_filter 不支持字段: {key}")
else:
# 信号/价格/市场类型: 需要 conditions
conds = rule.get("conditions")
@@ -254,7 +300,7 @@ def normalize(rule: dict) -> dict:
r.setdefault("enabled", True)
r.setdefault("asset_type", "stock")
# sector/abnormal 默认全市场 (sector 随后强制 all; abnormal 支持指定标的)
r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal"} else "symbols")
r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal", "volume_delta"} else "symbols")
r.setdefault("symbols", [])
r.setdefault("group_id", None)
# watchlist_group 作用域: 成员动态来自分组, symbols 不参与; 其他作用域清掉残留 group_id
@@ -290,6 +336,15 @@ def normalize(rule: dict) -> dict:
# ladder 专属默认字段
r.setdefault("metric", "sealed_vol")
r.setdefault("threshold", 0)
# volume_delta 专属默认字段 (轮询放量): 冷却期默认 300s 而非 3600s --
# 持续放量会连续多轮达标, 1 小时只提醒一次太迟钝。
if r.get("type") == "volume_delta":
if r.get("cooldown_seconds") is None:
r["cooldown_seconds"] = 300
r["metric"] = r["metric"] if r.get("metric") in VD_METRICS else "volume"
r.setdefault("threshold_volume", 9000)
r.setdefault("threshold_amount", 1e6)
r["basic_filter"] = {**VD_BASIC_FILTER_DEFAULTS, **(r.get("basic_filter") or {})}
if r.get("type") == "sector":
r["scope"] = "all"
r["symbols"] = []
+1
View File
@@ -20,6 +20,7 @@ class Cap(StrEnum):
KLINE_MINUTE_BATCH = "kline.minute.batch"
INTRADAY = "intraday"
INTRADAY_BATCH = "intraday.batch"
INTRADAY_UNIVERSE = "intraday.universe"
DEPTH5 = "depth5"
DEPTH5_BATCH = "depth5.batch"
WEBSOCKET = "websocket"
+8 -3
View File
@@ -32,7 +32,8 @@ _CAPSET_CACHE_FILE = "capabilities.json"
# v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量)
# v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测)
# v5: Free 档补充付费服务器 quote.by_symbol(10rpm/5标的),用于自选股实时监控。
_CACHE_SCHEMA_VERSION = 5
# v6: 新增 intraday.universe(全量分钟) 探测。
_CACHE_SCHEMA_VERSION = 6
# 探测用最小代价请求:挑流通性最好的 1 只标的试
_PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市
@@ -234,6 +235,11 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]:
lambda: tf.klines.intraday_batch([_PROBE_SYMBOL], count=1, as_dataframe=False),
defaults(Cap.INTRADAY_BATCH))
# intraday.universe — 全量分钟: 标的池单请求拉全市场最新 N 根 (Expert)
try_call(Cap.INTRADAY_UNIVERSE,
lambda: tf.klines.intraday_universe("CN_Equity_A", count=1, as_dataframe=False),
defaults(Cap.INTRADAY_UNIVERSE))
# depth5 — 按标的查(单只)
try_call(Cap.DEPTH5,
lambda: tf.depth.get(_PROBE_SYMBOL),
@@ -316,8 +322,6 @@ def _augment_custom_sources(capset: CapabilitySet) -> None:
daily_provider = preferences.get_daily_data_provider()
adj_provider = preferences.get_adj_factor_provider()
if adj_provider == "same_as_daily":
adj_provider = daily_provider
active_providers = {
"daily": daily_provider,
"adj_factor": adj_provider,
@@ -466,6 +470,7 @@ _CAP_ALIASES: dict[Cap, str] = {
Cap.KLINE_MINUTE_BY_SYMBOL: "分钟K",
Cap.INTRADAY: "分时",
Cap.INTRADAY_BATCH: "批量分时",
Cap.INTRADAY_UNIVERSE: "全量分钟",
Cap.DEPTH5: "五档",
Cap.DEPTH5_BATCH: "批量五档",
Cap.WEBSOCKET: "WS",
+67 -19
View File
@@ -38,6 +38,33 @@ from app.parquet import scan_enriched_parquet
logger = logging.getLogger(__name__)
def replace_with_retry(src: Path, dst: Path, *, attempts: int = 10, delay_s: float = 0.5) -> None:
"""os.replace 的 Windows 读锁重试版。
分区 parquet 的读端 (polars scan_parquet / DuckDB read_parquet 视图) 在扫描进行
期间持有句柄; Windows 不允许替换"仍被读端打开"的目标文件 (PermissionError,
WinError 5), Linux inode 交换语义则无此限制读端扫描通常亚秒级完成,
短退避重试即可穿过并发读窗口; attempts 次仍被占用则原样抛出, 由上层记录失败
"""
last: PermissionError | None = None
for i in range(attempts):
try:
src.replace(dst)
if i:
logger.info("parquet replace succeeded after %d blocked attempt(s): %s", i, dst)
return
except PermissionError as e:
last = e
if i == 0:
logger.warning(
"parquet replace blocked by concurrent reader, retrying (total <= %.1fs): %s",
attempts * delay_s, dst,
)
if i < attempts - 1:
time.sleep(delay_s)
raise last # type: ignore[misc] # attempts >= 1 时 last 必已赋值
def enriched_dirname(asset_type: str) -> str:
"""asset_type → enriched parquet 目录名。ETF 走独立目录, 其余(stock)用日K enriched。"""
return "kline_etf_enriched" if asset_type == "etf" else "kline_daily_enriched"
@@ -1813,13 +1840,15 @@ class KlineRepository:
# ================================================================
def latest_minute_date(self, symbol: str, asset_type: str = "stock") -> date | None:
# 注意: 必须走 execute_one (cursor+close)。直连 self.db.execute(...).fetchone()
# 的未消费结果集会把首个分区 parquet 的句柄钉在共享连接上, Windows 下阻塞
# 同步写入的 os.replace → 个股分时"补齐数据"500。
table = "kline_etf_minute" if asset_type == "etf" else "kline_minute"
try:
with self._lock:
row = self.db.execute(
row = self.execute_one(
f"SELECT max(CAST(datetime AS DATE)) FROM {table} 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:
@@ -1829,10 +1858,9 @@ class KlineRepository:
def latest_minute_date_global(self) -> date | None:
"""全市场最近分钟K日期 (不分 symbol)。用于非交易日回退到上一交易日。"""
try:
with self._lock:
row = self.db.execute(
row = self.execute_one(
"SELECT max(CAST(datetime AS DATE)) FROM kline_minute",
).fetchone()
)
if row and row[0]:
return row[0] if isinstance(row[0], date) else date.fromisoformat(str(row[0]))
except Exception: # noqa: BLE001
@@ -1841,10 +1869,9 @@ class KlineRepository:
def earliest_daily_date(self) -> date | None:
"""本地日K数据的最早日期。"""
try:
with self._lock:
res = self.db.execute(
res = self.execute_one(
"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))
@@ -1855,10 +1882,9 @@ class KlineRepository:
def earliest_minute_date(self) -> date | None:
"""本地分钟K数据的最早日期。"""
try:
with self._lock:
res = self.db.execute(
res = self.execute_one(
"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))
@@ -1866,13 +1892,35 @@ class KlineRepository:
return None
return None
def list_minute_dates(self, start: date, end: date, asset_type: str = "stock") -> list[date]:
"""枚举 [start, end] 内存在的分钟K分区日 (目录名直读, 零 parquet 扫描)。
分钟回测按交易日精确对日: 缺分区的日子由调用方显式跳过,
不做"回退最近分区" (那是实盘选股的语义, 回放会串日)
"""
dirname = "kline_minute" if asset_type == "stock" else f"kline_{asset_type}_minute"
minute_dir = self.store.data_dir / dirname
if not minute_dir.exists():
return []
out: list[date] = []
for entry in minute_dir.iterdir():
if not (entry.is_dir() and entry.name.startswith("date=")):
continue
try:
day = date.fromisoformat(entry.name[5:])
except ValueError:
continue
if start <= day <= end:
out.append(day)
out.sort()
return out
def latest_daily_date(self) -> date | None:
"""本地日K数据的最新日期。"""
try:
with self._lock:
res = self.db.execute(
res = self.execute_one(
"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))
@@ -1924,10 +1972,9 @@ class KlineRepository:
def _latest_enriched_date_duckdb(self) -> date | None:
try:
with self._lock:
res = self.db.execute(
res = self.execute_one(
"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))
@@ -2095,10 +2142,11 @@ class KlineRepository:
直接 write_parquet(out) 在进程被 kill (dev.sh 清端口用 kill -9)
或断电时会留下半截文件, 之后 scan_parquet glob 整条链路报错
临时文件后缀 .tmp 不匹配 *.parquet glob, 不会被扫描误读
Windows 下目标正被并发读取时由 replace_with_retry 短退避穿过
"""
tmp = out.with_name(out.name + ".tmp")
df.write_parquet(tmp)
tmp.replace(out) # 同目录 rename, POSIX/NTFS 均为原子操作
replace_with_retry(tmp, out)
def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None:
"""按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。"""
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "tickflow-stock-panel-backend"
version = "0.2.1"
version = "0.2.2"
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
requires-python = ">=3.11"
license = { text = "MIT" }
@@ -319,6 +319,7 @@ def test_builtin_matrix_strategies_use_their_declared_formula_modules():
path for path in strategy_dir.glob("*.py") if path.name != "__init__.py"
)
# 分钟形态策略 (minute_red_streak) 已迁至自定义策略目录, 内置策略全部 matrix 后端
assert len(strategy_files) == 19
for strategy_path in strategy_files:
strategy = StrategyEngine._load_file(strategy_path)
@@ -784,7 +785,10 @@ def test_registered_builtin_matrix_strategies_share_one_cache_profile():
strategy_dirs=[REPO_ROOT / "backend" / "app" / "strategy" / "builtin"]
)
profile = build_matrix_cache_profile(engine, "stock")
strategies = engine.strategy_definitions()
strategies = tuple(
s for s in engine.strategy_definitions()
if s.execution_backend != "minute_filter"
)
assert len(strategies) == 19
assert all(strategy.execution_backend == "matrix_native" for strategy in strategies)
@@ -0,0 +1,330 @@
"""分钟策略回测端到端集成测试 (三期 v1)。
用合成的分钟K分区 + 合成日线面板 + 专用测试策略, 完整跑通
StrategyBacktestService.run() minute_filter 分支:
逐日回放 (与实盘选股同一条 StrategyEngine.run 路径) 信号分钟收盘入场
涨停拒买 日K矩阵离场 交易记录携带分钟时间戳
核心断言:
- 日线窗口因果性: T 日的日线条件窗口只含 T-1 及更早 (测试策略内置守卫,
窗口含 T 则拒绝命中 若回放器传错窗口, 全部用例的信号归零);
- 入场价 = 触发分钟收盘价 (entry_price_override 机制);
- 涨停拒买: 触发分钟收盘 >= 当日涨停价 (T-1 收盘 + 板块规则) 不成交;
- 缺分钟分区的交易日显式跳过 (不回退最近分区);
- 离场复用日K口径 (max_hold 次日开盘)
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from pathlib import Path
import polars as pl
import pytest
from app.backtest.engine import BacktestEngine
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
from app.strategy.engine import StrategyEngine
# ── 测试策略: 内置因果性守卫 ──────────────────────────────────────
# 命中条件: 当日某分钟 close > T-1 close * 1.05, 触发分钟 = 首根满足条件的K。
# daily 窗口的最后一个日期必须 < 触发日, 否则返回空 (回放器传错窗口时信号归零)。
TEST_STRATEGY_SOURCE = '''
import polars as pl
META = {
"id": "test_minute_ping",
"name": "test_minute_ping",
"asset_types": ["stock"],
"timeframes": ["1m"],
"daily_history_bars": 5,
"order_by": "close",
"descending": True,
"limit": 100,
}
EXECUTION_BACKEND = "minute_filter"
def filter_minute_history(df, params, *, daily=None):
if daily is None or daily.is_empty():
return pl.DataFrame()
trigger_day = df.select(pl.col("datetime").max()).item().date()
# 因果性守卫: 日线窗口不得包含触发日。
if daily.get_column("date").max() >= trigger_day:
return pl.DataFrame()
prev = (
daily.sort("date").group_by("symbol").last()
.select(pl.col("symbol"), pl.col("close").alias("prev_close"))
)
joined = df.join(prev, on="symbol", how="inner")
hits = joined.filter(pl.col("close") > pl.col("prev_close") * 1.05)
if hits.is_empty():
return pl.DataFrame()
return (
hits.sort("datetime").group_by("symbol").first()
.select(
pl.col("symbol"),
pl.col("datetime").alias("last_datetime"),
pl.col("close"),
)
)
'''
# ── 合成数据 ─────────────────────────────────────────────────────
def _trading_days(n: int, start: date = date(2026, 7, 1)) -> list[date]:
days: list[date] = []
cur = start
while len(days) < n:
if cur.weekday() < 5:
days.append(cur)
cur += timedelta(days=1)
return days
def _daily_panel(days: list[date], symbols: list[str]) -> pl.DataFrame:
"""合成日线面板: 三个符号的慢涨走势, raw_close == close (复权因子 1)。"""
rows = []
for sym_idx, sym in enumerate(symbols):
base = 10.0 + sym_idx * 4.0
for t, day in enumerate(days):
close = round(base * (1 + t * 0.002), 3)
open_p = round(close - 0.05, 3)
rows.append({
"symbol": sym,
"date": day,
"open": open_p,
"high": round(close + 0.08, 3),
"low": round(open_p - 0.06, 3),
"close": close,
"raw_close": close,
# 成交额需过 DEFAULT_BASIC_FILTER.amount_min (2e8) — 命中行的
# amount 由 T-1 enriched 快照联表注入 (与实盘同路径)。
"volume": 2e7,
"amount": round(close * 2e7, 3),
"name": f"股票{sym_idx}",
"total_shares": 5e8,
"float_shares": 4e8,
"signal_limit_up": False,
"signal_limit_down": False,
})
return pl.DataFrame(rows).sort(["symbol", "date"]).with_columns(
pl.col("date").cast(pl.Date),
)
def _minute_frame(day: date, bars: list[tuple[str, str, float]]) -> pl.DataFrame:
"""bars: (symbol, "HH:MM"(北京), close)。分区 datetime 为 naive-UTC 存储 (北京 - 8h)。"""
rows = []
for sym, hm, close in bars:
local = datetime(day.year, day.month, day.day, int(hm[:2]), int(hm[3:]))
rows.append({
"symbol": sym,
"datetime": local - timedelta(hours=8),
"open": close - 0.01,
"high": close + 0.01,
"low": close - 0.02,
"close": close,
"volume": 1000.0,
"amount": close * 1000.0,
})
return pl.DataFrame(rows).sort(["symbol", "datetime"]).with_columns(
pl.col("datetime").cast(pl.Datetime("us")),
)
class _FakeMinuteRepo:
"""仅实现分钟回测所需的最小 repo 接口。"""
def __init__(self, minute_frames: dict[date, pl.DataFrame]) -> None:
self.minute_frames = minute_frames
self.store = None
def list_minute_dates(self, start, end, asset_type="stock"):
return sorted(d for d in self.minute_frames if start <= d <= end)
def get_minute_by_dates(self, symbols, dates, asset_type="stock"):
frames = [self.minute_frames[d] for d in dates if d in self.minute_frames]
if not frames:
return pl.DataFrame(
schema={"symbol": pl.Utf8, "datetime": pl.Datetime("us"),
"open": pl.Float64, "high": pl.Float64, "low": pl.Float64,
"close": pl.Float64, "volume": pl.Float64, "amount": pl.Float64},
)
df = pl.concat(frames)
if symbols:
df = df.filter(pl.col("symbol").is_in(list(symbols)))
return df.sort(["symbol", "datetime"])
def earliest_minute_date(self):
return min(self.minute_frames) if self.minute_frames else None
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
return pl.DataFrame()
def _make_service(
tmp_path: Path, panel: pl.DataFrame, minute_frames: dict[date, pl.DataFrame],
) -> StrategyBacktestService:
strat_dir = tmp_path / "strategies"
strat_dir.mkdir(exist_ok=True)
(strat_dir / "test_minute_ping.py").write_text(TEST_STRATEGY_SOURCE, encoding="utf-8")
strategy_engine = StrategyEngine(strategy_dirs=[strat_dir])
repo = _FakeMinuteRepo(minute_frames)
bt_engine = BacktestEngine(repo)
def _load_panel(self, symbols, start, end, feature_plan, asset_type="stock", **kw):
df = panel.filter((pl.col("date") >= start) & (pl.col("date") <= end))
if symbols:
df = df.filter(pl.col("symbol").is_in(list(symbols)))
keep = set(feature_plan.base_columns) | set(feature_plan.instrument_columns) | {"symbol", "date"}
return df.select(sorted(c for c in df.columns if c in keep))
bt_engine.load_panel_for_backtest = _load_panel.__get__(bt_engine)
return StrategyBacktestService(bt_engine, strategy_engine)
def _config(start: date, end: date, **kw) -> StrategyBacktestConfig:
defaults = dict(
strategy_id="test_minute_ping",
symbols=None,
start=start,
end=end,
exit_fill="open_t+1",
max_positions=10,
mode="position",
holding_days=1,
overrides={"max_hold_days": 1},
)
defaults.update(kw)
return StrategyBacktestConfig(**defaults)
@pytest.fixture()
def scenario(tmp_path: Path):
"""三个符号 x 三个回测日。面板共 30 个交易日 (指数慢涨, 涨停价按 T-1 收盘 +10%)。
- 000001.SZ: T1 触发 (close 10.72 > prev 10.19*1.05), T2/T3 不再触发;
- 000002.SZ: 三天都不触发 (涨幅不足 5%);
- 600000.SH: T2 触发但触发分钟收盘已达涨停价 拒买; T3 正常触发
"""
days = _trading_days(30)
t1, t2, t3 = days[-4], days[-3], days[-2] # 留一天做 T+1 离场
symbols = ["000001.SZ", "000002.SZ", "600000.SH"]
panel = _daily_panel(days, symbols)
def _prev_close(sym: str, before: date) -> float:
return panel.filter(
(pl.col("symbol") == sym) & (pl.col("date") < before)
).sort("date").get_column("close")[-1]
minute_frames = {
t1: _minute_frame(t1, [
("000001.SZ", "09:31", round(_prev_close("000001.SZ", t1) * 1.005, 3)),
("000001.SZ", "09:35", round(_prev_close("000001.SZ", t1) * 1.07, 3)), # 触发
("000002.SZ", "09:31", round(_prev_close("000002.SZ", t1) * 1.01, 3)),
("600000.SH", "09:31", round(_prev_close("600000.SH", t1) * 1.01, 3)),
]),
t2: _minute_frame(t2, [
("000001.SZ", "09:31", round(_prev_close("000001.SZ", t2) * 1.004, 3)),
("000002.SZ", "09:31", round(_prev_close("000002.SZ", t2) * 1.01, 3)),
# 涨停拒买: 触发分钟收盘 = T-1收盘 * 1.10 (主板涨停价, 半进位后相等)
("600000.SH", "09:40", round(_prev_close("600000.SH", t2) * 1.10, 3)),
]),
t3: _minute_frame(t3, [
("000001.SZ", "09:31", round(_prev_close("000001.SZ", t3) * 1.004, 3)),
("000002.SZ", "09:31", round(_prev_close("000002.SZ", t3) * 1.01, 3)),
("600000.SH", "09:50", round(_prev_close("600000.SH", t3) * 1.06, 3)), # 触发
]),
}
service = _make_service(tmp_path, panel, minute_frames)
return service, panel, {"t1": t1, "t2": t2, "t3": t3, "t4": days[-1]}, minute_frames
def test_entry_at_trigger_minute_price(scenario):
service, panel, days, _ = scenario
result = service.run(_config(days["t1"], days["t3"]))
assert not result.error, result.error
entries = [t for t in result.trades if t["symbol"] == "000001.SZ"]
assert len(entries) == 1
trade = entries[0]
# 入场价 = 触发分钟 (09:35) 收盘价, 入场时间戳精确到分钟
prev_close = panel.filter(
(pl.col("symbol") == "000001.SZ") & (pl.col("date") < days["t1"])
).sort("date").get_column("close")[-1]
expected_price = round(prev_close * 1.07, 3)
assert trade["entry_price"] == pytest.approx(expected_price, abs=1e-6)
assert trade["entry_date"].startswith(f"{days['t1']} 09:35")
def test_daily_window_strictly_before_trigger_day(scenario):
"""因果性: 测试策略拒绝含触发日的日线窗口 — 有信号即证明窗口止于 T-1。"""
service, _, days, _ = scenario
result = service.run(_config(days["t1"], days["t3"]))
assert not result.error, result.error
assert result.trades, "日线窗口若含触发日, 测试策略会拒绝命中 — 信号归零"
def test_limit_up_entry_rejected(scenario):
service, panel, days, _ = scenario
result = service.run(_config(days["t1"], days["t3"]))
assert not result.error, result.error
# T2 的 600000.SH 触发分钟收盘 = 涨停价 → 拒买; T3 才有它的成交
entries_600000 = [t for t in result.trades if t["symbol"] == "600000.SH"]
assert all(t["entry_date"][:10] == str(days["t3"]) for t in entries_600000)
execution = result.stats.get("execution", {})
assert execution.get("buy_limit_up", 0) >= 1
replay_stats = result.stats.get("minute_replay", {})
assert replay_stats.get("replayed_days") == 3
def test_missing_partition_day_skipped(tmp_path):
days = _trading_days(30)
t1, t2, t3 = days[-4], days[-3], days[-2]
symbols = ["000001.SZ"]
panel = _daily_panel(days, symbols)
def _prev(before: date) -> float:
return panel.filter(
(pl.col("symbol") == "000001.SZ") & (pl.col("date") < before)
).sort("date").get_column("close")[-1]
frames = {
t1: _minute_frame(t1, [("000001.SZ", "09:35", round(_prev(t1) * 1.07, 3))]),
t3: _minute_frame(t3, [("000001.SZ", "09:35", round(_prev(t3) * 1.06, 3))]),
# t2 无分区 → 应被跳过, 而不是回退到 t1/t3 的数据
}
service = _make_service(tmp_path, panel, frames)
result = service.run(_config(t1, t3))
assert not result.error, result.error
replay_stats = result.stats.get("minute_replay", {})
assert replay_stats.get("replayed_days") == 2
assert str(t2) in replay_stats.get("skipped_days", [])
entry_days = {t["entry_date"][:10] for t in result.trades}
assert str(t2) not in entry_days
def test_exit_reuses_daily_next_open(scenario):
"""离场复用日K口径: max_hold=1 → 次日开盘卖出。"""
service, panel, days, _ = scenario
result = service.run(_config(days["t1"], days["t3"]))
assert not result.error, result.error
trade = next(t for t in result.trades if t["symbol"] == "000001.SZ")
entry_day = date.fromisoformat(trade["entry_date"][:10])
exit_day = date.fromisoformat(str(trade["exit_date"])[:10])
assert exit_day > entry_day
next_open = panel.filter(
(pl.col("symbol") == "000001.SZ") & (pl.col("date") == exit_day)
).get_column("open")[0]
assert trade["exit_price"] == pytest.approx(next_open, abs=1e-6)
def test_guards(scenario, tmp_path):
service, panel, days, _ = scenario
# 信号触发卖出离场口径不支持 (通用校验或分钟分支守卫, 任一拒绝即可)
result = service.run(_config(days["t1"], days["t3"], exit_fill="signal_next_minute"))
assert result.error and "分钟" in result.error
# 无分钟分区 → 明确报错
empty_service = _make_service(tmp_path, panel, {})
result = empty_service.run(_config(days["t1"], days["t3"]))
assert "分钟K" in (result.error or "")
+169
View File
@@ -0,0 +1,169 @@
"""分钟红7 — 开盘 N 根 (当日最早) 分钟K多数收红, 且最高的 top_red 根全红。
数据契约: filter_minute_history 接收当日全市场分钟K窗口
(symbol, datetime, open, high, low, close, volume, amount),
ScreenerService.build_strategy_context 1m 分支从本地 kline_minute
分区注入; 策略本身不感知数据来源 (本地同步 / 盘中增量刷新对它透明)
META["daily_history_bars"] 声明叠加日线维度的条件 (N 日内涨停过):
引擎会以 daily= 关键字注入日线 enriched 窗口, 涨停判定直接复用
enriched 预计算信号 signal_limit_up (收盘封板) signal_broken_limit_up
(炸板: 盘中触及涨停未封住), 任一命中即算"盘中涨停过"
本文件是分钟红7的参考实现 (测试夹具): 该策略按用户要求以自定义策略形态
交付, 正式位置为运行时 data/strategies/custom/minute_red_streak.py
(gitignore, 用户可自行修改); 引擎按 id 加载, 参数覆盖不受位置影响
"""
import polars as pl
META = {
"id": "minute_red_streak",
"name": "分钟红7",
"description": "开盘前7根1分钟K至少5根收红, 最高的2根(按最高价)都是红K, 且近20日盘中触及过涨停",
"tags": ["分钟", "形态", "短线"],
"asset_types": ["stock"],
"timeframes": ["1m"],
# 日线 enriched 窗口 (交易日语义, 含 as_of): 覆盖 limit_up_days 参数上限
"daily_history_bars": 60,
"params": [
{
"id": "bars",
"label": "开盘K线数",
"type": "int",
"default": 7,
"min": 5,
"max": 15,
"step": 1,
},
{
"id": "min_red",
"label": "最少红K数",
"type": "int",
"default": 5,
"min": 1,
"max": 15,
"step": 1,
},
{
"id": "top_red",
"label": "最高K需红数",
"type": "int",
"default": 2,
"min": 1,
"max": 3,
"step": 1,
},
{
"id": "rank_by_close",
"label": "最高K按收盘价排序",
"type": "bool",
"default": False,
},
{
"id": "require_limit_up",
"label": "要求N日内涨停过",
"type": "bool",
"default": True,
},
{
"id": "limit_up_days",
"label": "涨停回看天数",
"type": "int",
"default": 20,
"min": 5,
"max": 60,
"step": 1,
},
],
"order_by": "red_count",
"descending": True,
"limit": 100,
}
EXECUTION_BACKEND = "minute_filter"
ENTRY_SIGNALS: list[str] = []
EXIT_SIGNALS: list[str] = []
def _recent_limit_ups(daily: pl.DataFrame | None, lookback: int) -> pl.DataFrame:
"""日线窗口 → (symbol, recent_limit_ups) 近 lookback 个交易日的涨停次数。
涨停过 = signal_limit_up (收盘封板) signal_broken_limit_up (炸板触及)
日线窗口缺失 / 无涨停信号列 返回空表 (调用方 inner join 即失败闭合,
宁可漏过不可错报)
"""
empty = pl.DataFrame(schema={"symbol": pl.Utf8, "recent_limit_ups": pl.UInt32})
if daily is None or daily.is_empty():
return empty
if not {"signal_limit_up", "signal_broken_limit_up"}.issubset(daily.columns):
return empty
return (
daily.select("symbol", "date", "signal_limit_up", "signal_broken_limit_up")
.sort(["symbol", "date"])
.filter(pl.int_range(pl.len()).over("symbol") >= pl.len().over("symbol") - lookback)
.group_by("symbol")
.agg(
recent_limit_ups=(
pl.col("signal_limit_up").fill_null(False)
| pl.col("signal_broken_limit_up").fill_null(False)
).sum()
)
.filter(pl.col("recent_limit_ups") > 0)
)
def filter_minute_history(df: pl.DataFrame, params: dict, *, daily: pl.DataFrame | None = None) -> pl.DataFrame:
"""红K形态过滤: 全向量化, 无逐行 Python 循环。
- 每标的按时间取当日最早 bars (开盘窗口); 不足 bars 根不触发
- = close > open; 窗口内红K数 >= min_red
- rank_by (high / close) 降序取前 top_red , 同值取时间更晚者, 需全红
- require_limit_up: limit_up_days 个交易日盘中触及过涨停 (日线维度,
daily 窗口的预计算涨停信号判定; 窗口缺失时失败闭合不触发)
"""
bars = int(params.get("bars") or 7)
min_red = min(int(params.get("min_red") or 5), bars)
top_red = min(int(params.get("top_red") or 2), bars)
rank_by = "close" if params.get("rank_by_close") else "high"
if rank_by not in df.columns:
rank_by = "high"
windowed = (
df.sort(["symbol", "datetime"])
.filter(pl.int_range(pl.len()).over("symbol") < bars)
.with_columns(_red=(pl.col("close") > pl.col("open")).cast(pl.Int32))
)
window = windowed.group_by("symbol").agg(
bars_checked=pl.len(),
red_count=pl.col("_red").sum(),
last_datetime=pl.col("datetime").max(),
# 输出列名用 close: 基础过滤的股价区间作用于开盘窗口末根收盘价
close=pl.col("close").sort_by("datetime").last(),
window_high=pl.col("high").max(),
window_low=pl.col("low").min(),
window_volume=pl.col("volume").sum(),
window_amount=pl.col("amount").sum(),
)
top = (
windowed.sort([rank_by, "datetime"], descending=[True, True])
.filter(pl.int_range(pl.len()).over("symbol") < top_red)
.group_by("symbol")
.agg(top_red_count=pl.col("_red").sum())
)
result = (
window.join(top, on="symbol", how="inner")
.filter(
(pl.col("bars_checked") >= bars)
& (pl.col("red_count") >= min_red)
& (pl.col("top_red_count") >= top_red)
)
.drop("bars_checked")
)
if params.get("require_limit_up", True):
lookback = max(5, min(int(params.get("limit_up_days") or 20), 60))
result = result.join(_recent_limit_ups(daily, lookback), on="symbol", how="inner")
return result
+75
View File
@@ -0,0 +1,75 @@
"""盘中异动聚合测试 (build_intraday, 不依赖真实网络/enriched)。
覆盖: 信号命中过滤counts 计数优先级排序 (涨停 > 炸板 > )
多信号行limit 截断空快照与缺信号列的降级
"""
from __future__ import annotations
from datetime import date
import polars as pl
from app.services.abnormal_moves import build_intraday
class _FakeRepo:
def __init__(self, df: pl.DataFrame):
self._df = df
def get_enriched_latest(self):
return self._df, date(2026, 8, 28)
def _df(rows: list[dict]) -> pl.DataFrame:
cols = ["symbol", "name", "close", "change_pct", "amplitude", "vol_ratio_5d",
"turnover_rate", "consecutive_limit_ups",
"signal_limit_up", "signal_broken_limit_up", "signal_limit_down_recovery",
"signal_limit_down", "signal_n_day_high", "signal_n_day_low",
"signal_volume_surge"]
base = {c: [] for c in cols}
for r in rows:
for c in cols:
base[c].append(r.get(c))
return pl.DataFrame(base)
def test_counts_filter_and_priority():
repo = _FakeRepo(_df([
{"symbol": "A1", "name": "", "close": 10.0, "change_pct": 0.1,
"signal_limit_up": True, "signal_n_day_high": True},
{"symbol": "B1", "name": "", "close": 5.0, "change_pct": -0.05,
"signal_limit_down": True},
{"symbol": "C1", "name": "", "close": 8.0, "change_pct": 0.02,
"signal_volume_surge": True},
{"symbol": "D1", "name": "", "close": 7.0, "change_pct": None}, # 无信号 → 不出现
]))
out = build_intraday(repo)
assert out["cache_date"] == "2026-08-28"
assert out["counts"] == {"limit_up": 1, "broken": 0, "recovery": 0,
"limit_down": 1, "new_high": 1, "new_low": 0,
"volume_surge": 1}
syms = [r["symbol"] for r in out["rows"]]
assert syms == ["A1", "B1", "C1"] # 优先级: 涨停 > 跌停 > 放量; 无信号被过滤
assert out["rows"][0]["signals"] == ["limit_up", "new_high"] # 多信号按优先级序
def test_limit_truncates():
repo = _FakeRepo(_df([
{"symbol": f"S{i}", "signal_volume_surge": True, "change_pct": 0.01} for i in range(10)
]))
out = build_intraday(repo, limit=3)
assert len(out["rows"]) == 3
assert out["counts"]["volume_surge"] == 10 # counts 不受 limit 影响
def test_empty_snapshot():
repo = _FakeRepo(pl.DataFrame({"symbol": [], "name": []}))
out = build_intraday(repo)
assert out["rows"] == [] and out["counts"] == {}
def test_missing_signal_columns_degrades():
repo = _FakeRepo(pl.DataFrame({"symbol": ["A1"], "name": [""]}))
out = build_intraday(repo)
assert out["rows"] == [] and out["counts"] == {}
+161
View File
@@ -0,0 +1,161 @@
"""Windows 读锁竞态下 parquet 原子替换的重试测试。
根因: polars scan_parquet / DuckDB read_parquet 扫描进行中持有分区句柄,
Windows os.replace 替换"仍被打开"的目标文件抛 PermissionError (WinError 5);
Linux inode 交换语义无此限制表现为个股分时"补齐数据"500
修复: replace_with_retry 短退避重试穿过读窗口; 永久占用则原样抛出
两处 _atomic_write_parquet (repository / kline_sync) 均接入
另含 DuckDB 句柄泄漏回归: latest_minute_date 等曾用 self.db.execute(...)
.fetchone() 直连共享连接, 未消费结果集把首个分区句柄钉死在连接上,
导致同步 os.replace 永久被拒 (修为 execute_one cursor+close)
"""
from __future__ import annotations
import os
import sys
from datetime import date, datetime
import polars as pl
import pytest
from app.services import kline_sync
from app.tickflow import repository
try:
import psutil
_PSUTIL = True
except ImportError: # pragma: no cover
_PSUTIL = False
def _minute_frame() -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["600519.SH"],
"datetime": [datetime(2026, 1, 15, 9, 30)],
"open": [10.0], "high": [10.5], "low": [9.5], "close": [10.2],
"volume": [100.0], "amount": [1020.0],
})
def _flaky_replace(monkeypatch, fail_times: int) -> dict:
"""os.replace 前 fail_times 次 raise PermissionError, 之后正常执行。"""
real_replace = os.replace
state = {"calls": 0}
def _flaky(src, dst):
state["calls"] += 1
if state["calls"] <= fail_times:
raise PermissionError(5, "拒绝访问。")
return real_replace(src, dst)
monkeypatch.setattr(os, "replace", _flaky)
return state
# ---------- replace_with_retry 本体 ----------
def test_retry_succeeds_after_transient_blocks(tmp_path, monkeypatch):
out = tmp_path / "part.parquet"
out.write_bytes(b"old")
src = tmp_path / "part.parquet.tmp"
src.write_bytes(b"new")
state = _flaky_replace(monkeypatch, fail_times=2)
repository.replace_with_retry(src, out, attempts=5, delay_s=0)
assert out.read_bytes() == b"new"
assert state["calls"] == 3
assert not src.exists()
def test_retry_exhausted_raises_last_error(tmp_path, monkeypatch):
monkeypatch.setattr(os, "replace", lambda s, d: (_ for _ in ()).throw(PermissionError(5, "拒绝访问。")))
src = tmp_path / "a.tmp"
src.write_bytes(b"x")
with pytest.raises(PermissionError, match="拒绝访问"):
repository.replace_with_retry(src, tmp_path / "a.parquet", attempts=3, delay_s=0)
assert src.exists() # 未被消费, 目标未生成
def test_retry_no_block_single_attempt(tmp_path, monkeypatch):
out = tmp_path / "part.parquet"
out.write_bytes(b"old")
src = tmp_path / "part.parquet.tmp"
src.write_bytes(b"new")
state = _flaky_replace(monkeypatch, fail_times=0)
repository.replace_with_retry(src, out, attempts=5, delay_s=0)
assert state["calls"] == 1
assert out.read_bytes() == b"new"
# ---------- 两处 _atomic_write_parquet 接入 ----------
def test_kline_sync_atomic_write_survives_transient_lock(tmp_path, monkeypatch):
state = _flaky_replace(monkeypatch, fail_times=1)
out = tmp_path / "date=2026-01-15" / "part.parquet"
out.parent.mkdir(parents=True)
kline_sync._atomic_write_parquet(_minute_frame(), out)
assert out.exists()
assert state["calls"] == 2
assert pl.read_parquet(out).height == 1
def test_repository_atomic_write_survives_transient_lock(tmp_path, monkeypatch):
state = _flaky_replace(monkeypatch, fail_times=1)
out = tmp_path / "kline_minute" / "date=2026-01-15" / "part.parquet"
out.parent.mkdir(parents=True)
repository.KlineRepository._atomic_write_parquet(_minute_frame(), out)
assert out.exists()
assert state["calls"] == 2
def test_write_minute_partition_survives_reader_race(tmp_path, monkeypatch):
"""集成: _write_minute_partition 读旧→concat→写新全程有读锁竞态仍完成。"""
state = _flaky_replace(monkeypatch, fail_times=2)
# 预置旧分区 (读改写路径)
old_dir = tmp_path / "date=2026-01-15"
old_dir.mkdir(parents=True)
_minute_frame().write_parquet(old_dir / "part.parquet")
written = kline_sync._write_minute_partition(_minute_frame(), tmp_path)
assert written == 1
assert state["calls"] >= 3 # 至少经历了重试
# ---------- DuckDB 句柄泄漏回归 (Windows 实测语义) ----------
@pytest.mark.skipif(sys.platform != "win32" or not _PSUTIL, reason="Windows 句柄语义 + psutil")
def test_minute_date_queries_do_not_pin_partition_handles(tmp_path):
"""latest_minute_date 等查询后不得残留分区句柄。
旧实现 self.db.execute(...).fetchone() 的未消费结果集经 DuckDB buffer
manager 钉住首个分区句柄, 后续同步 os.replace 永久 PermissionError
"""
from app.tickflow.repository import DataStore, KlineRepository
minute_dir = tmp_path / "kline_minute"
kline_sync._write_minute_partition(
_minute_frame(), minute_dir) # date=2026-01-15
repo = KlineRepository(DataStore(data_dir=tmp_path))
assert repo.latest_minute_date("600519.SH") == date(2026, 1, 15)
assert repo.latest_minute_date_global() == date(2026, 1, 15)
assert repo.earliest_minute_date() == date(2026, 1, 15)
me = psutil.Process()
held = [f.path for f in me.open_files() if "kline_minute" in f.path]
assert held == []
# 钉住场景的端到端后果: 查询后重写同一分区必须成功 (旧实现在此 PermissionError)
assert kline_sync._write_minute_partition(_minute_frame(), minute_dir) == 1
+178
View File
@@ -0,0 +1,178 @@
"""盘前风向标服务测试 (不依赖真实网络)。
覆盖: 交易日回退历史日 JSON 缓存命中与落盘收益 enrich 数学 (当日oc/全天/次日)
fuyao 未配置降级目标日失败 fallback_prev彻底失败 no_dataAI 复盘摘要段
日期用 2026-08-26/27/28 (写作时为过去交易日), 与仓库既有绝对日期测试风格一致
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import polars as pl
import pytest
from app.plugins.fuyao.client import FuyaoError
from app.services import auction_benchmark as ab
def _write_kline(data_dir: Path, day: str, rows: list[tuple[str, float, float]]) -> None:
part = data_dir / "kline_daily" / f"date={day}"
part.mkdir(parents=True, exist_ok=True)
df = pl.DataFrame(
{
"symbol": [r[0] for r in rows],
"open": [r[1] for r in rows],
"close": [r[2] for r in rows],
}
)
df.write_parquet(part / "part-0.parquet")
@pytest.fixture()
def data_dir(tmp_path: Path) -> Path:
for d in ("2026-08-26", "2026-08-27", "2026-08-28"):
(tmp_path / "kline_daily" / f"date={d}").mkdir(parents=True, exist_ok=True)
_write_kline(tmp_path, "2026-08-26", [("600519.SH", 1690.0, 1700.0), ("000858.SZ", 130.0, 131.0)])
_write_kline(tmp_path, "2026-08-27", [("600519.SH", 1717.0, 1734.0), ("000858.SZ", 132.0, 130.0)])
_write_kline(tmp_path, "2026-08-28", [("600519.SH", 1734.0, 1768.68), ("000858.SZ", 129.0, 133.0)])
return tmp_path
class _FakeProvider:
"""记录调用; fail_dates 中的日期抛 FuyaoError。"""
def __init__(self, fail_dates: set[str] | None = None):
self.calls: list[str | None] = []
self.fail_dates = fail_dates or set()
def short_term_benchmark(self, date_iso: str | None) -> dict:
self.calls.append(date_iso)
if date_iso in self.fail_dates:
raise FuyaoError(f"code=3002: {date_iso} 未就绪")
return {
"date": date_iso or "2026-08-28",
"date_ms": 0,
"item": [
{"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台",
"auction_pct": 1.0, "tags": ["白酒", "超级品牌"]},
{"thscode": "000858.SZ", "ticker": "000858", "name": "五粮液",
"auction_pct": -2.5, "tags": ["白酒"]},
],
}
def _use_provider(monkeypatch, provider) -> _FakeProvider:
monkeypatch.setattr(ab, "_provider", lambda: provider)
return provider
# ---- 交易日解析 ----
def test_resolve_rolls_back_non_trading_day(data_dir):
assert ab.resolve_trade_date(data_dir, date(2026, 8, 30)) == date(2026, 8, 28)
assert ab.resolve_trade_date(data_dir, date(2026, 8, 27)) == date(2026, 8, 27)
# ---- 状态与缓存 ----
def test_source_unavailable_without_fuyao(data_dir, monkeypatch):
monkeypatch.setattr(ab, "_provider", lambda: None)
out = ab.get_auction_benchmark(data_dir, None)
assert out["state"] == "source_unavailable"
def test_fetch_stores_cache_then_hits_cache(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
out = ab.get_auction_benchmark(data_dir, None) # 默认 → 最近分区 08-28
assert out["state"] == "ok" and out["trade_date"] == "2026-08-28"
assert out["count"] == 2 and len(out["items"]) == 2
assert provider.calls == ["2026-08-28"]
assert (data_dir / "auction_benchmark" / "date=2026-08-28.json").exists()
provider.calls.clear()
out2 = ab.get_auction_benchmark(data_dir, date(2026, 8, 30)) # 周日 → 08-28 → 命中缓存
assert out2["state"] == "ok" and out2["trade_date"] == "2026-08-28"
assert provider.calls == []
def test_explicit_history_date_uses_cache(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
ab.get_auction_benchmark(data_dir, date(2026, 8, 27))
assert provider.calls == ["2026-08-27"]
provider.calls.clear()
ab.get_auction_benchmark(data_dir, date(2026, 8, 27))
assert provider.calls == []
def test_failure_falls_back_to_prev(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider(fail_dates={"2026-08-28"}))
out = ab.get_auction_benchmark(data_dir, date(2026, 8, 28))
assert out["state"] == "fallback_prev"
assert out["trade_date"] == "2026-08-27"
assert out["requested_date"] == "2026-08-28"
# 回退日缓存以 ok 落盘 (不污染直查)
cached = json.loads((data_dir / "auction_benchmark" / "date=2026-08-27.json").read_text(encoding="utf-8"))
assert cached["state"] == "ok"
def test_total_failure_returns_no_data(data_dir, monkeypatch):
_use_provider(monkeypatch, _FakeProvider(fail_dates={"2026-08-28", "2026-08-27"}))
out = ab.get_auction_benchmark(data_dir, date(2026, 8, 28))
assert out["state"] == "no_data"
assert "2026-08-28" in out.get("message", "")
def test_corrupt_cache_refetches(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
cache = data_dir / "auction_benchmark" / "date=2026-08-28.json"
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text("{broken json", encoding="utf-8")
out = ab.get_auction_benchmark(data_dir, date(2026, 8, 28))
assert out["state"] == "ok"
assert provider.calls # 缓存损坏 → 重新拉取
# ---- 收益 enrich ----
def test_enrich_math_with_local_kline(data_dir, monkeypatch):
# 显式查 08-27: prev=08-26, next=08-28
_use_provider(monkeypatch, _FakeProvider())
out = ab.get_auction_benchmark(data_dir, date(2026, 8, 27))
by = {i["thscode"]: i for i in out["items"]}
mt = by["600519.SH"]
# day0_oc = 1734/1717-1; day0_pct = 1734/1700-1; d1 = 1768.68/1734-1
assert mt["day0_oc"] == pytest.approx(1734.0 / 1717.0 - 1)
assert mt["day0_pct"] == pytest.approx(1734.0 / 1700.0 - 1)
assert mt["d1_pct"] == pytest.approx(1768.68 / 1734.0 - 1)
wly = by["000858.SZ"]
assert wly["day0_oc"] == pytest.approx(130.0 / 132.0 - 1)
assert wly["d1_pct"] == pytest.approx(133.0 / 130.0 - 1)
# 原始字段透传
assert mt["auction_pct"] == 1.0 and mt["tags"] == ["白酒", "超级品牌"]
def test_enrich_missing_kline_gives_none(data_dir, monkeypatch):
# 最新分区 08-28 无次日 → d1_pct=None; kline 行存在则 oc/pct 正常
_use_provider(monkeypatch, _FakeProvider())
out = ab.get_auction_benchmark(data_dir, None)
for i in out["items"]:
assert i["d1_pct"] is None
assert i["day0_oc"] is not None
# ---- AI 复盘摘要 ----
def test_build_recap_context_contains_summary(data_dir, monkeypatch):
_use_provider(monkeypatch, _FakeProvider())
ctx = ab.build_recap_context(data_dir)
assert "盘前风向标名单" in ctx and "贵州茅台" in ctx
assert "白酒" in ctx # 概念标签
assert "当日" in ctx # 收益对照
def test_build_recap_context_empty_without_source(data_dir, monkeypatch):
monkeypatch.setattr(ab, "_provider", lambda: None)
assert ab.build_recap_context(data_dir) == ""
+4 -4
View File
@@ -12,7 +12,7 @@ from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
from app.tickflow.policy import _augment_custom_sources
def _set_providers(monkeypatch, *, daily="tickflow", adj="same_as_daily",
def _set_providers(monkeypatch, *, daily="tickflow", adj="tickflow",
minute="tickflow", financial="tickflow") -> None:
"""mock preferences 各数据集 provider getter。"""
from app.services import preferences
@@ -42,9 +42,9 @@ def test_daily_custom_source_grants_daily_batch(monkeypatch):
assert not capset.has(Cap.FINANCIAL)
def test_adj_same_as_daily_resolves_to_daily_provider(monkeypatch):
"""adj_factor_provider=same_as_daily → 跟随 daily provider 判定。"""
_set_providers(monkeypatch, daily="mock_src", adj="same_as_daily")
def test_adj_custom_source_grants_adj_factor(monkeypatch):
"""adj 显式路由到声明除权的自定义源 → 补授能力 (跟随日K已下线, 独立判定)"""
_set_providers(monkeypatch, adj="mock_src")
_set_datasets(monkeypatch, {"adj_factor"})
capset = CapabilitySet()
_augment_custom_sources(capset)
+256
View File
@@ -0,0 +1,256 @@
"""能力路由矩阵契约测试。
覆盖: 注册表与路由偏好字段一一对应候选按各源 datasets 声明过滤
候选只含当前可用源 (未就绪插件进 pending 并携带原因)TickFlow 候选
按当前订阅档位过滤偏好指向 tickflow 但档位不足时的 tf_available
标记usable 跟随生效源 (各页能力门控的统一判定)每能力独立路由
(跟随日K特殊值已下线, 存量旧值由 preferences getter 自愈回退)
偏好指向未知源时的回退形态全部用假插件/自定义源,
不依赖真实网络与本地 data/ 目录
"""
from __future__ import annotations
from app.data_providers import custom as custom_sources
from app.data_providers.capabilities import CAPABILITY_REGISTRY, build_capability_matrix
DEFAULT_CURRENT = {
"daily_data_provider": "tickflow",
"adj_factor_provider": "tickflow",
"minute_data_provider": "tickflow",
"depth5_data_provider": "tickflow",
"realtime_data_provider": "tickflow",
"financial_data_provider": "tickflow",
}
def _fake_sources(monkeypatch, plugins: list[dict], customs: list[dict] | None = None) -> None:
monkeypatch.setattr(custom_sources, "list_plugins", lambda: plugins)
monkeypatch.setattr(custom_sources, "list_sources", lambda: customs or [])
def _by_id(matrix: dict) -> dict[str, dict]:
return {c["id"]: c for c in matrix["capabilities"]}
def test_registry_covers_all_routing_fields():
"""注册表是能力的单一权威: 可路由能力与偏好键一一对应、无重复;
full_minute 为不可路由能力 (field=None, TickFlow Expert 提供)"""
routable = [c["field"] for c in CAPABILITY_REGISTRY if c["field"] is not None]
assert sorted(routable) == sorted(DEFAULT_CURRENT)
assert len(set(routable)) == len(routable)
assert {c["id"] for c in CAPABILITY_REGISTRY} == {
"realtime", "daily", "minute", "full_minute", "depth5", "adj_factor", "financial",
}
full_minute = next(c for c in CAPABILITY_REGISTRY if c["id"] == "full_minute")
assert full_minute["field"] is None
assert full_minute["tf_tier"] == "expert"
for cap in CAPABILITY_REGISTRY:
assert cap["default"] == "tickflow"
assert cap["tf_tier"] in ("none", "starter", "pro", "expert")
assert "follow" not in cap
def test_matrix_without_third_party_sources(monkeypatch):
"""无插件无自定义源: 每个能力只剩 TickFlow 候选, 默认路由全部生效。"""
_fake_sources(monkeypatch, [])
matrix = build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert")
assert matrix["tickflow_tier"] == "expert"
assert len(matrix["capabilities"]) == 7
for cap in matrix["capabilities"]:
names = [c["name"] for c in cap["candidates"]]
assert names == ["tickflow"]
assert cap["candidates"][0]["kind"] == "builtin"
assert cap["tf_available"] is True
assert cap["usable"] is True
assert cap["pending"] == []
assert cap["current"] == cap["effective"] == "tickflow"
def test_candidates_only_available_unready_goes_pending(monkeypatch):
"""候选只包含声明了该能力且可用的源; 未就绪插件进 pending 并携带原因。"""
_fake_sources(
monkeypatch,
[
{"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime"],
"available": True, "status": "ok"},
{"name": "sdk", "display_name": "SDK", "datasets": ["daily", "minute"],
"available": False, "status": "依赖未安装"},
],
[{"name": "myhttp", "display_name": "MyHTTP", "datasets": ["financial", "realtime"]}],
)
caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert"))
assert [c["name"] for c in caps["realtime"]["candidates"]] == ["tickflow", "fuyao", "myhttp"]
assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"]
assert [c["name"] for c in caps["daily"]["pending"]] == ["sdk"]
assert caps["daily"]["pending"][0]["available"] is False
assert caps["daily"]["pending"][0]["note"] == "依赖未安装"
assert [c["name"] for c in caps["financial"]["candidates"]] == ["tickflow", "myhttp"]
assert caps["financial"]["candidates"][1]["kind"] == "custom"
assert [c["name"] for c in caps["adj_factor"]["candidates"]] == ["tickflow"]
def test_tickflow_candidates_filtered_by_tier(monkeypatch):
"""TickFlow 只出现在当前档位确实提供的能力候选里 (free: 仅日K)。"""
_fake_sources(monkeypatch, [])
caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="free"))
assert caps["daily"]["tf_available"] is True
assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"]
for cap_id in ("realtime", "minute", "depth5", "adj_factor", "financial"):
assert caps[cap_id]["tf_available"] is False
assert [c["name"] for c in caps[cap_id]["candidates"]] == []
# starter 解锁实时与除权, 分钟/五档/财务仍锁
caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="starter"))
assert caps["realtime"]["tf_available"] is True
assert caps["adj_factor"]["tf_available"] is True
assert caps["minute"]["tf_available"] is False
assert caps["depth5"]["tf_available"] is False
assert caps["financial"]["tf_available"] is False
def test_current_tickflow_unmet_tier_flagged(monkeypatch):
"""偏好仍指向 tickflow 但档位不足: current 不动, tf_available=False 供前端警示。"""
_fake_sources(monkeypatch, [])
cap = _by_id(
build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="free"),
)["realtime"]
assert cap["current"] == cap["effective"] == "tickflow"
assert cap["tf_available"] is False
assert cap["usable"] is False
assert [c["name"] for c in cap["candidates"]] == []
def test_usable_follows_effective_provider(monkeypatch):
"""usable 跟随生效源而非 TickFlow 套餐: 各页能力门控的统一判定。
- 生效源是可用插件 usable (即使 TickFlow 档位不足);
- 生效源是未就绪插件 不可用 (即使有其他可用候选);
- 除权独立路由, 档位门槛/插件可用性同样生效
"""
_fake_sources(
monkeypatch,
[
{"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime", "minute"],
"available": True, "status": "ok"},
{"name": "sdk", "display_name": "SDK", "datasets": ["daily", "adj_factor", "minute"],
"available": False, "status": "依赖未安装"},
],
)
caps = _by_id(
build_capability_matrix(
dict(DEFAULT_CURRENT, realtime_data_provider="fuyao", minute_data_provider="sdk"),
tickflow_tier="free",
),
)
# 路由到可用插件: TickFlow 档位不足不影响 usable
assert caps["realtime"]["tf_available"] is False
assert caps["realtime"]["usable"] is True
# 路由到未就绪插件: 有可用候选 (fuyao) 也不算 usable
minute = caps["minute"]
assert [c["name"] for c in minute["candidates"]] == ["fuyao"]
assert minute["usable"] is False
# 除权默认路由 tickflow: 自身档位门槛 (starter+) 生效, free 档不可用
# (独立路由, 不再随日K联动)
assert caps["adj_factor"]["effective"] == "tickflow"
assert caps["adj_factor"]["tf_available"] is False
assert caps["adj_factor"]["usable"] is False
# 除权显式路由到未就绪 sdk → 不可用 (有 tickflow 候选也不算, 日K不受影响)
caps = _by_id(
build_capability_matrix(
dict(DEFAULT_CURRENT, adj_factor_provider="sdk"), tickflow_tier="expert",
),
)
assert [c["name"] for c in caps["adj_factor"]["candidates"]] == ["tickflow"]
assert caps["adj_factor"]["effective"] == "sdk"
assert caps["adj_factor"]["usable"] is False
assert caps["daily"]["usable"] is True
def test_unknown_or_empty_tier_fails_closed(monkeypatch):
"""未知档 (探测缺失) 与空档按 none 处理: 仅全档位能力 (日K) 保留 TickFlow。"""
_fake_sources(monkeypatch, [])
for tier in ("", "unknown", None):
matrix = build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier=tier)
caps = _by_id(matrix)
assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"]
assert caps["realtime"]["tf_available"] is False
assert caps["realtime"]["candidates"] == []
assert caps["minute"]["tf_available"] is False
def test_adj_factor_routes_independently(monkeypatch):
"""除权独立路由 (跟随日K已下线): 显式切到声明除权的插件即生效, 与日K当前源无关。
历史遗留值 same_as_daily 不再被矩阵特判 存量配置经 preferences getter
按非法源回退 tickflow (getter 层自愈), 矩阵只信任注入值
"""
_fake_sources(
monkeypatch,
[{"name": "sdk", "display_name": "SDK", "datasets": ["adj_factor"],
"available": True, "status": "ok"}],
)
# 日K走 tickflow, 除权显式走 sdk → 互不影响 (TickFlow none 档下插件照常可用)
caps = _by_id(
build_capability_matrix(
dict(DEFAULT_CURRENT, adj_factor_provider="sdk"), tickflow_tier="none",
),
)
adj = caps["adj_factor"]
assert adj["current"] == adj["effective"] == "sdk"
assert adj["usable"] is True
assert caps["daily"]["effective"] == "tickflow"
assert caps["daily"]["usable"] is True
def test_depth5_capability_semantics(monkeypatch):
"""五档: pro+ 档 TickFlow 可供 (usable); 档位不足时不可用且无候选。
插件数据集白名单未开放 depth5, 假插件即使声明其他数据集也不进五档候选;
未来契约开放后声明 depth5 的源会自然成为候选 (candidates datasets 过滤)
"""
_fake_sources(
monkeypatch,
[{"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime"],
"available": True, "status": "ok"}],
)
# pro 档: TickFlow 进候选, 默认路由 tickflow → usable
cap = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="pro"))["depth5"]
assert cap["tf_available"] is True
assert [c["name"] for c in cap["candidates"]] == ["tickflow"]
assert cap["usable"] is True
# starter 档: 档位不足 → 无候选, usable False (连板梯队封单缺数据)
cap = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="starter"))["depth5"]
assert cap["tf_available"] is False
assert cap["candidates"] == []
assert cap["usable"] is False
def test_unknown_current_display_falls_back_to_name(monkeypatch):
"""偏好指向未注册源 (正常经 getters 校验不会发生): 展示回退为原始名, 不抛异常。"""
_fake_sources(monkeypatch, [])
caps = _by_id(
build_capability_matrix(
dict(DEFAULT_CURRENT, realtime_data_provider="ghost"), tickflow_tier="expert",
),
)
assert caps["realtime"]["current"] == "ghost"
assert caps["realtime"]["current_display"] == "ghost"
assert caps["realtime"]["effective_display"] == "ghost"
def test_full_minute_row_is_non_routable_expert_only(monkeypatch):
"""全量分钟行: field=None 不可路由, 生效源恒为 TickFlow, 按 expert 档判定可用。"""
_fake_sources(monkeypatch, [])
# 即使有插件声明别的数据集也不会成为全量分钟候选 (契约不开放该数据集)
caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert"))
fm = caps["full_minute"]
assert fm["field"] is None
assert [c["name"] for c in fm["candidates"]] == ["tickflow"]
assert fm["usable"] is True
assert fm["tf_available"] is True
assert fm["effective"] == "tickflow"
caps_pro = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="pro"))
fm_pro = caps_pro["full_minute"]
assert fm_pro["candidates"] == []
assert fm_pro["usable"] is False
assert fm_pro["tf_available"] is False
+228 -20
View File
@@ -1,15 +1,19 @@
"""自定义源实时行情涨跌幅单位自适应归一测试
"""自定义源实时行情比例字段单位归一测试 (CONTRIBUTING §3.1)
契约要求 change_pct/amplitude/turnover_rate 小数制 (0.0366 = 3.66%),
但不少第三方接口( a-stock-data)直接返回 3.66 表示 3.66%未归一会把
行业/概念统计与前端 x100 展示整体放大 100 (用户反馈)
契约: change_pct/amplitude/turnover_rate 小数制 (0.0366 = 3.66%)
单位只认显式声明 pct_unit: percent|decimal, 不靠数值猜:
- 声明 percent 无条件 /100; 声明 decimal 无条件透传;
- 未声明 change_pct 保留截面中位数判定(涨跌停 30% 上限物理可判),
amplitude/turnover_rate None 交下游重算(fail-closed),
已被 transforms 显式处理过的列视为用户接管单位, 透传
"""
from __future__ import annotations
import polars as pl
import pytest
from app.data_providers.custom.config import CustomSourceConfig, DatasetConfig
from app.data_providers.custom.config import CustomSourceConfig, DatasetConfig, config_from_dict
from app.data_providers.custom.provider import GenericHTTPProvider, _normalize_pct_units
@@ -22,24 +26,64 @@ def _df(pcts, amps=None, turnovers=None):
return pl.DataFrame(data)
def test_percent_unit_batch_is_divided_by_100():
out = _normalize_pct_units(_df(
# ---- 显式声明: percent ----
def test_declared_percent_divides_all_columns():
out = _normalize_pct_units(
_df(
[1.5, -2.2, 0.9, 2.8, -1.1, 0.6, 3.3, -0.8],
amps=[2.0, 3.5, 1.8, 4.0, 2.5, 1.2, 5.0, 1.6],
turnovers=[0.5, 1.2, 0.8, 2.0, 0.9, 0.4, 1.5, 0.7],
))
),
pct_unit="percent",
)
assert out["change_pct"][0] == pytest.approx(0.015)
assert out["amplitude"][0] == pytest.approx(0.02)
assert out["turnover_rate"][0] == pytest.approx(0.005)
def test_fraction_unit_batch_untouched():
def test_declared_percent_wins_even_when_values_look_decimal():
# 百分制低波动日: 0.25 表示 0.25%, 数值落在小数制区间内——声明优先, 不靠猜
out = _normalize_pct_units(
_df(
[0.25, 0.30, 0.28, 0.27, 0.26, 0.22],
amps=[0.4, 0.5, 0.45, 0.6, 0.5, 0.4],
turnovers=[0.05, 0.08, 0.06, 0.1, 0.07, 0.05],
),
pct_unit="percent",
)
assert out["change_pct"][0] == pytest.approx(0.0025)
assert out["amplitude"][0] == pytest.approx(0.004)
assert out["turnover_rate"][0] == pytest.approx(0.0005)
# ---- 显式声明: decimal ----
def test_declared_decimal_passes_through():
pcts = [0.015, -0.022, 0.009, 0.028, -0.011, 0.006, 0.033, -0.008]
out = _normalize_pct_units(_df(pcts, amps=[0.02, 0.035, 0.018, 0.04, 0.025, 0.012, 0.05, 0.016]))
out = _normalize_pct_units(
_df(pcts, amps=[0.02, 0.035, 0.018, 0.04, 0.025, 0.012, 0.05, 0.016]), pct_unit="decimal"
)
assert out["change_pct"].to_list() == pcts
assert out["amplitude"][0] == pytest.approx(0.02)
def test_declared_decimal_wins_even_when_values_look_percent():
# 用户声明了小数制就按小数制契约透传, 不替用户"修正"数据
out = _normalize_pct_units(_df([3.66, -2.15, 0.9, 2.8, 1.1]), pct_unit="decimal")
assert out["change_pct"][0] == pytest.approx(3.66)
# ---- 未声明: change_pct 保留截面判定(物理可判) ----
def test_undeclared_change_pct_percent_batch_normalized():
out = _normalize_pct_units(_df([1.5, -2.2, 0.9, 2.8, 3.3, 0.6]))
assert out["change_pct"][0] == pytest.approx(0.015)
def test_limit_up_fraction_30cm_not_divided():
# 北交所 30% 涨跌停的小数制极值不应被误判为百分制
out = _normalize_pct_units(_df([0.30, 0.29, 0.28, 0.27, 0.26]))
@@ -60,38 +104,85 @@ def test_string_values_are_cast():
assert out["change_pct"][0] == pytest.approx(0.015)
# ---- 未声明: amplitude/turnover_rate fail-closed (核心修复) ----
def test_undeclared_amplitude_and_turnover_are_nulled():
# 百分制 0.05 = 0.05% 与小数制 0.05 = 5% 数值相同, 不可判定 → 置 None
out = _normalize_pct_units(
_df(
[1.5, -2.2, 0.9, 2.8, 3.3, 0.6],
amps=[2.0, 3.5, 1.8, 4.0, 5.0, 1.6],
turnovers=[0.05, 1.2, 0.8, 2.0, 1.5, 0.7],
)
)
assert out["amplitude"].null_count() == 6
assert out["turnover_rate"].null_count() == 6
# change_pct 仍正常归一
assert out["change_pct"][0] == pytest.approx(0.015)
def test_undeclared_transformed_column_passes_through():
# 用户已用 transforms 显式处理过单位(如 value / 100)的列: 视为接管, 不置 None
out = _normalize_pct_units(
_df([1.5, -2.2, 0.9, 2.8, 3.3, 0.6], turnovers=[0.005, 0.012, 0.008, 0.02, 0.015, 0.007]),
transformed_cols=frozenset({"turnover_rate"}),
)
assert out["turnover_rate"][0] == pytest.approx(0.005)
# 未 transform 的 amplitude 仍 fail-closed
assert "amplitude" not in out.columns
def test_missing_or_null_columns_noop():
out = _normalize_pct_units(pl.DataFrame({"close": [1.0, 2.0]}))
assert out.columns == ["close"]
out2 = _normalize_pct_units(_df([None, None, None, None, None, None]))
assert out2["change_pct"].null_count() == 6
# 全 null 的不可判定列保持 null
out3 = _normalize_pct_units(_df([1.5, -2.2, 0.9, 2.8, 3.3, 0.6], turnovers=[None] * 6))
assert out3["turnover_rate"].null_count() == 6
def _realtime_provider(rows):
provider = GenericHTTPProvider(CustomSourceConfig(
# ---- provider 集成 ----
def _realtime_provider(rows, **ds_kwargs):
provider = GenericHTTPProvider(
CustomSourceConfig(
name="pct_source",
display_name="Pct Source",
datasets={"realtime": DatasetConfig(
datasets={
"realtime": DatasetConfig(
url="https://example.test/realtime",
field_map={
"code": "symbol", "price": "last_price", "pre_close": "prev_close",
"pct": "change_pct", "amp": "amplitude", "turnover": "turnover_rate",
"code": "symbol",
"price": "last_price",
"pre_close": "prev_close",
"pct": "change_pct",
"amp": "amplitude",
"turnover": "turnover_rate",
},
)},
))
**ds_kwargs,
)
},
)
)
provider._request_rows = lambda cfg, **kwargs: rows
return provider
def test_get_realtime_normalizes_percent_source():
provider = _realtime_provider([
_ROWS = [
{"code": "S1", "price": 10.0, "pre_close": 9.85, "pct": 1.52, "amp": 2.4, "turnover": 1.1},
{"code": "S2", "price": 20.0, "pre_close": 20.44, "pct": -2.15, "amp": 3.1, "turnover": 0.8},
{"code": "S3", "price": 30.0, "pre_close": 29.8, "pct": 0.67, "amp": 1.9, "turnover": 0.5},
{"code": "S4", "price": 40.0, "pre_close": 38.9, "pct": 2.83, "amp": 4.2, "turnover": 2.0},
{"code": "S5", "price": 50.0, "pre_close": 50.55, "pct": -1.09, "amp": 2.0, "turnover": 0.9},
{"code": "S6", "price": 60.0, "pre_close": 59.64, "pct": 0.60, "amp": 1.6, "turnover": 0.7},
])
]
def test_get_realtime_declared_percent_source():
provider = _realtime_provider(_ROWS, pct_unit="percent")
try:
rows = provider.get_realtime()
finally:
@@ -101,3 +192,120 @@ def test_get_realtime_normalizes_percent_source():
assert by_sym["S1"]["amplitude"] == pytest.approx(0.024)
assert by_sym["S1"]["turnover_rate"] == pytest.approx(0.011)
assert by_sym["S2"]["change_pct"] == pytest.approx(-0.0215)
def test_get_realtime_undeclared_nulls_ambiguous_columns():
provider = _realtime_provider(_ROWS)
try:
rows = provider.get_realtime()
finally:
provider.close()
by_sym = {r["symbol"]: r for r in rows}
# change_pct 截面判定仍归一
assert by_sym["S1"]["change_pct"] == pytest.approx(0.0152)
# 不可判定列 fail-closed
assert by_sym["S1"]["amplitude"] is None
assert by_sym["S1"]["turnover_rate"] is None
def test_get_realtime_transformed_turnover_kept():
provider = _realtime_provider(_ROWS, transforms={"turnover_rate": "value / 100"})
try:
rows = provider.get_realtime()
finally:
provider.close()
by_sym = {r["symbol"]: r for r in rows}
assert by_sym["S1"]["turnover_rate"] == pytest.approx(0.011)
assert by_sym["S1"]["amplitude"] is None
# ---- 配置解析与校验 ----
def test_config_parses_pct_unit():
cfg = config_from_dict(
{
"name": "s",
"datasets": {
"realtime": {
"url": "https://example.test",
"pct_unit": "Percent",
}
},
}
)
assert cfg.datasets["realtime"].pct_unit == "percent"
def test_config_rejects_invalid_pct_unit():
with pytest.raises(ValueError, match="pct_unit"):
config_from_dict(
{
"name": "s",
"datasets": {
"realtime": {
"url": "https://example.test",
"pct_unit": "basis_point",
}
},
}
)
def test_validate_flags_pct_unit_on_non_realtime():
provider = GenericHTTPProvider(
CustomSourceConfig(
name="s",
display_name="S",
datasets={
"daily": DatasetConfig(
url="https://example.test",
field_map={
"c": "symbol",
"d": "date",
"o": "open",
"h": "high",
"l": "low",
"cl": "close",
"v": "volume",
"a": "amount",
},
pct_unit="percent",
)
},
)
)
try:
errors = provider.validate()
finally:
provider.close()
assert any("pct_unit" in e and "realtime" in e for e in errors)
def test_validate_flags_invalid_pct_unit_value():
provider = GenericHTTPProvider(
CustomSourceConfig(
name="s",
display_name="S",
datasets={
"realtime": DatasetConfig(
url="https://example.test",
field_map={
"c": "symbol",
"p": "last_price",
"pc": "prev_close",
"o": "open",
"h": "high",
"l": "low",
"v": "volume",
},
pct_unit="bp",
)
},
)
)
try:
errors = provider.validate()
finally:
provider.close()
assert any("pct_unit" in e for e in errors)
+158
View File
@@ -0,0 +1,158 @@
"""板块分时 (dimension-intraday) 纯函数测试。
夹具: snapshot 扩展配置 (所属概念) + kline_minute/kline_daily 分区,
验证等权口径停牌 ffillprev_close/首根基准与各降级状态
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import polars as pl
from app.api.ext_data import _dimension_intraday_compute
from app.services.ext_data import ExtConfig
def _mk_config() -> ExtConfig:
return ExtConfig(id="ext_gn", label="测试概念", mode="snapshot", fields=[])
def _write_ext(data_dir: Path, values: dict[str, str]) -> None:
"""snapshot 扩展数据: symbol → 所属概念 标签串。"""
cfg_dir = data_dir / "ext_data" / "ext_gn"
cfg_dir.mkdir(parents=True, exist_ok=True)
df = pl.DataFrame({
"symbol": list(values.keys()),
"所属概念": list(values.values()),
})
df.write_parquet(cfg_dir / "part.parquet")
def _write_minute(data_dir: Path, day: str, rows: list[tuple[str, str, float]]) -> None:
part = data_dir / "kline_minute" / f"date={day}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
df = pl.DataFrame(
{
"symbol": [r[0] for r in rows],
"datetime": [datetime.fromisoformat(r[1]) for r in rows],
"close": [r[2] for r in rows],
},
schema_overrides={"datetime": pl.Datetime("us")},
)
df.write_parquet(part)
def _write_daily(data_dir: Path, day: str, closes: dict[str, float]) -> None:
part = data_dir / "kline_daily" / f"date={day}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame({
"symbol": list(closes.keys()),
"close": list(closes.values()),
}).write_parquet(part)
def test_dimension_intraday_equal_weight_and_ffill(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能、芯片", "000002.SZ": "人工智能"})
# 前收: 000001=10.0 (+5%/+6%/+4%), 000002=20.0, 600000 非成分股
_write_daily(data_dir, "2026-08-27", {"000001.SZ": 10.0, "000002.SZ": 20.0, "600000.SH": 5.0})
# 000002 在 09:32 无成交 (停牌分钟) → ffill 沿用 20.4
_write_minute(data_dir, "2026-08-28", [
("000001.SZ", "2026-08-28T09:31:00", 10.5),
("000002.SZ", "2026-08-28T09:31:00", 20.4),
("600000.SH", "2026-08-28T09:31:00", 5.05),
("000001.SZ", "2026-08-28T09:32:00", 10.6),
("600000.SH", "2026-08-28T09:32:00", 5.10),
("000001.SZ", "2026-08-28T09:33:00", 10.4),
("000002.SZ", "2026-08-28T09:33:00", 20.8),
("600000.SH", "2026-08-28T09:33:00", 5.20),
])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "ok"
assert payload["date"] == "2026-08-28"
assert payload["basis"] == "prev_close"
assert payload["member_count"] == 2
assert payload["members_with_minute"] == 2
points = payload["points"]
assert [p["time"] for p in points] == ["09:31", "09:32", "09:33"]
# 09:31: 成分等权 (5% + 2%)/2 = 3.5%; 全市场 (5+2+1)/3 ≈ 2.667% (小数制)
assert points[0]["sector"] == 0.035
assert points[0]["market"] == 0.0267
# 09:32: 000002 ffill 20.4 → (6% + 2%)/2 = 4.0% (无 ffill 会是 6.0%)
assert points[1]["sector"] == 0.04
assert points[1]["market"] == 0.04
# 09:33: (4% + 4%)/2 = 4.0%
assert points[2]["sector"] == 0.04
def test_dimension_intraday_tag_no_partial_match(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能体"})
_write_minute(data_dir, "2026-08-28", [("000001.SZ", "2026-08-28T09:31:00", 10.5)])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "empty"
assert payload["reason"] == "no_members"
def test_dimension_intraday_no_minute_store(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能"})
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "no_data"
assert payload["reason"] == "minute_missing"
def test_dimension_intraday_requested_date_absent(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能"})
_write_minute(data_dir, "2026-08-28", [("000001.SZ", "2026-08-28T09:31:00", 10.5)])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", "2026-08-27")
assert payload["status"] == "no_data"
assert payload["reason"] == "minute_missing"
def test_dimension_intraday_members_without_bars(tmp_path: Path) -> None:
"""成分股全是 ETF 等无分钟数据的标的 → empty/no_member_bars。"""
data_dir = tmp_path / "data"
_write_ext(data_dir, {"510050.SH": "人工智能"})
_write_daily(data_dir, "2026-08-27", {"510050.SH": 3.0, "000001.SZ": 10.0})
_write_minute(data_dir, "2026-08-28", [("000001.SZ", "2026-08-28T09:31:00", 10.5)])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "empty"
assert payload["reason"] == "no_member_bars"
assert payload["member_count"] == 1
def test_dimension_intraday_first_close_basis(tmp_path: Path) -> None:
"""无前一交易日日K → 基准退化为当日首根 close, 曲线起点 ≈ 0。"""
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能"})
_write_minute(data_dir, "2026-08-28", [
("000001.SZ", "2026-08-28T09:31:00", 10.0),
("000001.SZ", "2026-08-28T09:32:00", 10.3),
])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "ok"
assert payload["basis"] == "first_close"
assert payload["points"][0]["sector"] == 0.0
assert payload["points"][1]["sector"] == 0.03
def test_dimension_intraday_explicit_date_uses_that_partition(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能"})
_write_daily(data_dir, "2026-08-26", {"000001.SZ": 10.0})
_write_daily(data_dir, "2026-08-27", {"000001.SZ": 11.0})
_write_minute(data_dir, "2026-08-27", [("000001.SZ", "2026-08-27T09:31:00", 10.5)])
_write_minute(data_dir, "2026-08-28", [("000001.SZ", "2026-08-28T09:31:00", 12.1)])
# 默认取最新分区 2026-08-28 → prev 为 08-27 的 11.0 → +10%
latest = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert latest["date"] == "2026-08-28"
assert latest["points"][0]["sector"] == 0.1
# 显式指定 08-27 → prev 为 08-26 的 10.0 → +5%
explicit = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", "2026-08-27")
assert explicit["date"] == "2026-08-27"
assert explicit["points"][0]["sector"] == 0.05
+157
View File
@@ -0,0 +1,157 @@
"""龙虎榜服务测试 (不依赖真实网络)。
覆盖: 交易日回退 (非交易日/目标日解析)历史日 JSON 缓存命中与落盘
fuyao 未配置降级当日未发布 fallback_prev彻底失败 no_data
AI 复盘摘要段构建
日期用 2026-08-27/28 (写作时为过去交易日), 与仓库既有绝对日期测试风格一致
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from app.plugins.fuyao.client import FuyaoError
from app.services import dragon_tiger as dt
def _mk_days(data_dir: Path, *days: str) -> None:
for d in days:
(data_dir / "kline_daily" / f"date={d}").mkdir(parents=True, exist_ok=True)
@pytest.fixture()
def data_dir(tmp_path: Path) -> Path:
_mk_days(tmp_path, "2026-08-26", "2026-08-27", "2026-08-28")
return tmp_path
class _FakeProvider:
"""按 (board, date) 记录调用; fail_dates 中的日期抛 FuyaoError。"""
def __init__(self, fail_dates: set[str] | None = None):
self.calls: list[tuple] = []
self.fail_dates = fail_dates or set()
def dragon_tiger(self, board_type: str, date: str | None) -> dict:
self.calls.append((board_type, date))
if date in self.fail_dates:
raise FuyaoError(f"code=3002: {date} 未就绪")
iso = date or "2026-08-28"
return {
"trade_date": iso,
"count": 2,
"stock_count": 2,
"stock_items": [
{"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台",
"change": 0.05, "net_value": 1.5e8, "net_rate": 0.01,
"buy_value": 2e8, "sell_value": 0.5e8, "hot_rank": 3, "range_days": 1,
"org_net_value": 0.8e8},
{"thscode": "000858.SZ", "ticker": "000858", "name": "五粮液",
"change": -0.03, "net_value": -0.6e8, "net_rate": -0.004,
"buy_value": 0.4e8, "sell_value": 1e8, "range_days": 3},
],
"hot_money_items": [
{"name": "宁波桑田路", "buying": 1.9e8,
"rows": [{"thscode": "600519.SH", "name": "贵州茅台",
"hot_money_item_net_value": 1.2e8}]},
],
}
def _use_provider(monkeypatch, provider) -> _FakeProvider:
monkeypatch.setattr(dt, "_provider", lambda: provider)
return provider
# ---- 交易日解析 ----
def test_resolve_rolls_back_non_trading_day(data_dir):
# 周日 08-30 → 最近分区 08-28
assert dt.resolve_trade_date(data_dir, date(2026, 8, 30)) == date(2026, 8, 28)
# 08-27 是交易日 → 原样
assert dt.resolve_trade_date(data_dir, date(2026, 8, 27)) == date(2026, 8, 27)
def test_resolve_older_than_all_partitions_returns_target(data_dir):
# 早于全部本地分区 → 原样返回, 由调用方显式尝试 (失败如实 no_data)
assert dt.resolve_trade_date(data_dir, date(2020, 1, 1)) == date(2020, 1, 1)
# ---- 状态与缓存 ----
def test_source_unavailable_without_fuyao(data_dir, monkeypatch):
monkeypatch.setattr(dt, "_provider", lambda: None)
out = dt.get_dragon_tiger(data_dir, None)
assert out["state"] == "source_unavailable"
def test_fetch_stores_cache_then_hits_cache(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
out = dt.get_dragon_tiger(data_dir, None) # 默认 → 最近分区 08-28
assert out["state"] == "ok"
assert out["trade_date"] == "2026-08-28"
assert len(provider.calls) == 3 # 三榜一次取齐
cache = data_dir / "dragon_tiger" / "date=2026-08-28.json"
assert cache.exists()
provider.calls.clear()
out2 = dt.get_dragon_tiger(data_dir, date(2026, 8, 30)) # 周日 → 08-28 → 命中缓存
assert out2["state"] == "ok" and out2["trade_date"] == "2026-08-28"
assert provider.calls == [] # 纯本地, 不打网络
def test_explicit_history_date_uses_cache(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
dt.get_dragon_tiger(data_dir, date(2026, 8, 27))
assert provider.calls[0] == ("all", "2026-08-27")
provider.calls.clear()
dt.get_dragon_tiger(data_dir, date(2026, 8, 27))
assert provider.calls == []
def test_unpublished_today_falls_back_to_prev(data_dir, monkeypatch):
# 08-28 拉取失败 (未发布/未就绪) → 回退 08-27, state=fallback_prev
provider = _use_provider(monkeypatch, _FakeProvider(fail_dates={"2026-08-28"}))
out = dt.get_dragon_tiger(data_dir, date(2026, 8, 28))
assert out["state"] == "fallback_prev"
assert out["trade_date"] == "2026-08-27"
assert out["requested_date"] == "2026-08-28"
# 回退日缓存以 ok 状态落盘 (不污染直查)
cached = json.loads((data_dir / "dragon_tiger" / "date=2026-08-27.json").read_text(encoding="utf-8"))
assert cached["state"] == "ok"
def test_total_failure_returns_no_data(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider(fail_dates={"2026-08-28", "2026-08-27"}))
out = dt.get_dragon_tiger(data_dir, date(2026, 8, 28))
assert out["state"] == "no_data"
assert "2026-08-28" in out.get("message", "")
def test_corrupt_cache_refetches(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
cache = data_dir / "dragon_tiger" / "date=2026-08-28.json"
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text("{broken json", encoding="utf-8")
out = dt.get_dragon_tiger(data_dir, date(2026, 8, 28))
assert out["state"] == "ok"
assert provider.calls # 缓存损坏 → 重新拉取
# ---- AI 复盘摘要 ----
def test_build_recap_context_contains_summary(data_dir, monkeypatch):
_use_provider(monkeypatch, _FakeProvider())
ctx = dt.build_recap_context(data_dir)
assert "净买入居前" in ctx and "贵州茅台" in ctx
assert "机构净买居前" in ctx
assert "宁波桑田路" in ctx
def test_build_recap_context_empty_without_source(data_dir, monkeypatch):
monkeypatch.setattr(dt, "_provider", lambda: None)
assert dt.build_recap_context(data_dir) == ""
@@ -0,0 +1,52 @@
"""全量分钟能力 (Cap.INTRADAY_UNIVERSE) 契约。
- 能力位存在且值为 "intraday.universe"
- tiers.yaml expert 档声明该能力 (Pro/自定义源天然没有)
- 探测层注册了该能力的探测调用, 显示标签为全量分钟
- 缓存 schema bump ( capabilities.json 触发重探测)
- 盘中分钟服务门控挂在该能力位上
"""
from pathlib import Path
from types import SimpleNamespace
import polars as pl
from app.services.minute_refresh import MinuteRefreshService
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
from app.tickflow.policy import _CACHE_SCHEMA_VERSION, _CAP_ALIASES, _load_tiers_yaml
def test_capability_enum_value():
assert Cap("intraday.universe") is Cap.INTRADAY_UNIVERSE
def test_tiers_yaml_grants_universe_to_expert_only():
tiers = _load_tiers_yaml()
assert "intraday.universe" in tiers["expert"]
for tier in ("free", "starter", "pro"):
assert "intraday.universe" not in tiers[tier]
def test_policy_labels_and_cache_schema():
assert _CAP_ALIASES[Cap.INTRADAY_UNIVERSE] == "全量分钟"
assert _CACHE_SCHEMA_VERSION >= 6
def test_service_gate_requires_universe_not_batch_alone():
class _Repo:
store = SimpleNamespace(data_dir=Path("."))
def get_instruments(self) -> pl.DataFrame:
return pl.DataFrame({"symbol": []})
def _svc_with(caps: dict) -> MinuteRefreshService:
svc = MinuteRefreshService(_Repo())
svc.set_app_state(SimpleNamespace(capabilities=CapabilitySet(caps)))
return svc
universe = {Cap.INTRADAY_UNIVERSE: CapabilityLimits(rpm=20)}
batch_only = {Cap.INTRADAY_BATCH: CapabilityLimits(rpm=60, batch=200)}
assert _svc_with(universe).capability_ok() is True
assert _svc_with(batch_only).capability_ok() is False # 仅有 intraday.batch 不放行
assert _svc_with({}).capability_ok() is False
+242
View File
@@ -0,0 +1,242 @@
"""fuyao 财务适配测试 (不依赖真实网络)。
覆盖: 三大报表字段映射 (canonical 列名 + 扩展列透传 + ISO 日期口径)
latest_only 分档 (limit 1 vs 8)metrics 组装 (eps_basic 顺带 / bps 估值反推 /
指标 index_id 映射与未知 id 透传 / 单股指标失败不弃行)shares 恒空
报告期合并写入的逐列填空语义 (并集共存, 新行缺列不覆盖旧值)
"""
from __future__ import annotations
import polars as pl
import pytest
from app.plugins.fuyao import client as fc
from app.plugins.fuyao import provider as fp
from app.plugins.fuyao.provider import FuyaoProvider
from app.services.financial_sync import _merge_report_history
class _FakeFinClient:
"""财务端点假客户端: 记录调用入参, 按表返回预置行。"""
def __init__(
self,
statements: dict[str, list[dict]] | None = None,
indicators: dict[str, list[dict]] | None = None,
indicator_error: Exception | None = None,
valuations: list[dict] | None = None,
prices: list[dict] | None = None,
):
self.statements = statements or {}
self.indicators = indicators or {}
self.indicator_error = indicator_error
self.valuations = valuations or []
self.prices = prices or []
self.stmt_calls: list[tuple] = []
self.ind_calls: list[str] = []
def financial_statements(self, stmt, thscode, limit=1):
self.stmt_calls.append((stmt, thscode, limit))
return [dict(r, thscode=thscode) for r in self.statements.get(stmt, [])]
def financial_indicators(self, thscode, report):
self.ind_calls.append(f"{thscode}@{report}")
if self.indicator_error:
raise self.indicator_error
return self.indicators.get(report, [])
def valuations_snapshot(self, thscodes):
return [r for r in self.valuations if r.get("thscode") in thscodes]
def price_snapshot_batch(self, thscodes):
return [r for r in self.prices if r.get("thscode") in thscodes]
def _provider_with(monkeypatch, fake):
monkeypatch.setattr(
fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: fake})
)
monkeypatch.setattr(fp, "get_api_key", lambda: "test-key")
monkeypatch.setattr(fp, "_HIST_INTERVAL_S", 0)
return FuyaoProvider()
# period_end_ms: 2026-06-30 上海零点; report_date_ms: 2026-08-15 上海零点
_INCOME_ROW = {
"period": "quarterly",
"fiscal_year": 2026,
"fiscal_period": "Q2",
"report_date_ms": 1786723200000,
"period_end_ms": 1782748800000,
"currency": "CNY",
"operating_income": 90703260964.48,
"net_profit": 46033330566.78,
"parent_holder_net_profit": 44516880421.86,
"basic_eps": 35.57,
"operating_expenses": 50000000000.0, # 扶摇独有 → 扩展列
}
def test_income_mapping_canonical_columns(monkeypatch):
fake = _FakeFinClient(statements={"income": [_INCOME_ROW]})
provider = _provider_with(monkeypatch, fake)
df = provider.get_financials("income", ["600519.SH"], latest_only=True)
row = df.to_dicts()[0]
assert row["symbol"] == "600519.SH"
assert row["period_end"] == "2026-06-30"
assert row["announce_date"] == "2026-08-15"
assert row["revenue"] == pytest.approx(_INCOME_ROW["operating_income"])
assert row["net_income"] == pytest.approx(_INCOME_ROW["net_profit"])
assert row["net_income_attributable"] == pytest.approx(
_INCOME_ROW["parent_holder_net_profit"]
)
assert row["basic_eps"] == 35.57
# 扩展列: 扶摇独有数字字段原名透传; 字符串元数据不透传
assert row["operating_expenses"] == 50000000000.0
assert "thscode" not in df.columns and "ticker" not in df.columns
# 原始名不残留 (已映射字段)
assert "operating_income" not in df.columns and "net_profit" not in df.columns
def test_statements_limit_latest_vs_history(monkeypatch):
fake = _FakeFinClient(statements={"income": [_INCOME_ROW]})
provider = _provider_with(monkeypatch, fake)
provider.get_financials("income", ["600519.SH"], latest_only=True)
assert fake.stmt_calls == [("income", "600519.SH", 1)]
provider.get_financials("income", ["600519.SH"], latest_only=False)
assert fake.stmt_calls[-1] == ("income", "600519.SH", fp._FINANCIAL_HISTORY_PERIODS)
def test_balance_and_cashflow_mapping(monkeypatch):
balance = {
"period_end_ms": 1782748800000,
"report_date_ms": 1786723200000,
"assets_total": 309050784569.31,
"total_debt": 46954432394.95,
"holder_equity_total": 262096352174.36,
}
cashflow = {
"period_end_ms": 1782748800000,
"report_date_ms": 1786723200000,
"act_cash_flow_net": 92000000000.0,
"invest_cash_flow_net": -3000000000.0,
"pay_dividends_profits_interest_cash": 64000000000.0, # 扶摇独有 → 扩展列
}
fake = _FakeFinClient(
statements={"balance_sheet": [balance], "cash_flow": [cashflow]}
)
provider = _provider_with(monkeypatch, fake)
bal = provider.get_financials("balance_sheet", ["600519.SH"]).to_dicts()[0]
assert bal["total_assets"] == pytest.approx(balance["assets_total"])
assert bal["total_liabilities"] == pytest.approx(balance["total_debt"])
assert bal["total_equity"] == pytest.approx(balance["holder_equity_total"])
cf = provider.get_financials("cash_flow", ["600519.SH"]).to_dicts()[0]
assert cf["net_operating_cash_flow"] == pytest.approx(92000000000.0)
assert cf["net_investing_cash_flow"] == pytest.approx(-3000000000.0)
assert cf["pay_dividends_profits_interest_cash"] == pytest.approx(64000000000.0)
_METRICS_ABILITIES = [
{
"ability": "profitability",
"indicators": [
{"index_id": "index_weighted_avg_roe", "value": "16.7500"},
{"index_id": "sale_gross_margin", "value": "89.5552"},
],
},
{
"ability": "growth",
# 实测 index_id 与文档有出入; 未映射 id 原名透传
"indicators": [{"index_id": "fixed_asset_invest_expansion_ratio", "value": "2.12587300"}],
},
{
"ability": "solvency",
"indicators": [{"index_id": "earned_interest_multiple", "value": None}], # null → 不写列
},
]
def test_metrics_assembly(monkeypatch):
fake = _FakeFinClient(
statements={"income": [_INCOME_ROW]},
indicators={"2026-2": _METRICS_ABILITIES},
valuations=[{"thscode": "600519.SH", "pb_mrq": 6.455055}],
prices=[{"thscode": "600519.SH", "last_price": 1297.4}],
)
provider = _provider_with(monkeypatch, fake)
df = provider.get_financials("metrics", ["600519.SH"], latest_only=True)
row = df.to_dicts()[0]
assert row["period_end"] == "2026-06-30"
assert row["announce_date"] == "2026-08-15"
assert row["eps_basic"] == 35.57 # 顺带取自利润表
assert row["bps"] == pytest.approx(1297.4 / 6.455055) # 估值反推
assert row["roe"] == pytest.approx(16.75) # 字符串 → float
assert row["gross_margin"] == pytest.approx(89.5552)
assert row["fixed_asset_invest_expansion_ratio"] == pytest.approx(2.125873)
assert "earned_interest_multiple" not in df.columns # 全空指标不成列
assert fake.ind_calls == ["600519.SH@2026-2"] # report 由利润表最新期反推
def test_metrics_indicator_failure_keeps_row(monkeypatch):
"""指标端点单股失败 (如未披露期 code=5003) → 行仍写入 (eps/bps 保留)。"""
fake = _FakeFinClient(
statements={"income": [_INCOME_ROW]},
indicator_error=fc.FuyaoError("code=5003"),
valuations=[{"thscode": "600519.SH", "pb_mrq": 6.455055}],
prices=[{"thscode": "600519.SH", "last_price": 1297.4}],
)
provider = _provider_with(monkeypatch, fake)
df = provider.get_financials("metrics", ["600519.SH"], latest_only=True)
row = df.to_dicts()[0]
assert row["symbol"] == "600519.SH"
assert row["eps_basic"] == 35.57
assert "roe" not in df.columns
def test_metrics_skips_symbol_without_income(monkeypatch):
fake = _FakeFinClient(statements={"income": []})
provider = _provider_with(monkeypatch, fake)
assert provider.get_financials("metrics", ["600519.SH"]).is_empty()
def test_shares_returns_empty(monkeypatch):
provider = _provider_with(monkeypatch, _FakeFinClient())
assert provider.get_financials("shares", ["600519.SH"]).is_empty()
def test_merge_fills_missing_cells_from_old_rows():
"""逐列填空: 同报告期新行缺的列由旧行补齐, 有值则覆盖 (并集共存语义)。"""
old = pl.DataFrame({
"symbol": ["600519.SH", "600519.SH"],
"period_end": ["2026-03-31", "2026-06-30"],
"announce_date": ["2026-04-20", "2026-08-10"],
"diluted_eps": [68.1, 70.2], # tickflow 提供, fuyao 没有
"net_income": [280.0, 460.0],
})
new = pl.DataFrame({
"symbol": ["600519.SH"],
"period_end": ["2026-06-30"],
"announce_date": ["2026-08-15"], # 更晚公告 → 该期以新行为基准
"net_income": [461.5], # 修正值覆盖
# diluted_eps 缺失 → 由旧行 70.2 补齐
"bps": [200.99], # fuyao 扩展列, 旧行没有
})
merged = _merge_report_history(old, new).to_dicts()
assert len(merged) == 2
q2 = next(r for r in merged if r["period_end"] == "2026-06-30")
assert q2["net_income"] == pytest.approx(461.5) # 新值覆盖
assert q2["diluted_eps"] == pytest.approx(70.2) # 旧行补齐
assert q2["bps"] == pytest.approx(200.99) # 扩展列并入
q1 = next(r for r in merged if r["period_end"] == "2026-03-31")
assert q1["diluted_eps"] == pytest.approx(68.1) # 未触碰期原样保留
# 旧公告覆盖新公告的倒序场景: announce 早的行不覆盖晚的
reversed_new = pl.DataFrame({
"symbol": ["600519.SH"],
"period_end": ["2026-06-30"],
"announce_date": ["2026-08-01"],
"net_income": [999.0],
})
q2b = _merge_report_history(old, reversed_new).to_dicts()[1]
# 公告更晚的 old 行 (08-10) 胜出, 早公告的新行不覆盖 → 业绩修正以最新公告为准
assert q2b["net_income"] == pytest.approx(460.0)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
"""fetch_intraday_full_market_burst 单块容错契约。
一个块失败不得拖垮整轮: 成功块必须照常返回供落盘; 失败块单独重试一次;
失败块过多 (系统性故障) 时跳过重试全部用假 client, 不发真实网络请求
"""
from types import SimpleNamespace
from unittest.mock import patch
import polars as pl
from app.services import kline_sync
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
def _capset(batch: int = 2) -> CapabilitySet:
return CapabilitySet({Cap.INTRADAY_BATCH: CapabilityLimits(rpm=60, batch=batch)})
def _frame() -> pl.DataFrame:
# _normalize_minute 的最小输入: 毫秒 timestamp → 北京墙钟 datetime。
# 时间必须落在交易时段 (时区契约守卫会拒绝非交易小时的脏数据)
from datetime import datetime
from zoneinfo import ZoneInfo
ts = int(datetime(2026, 8, 28, 9, 31, tzinfo=ZoneInfo("Asia/Shanghai")).timestamp() * 1000)
return pl.DataFrame({
"timestamp": [ts],
"open": [1.0], "high": [1.0], "low": [1.0], "close": [1.0],
"volume": [100.0], "amount": [100.0],
})
class _FakeKlines:
"""intraday_batch 假实现: fail_once 首次失败重试成功, fail_always 恒失败。"""
def __init__(self, fail_once: set[str], fail_always: set[str]) -> None:
self.fail_once = set(fail_once)
self.fail_always = set(fail_always)
self.calls: list[list[str]] = []
def intraday_batch(self, chunk, **kwargs):
self.calls.append(list(chunk))
syms = set(chunk)
if syms & self.fail_always:
raise RuntimeError("permanent failure")
if syms & self.fail_once:
self.fail_once -= syms
raise RuntimeError("transient failure")
return {s: _frame() for s in chunk}
def _run(symbols: list[str], fake: _FakeKlines, batch: int = 2):
client = SimpleNamespace(klines=fake)
with patch.object(kline_sync, "get_client", return_value=client):
return kline_sync.fetch_intraday_full_market_burst(symbols, _capset(batch))
def test_transient_chunk_failure_retried_and_all_symbols_returned():
symbols = [f"S{i}" for i in range(6)] # 3 chunks (batch=2)
fake = _FakeKlines(fail_once={"S0", "S1"}, fail_always=set())
df, requests = _run(symbols, fake)
# 失败块 (S0,S1) 重试后成功 → 6 只全在, 请求数 = 3 块 + 1 次重试
assert set(df["symbol"].to_list()) == set(symbols)
assert requests == 4
def test_permanent_chunk_failure_skipped_without_losing_other_chunks():
symbols = [f"S{i}" for i in range(6)]
fake = _FakeKlines(fail_once=set(), fail_always={"S4", "S5"})
df, requests = _run(symbols, fake)
# 失败块重试仍失败 → 只跳过该块, 其余 4 只必须返回 (旧实现会整轮丢弃)
assert set(df["symbol"].to_list()) == {"S0", "S1", "S2", "S3"}
assert requests == 4
def test_all_chunks_succeed_requests_equals_chunk_count():
symbols = [f"S{i}" for i in range(6)]
fake = _FakeKlines(fail_once=set(), fail_always=set())
df, requests = _run(symbols, fake)
assert set(df["symbol"].to_list()) == set(symbols)
assert requests == 3
def test_systemic_failure_skips_retry_to_avoid_pressuring_overloaded_server():
symbols = [f"S{i}" for i in range(12)] # 6 chunks (batch=2)
# 5 个块恒失败 (>4) → 系统性故障, 不再重试
fake = _FakeKlines(fail_once=set(), fail_always={s for s in symbols if s not in ("S0", "S1")})
df, requests = _run(symbols, fake)
assert set(df["symbol"].to_list()) == {"S0", "S1"}
assert requests == 6 # 无重试
assert len(fake.calls) == 6
@@ -0,0 +1,125 @@
"""回归测试: job 记录跨进程死亡持久化(「数据在、记录丢」补丁)。
背景(用户反馈): 全市场同步 12:11~12:42 成功结束后 0.7s, uvicorn --reload
检测到代码变更杀死 worker, 恰好落在管道完成与 job_store.succeed() 落盘之间
数据已写盘但同步历史无任何记录旧实现 pending/running 仅存内存终态才
落盘, 存在整段丢失窗口
修复后契约:
- create()/start() 即落盘 pending/running 快照;
- 下次进程启动(= JobStore 实例, 同目录)把遗留的 pending/running
孤儿记录补标为 failed(中断), finished_at 取文件 mtime;
- 终态记录不受补录影响; 终态写入覆盖 running 快照(同一文件)
均为纯逻辑, 不触网
"""
from __future__ import annotations
import json
from app.services.pipeline_jobs import JobStore
def _read_disk(d, jid: str) -> dict:
return json.loads((d / f"{jid}.json").read_text("utf-8"))
# ── 创建/启动即落盘 ──────────────────────────────────────────────────────
def test_create_writes_pending_snapshot_to_disk(tmp_path):
d = tmp_path / "jobs"
store = JobStore(store_dir=d)
jid, _ = store.create(timeout_s=60)
disk = _read_disk(d, jid)
assert disk["status"] == "pending"
assert disk["stage"] == "init"
def test_start_updates_disk_snapshot_to_running(tmp_path):
d = tmp_path / "jobs"
store = JobStore(store_dir=d)
jid, _ = store.create(timeout_s=60)
store.start(jid)
disk = _read_disk(d, jid)
assert disk["status"] == "running"
assert disk["started_at"] is not None
# ── 进程死亡 → 下次启动补录 ──────────────────────────────────────────────
def test_orphan_running_record_is_reaped_on_next_boot(tmp_path):
"""核心场景: 进程死在 running(甚至工作已做完但未终态), 记录必须可见。"""
d = tmp_path / "jobs"
dead = JobStore(store_dir=d)
jid, _ = dead.create(timeout_s=60)
dead.start(jid)
dead.progress(jid, "sync", 50, "halfway") # 进度只更新内存
# 新进程 = 同目录新实例(内存为空, 只有磁盘)
revived = JobStore(store_dir=d)
j = revived.get(jid)
assert j is not None
assert j["status"] == "failed"
assert "中断" in j["error"]
assert j["finished_at"] is not None
# finished_at 基于文件 mtime(≈ start 时刻), 时长不得虚增为负或巨大
assert j["duration_s"] is not None
assert 0 <= j["duration_s"] <= 60
# 同步历史列表可见
assert any(x["id"] == jid for x in revived.list_recent())
def test_orphan_pending_record_is_reaped(tmp_path):
"""进程死在 create() 与 start() 之间: 记录同样可见, 时长为 None。"""
d = tmp_path / "jobs"
dead = JobStore(store_dir=d)
jid, _ = dead.create(timeout_s=60)
# 未 start 即死亡
revived = JobStore(store_dir=d)
j = revived.get(jid)
assert j["status"] == "failed"
assert j["duration_s"] is None
def test_reap_does_not_touch_terminal_records(tmp_path):
d = tmp_path / "jobs"
store = JobStore(store_dir=d)
jid, _ = store.create(timeout_s=60)
store.start(jid)
store.succeed(jid, {"daily_rows": 100})
revived = JobStore(store_dir=d)
j = revived.get(jid)
assert j["status"] == "succeeded"
assert j["result"] == {"daily_rows": 100}
def test_reap_allows_new_job_after_dead_orphan(tmp_path):
"""补录后旧 job 已 failed: 新进程 create() 不被死孤儿阻塞(单飞只看内存)。"""
d = tmp_path / "jobs"
dead = JobStore(store_dir=d)
old_jid, _ = dead.create(timeout_s=60)
dead.start(old_jid)
revived = JobStore(store_dir=d)
new_jid, is_new = revived.create(timeout_s=60)
assert is_new is True
assert new_jid != old_jid
# ── 终态覆盖快照 ─────────────────────────────────────────────────────────
def test_terminal_write_replaces_running_snapshot(tmp_path):
d = tmp_path / "jobs"
store = JobStore(store_dir=d)
jid, _ = store.create(timeout_s=60)
store.start(jid)
store.fail(jid, "boom")
files = list(d.glob("*.json"))
assert len(files) == 1
disk = _read_disk(d, jid)
assert disk["status"] == "failed"
assert disk["error"] == "boom"
+137
View File
@@ -0,0 +1,137 @@
"""个股详情分时轮询的 live 直拉路径测试。
背景: 盘中分钟增量落盘后, 当日本地分区很快达到 90% 完整度,
/api/kline/minute 的本地优先启发式会拦截实时补拉, 详情分时图停在
上一增量轮 (60s 滞后)live=1 让详情轮询在连续竞价时段绕过本地优先
"""
from __future__ import annotations
from datetime import date, datetime
import polars as pl
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.kline import router
from app.market_time import CN_TZ, in_continuous_session
# 2026-08-26 是周三; 10:00 处于上午连续竞价, expected(已交易分钟) = 30
_NOW = datetime(2026, 8, 26, 10, 0, tzinfo=CN_TZ)
_TODAY = date(2026, 8, 26)
_LOCAL_ROWS = 30
class _FakeRepo:
def resolve_asset_type(self, symbol: str) -> str:
return "stock"
def get_instruments(self) -> pl.DataFrame:
return pl.DataFrame(
{"symbol": [], "name": [], "total_shares": [], "float_shares": []}
)
def get_daily_asset(self, asset_type, symbol, start, end, columns=None):
return pl.DataFrame({"date": [], "close": []})
def get_minute(self, symbol, trade_date, asset_type="stock") -> pl.DataFrame:
return pl.DataFrame({
"datetime": [
datetime(2026, 8, 26, 9, 30 + offset // 60, offset % 60)
for offset in range(_LOCAL_ROWS)
],
"close": [10.0] * _LOCAL_ROWS,
})
def _client() -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.repo = _FakeRepo()
return TestClient(app)
def _patch_market(monkeypatch, *, in_session: bool) -> None:
import app.api.kline as kline_api
monkeypatch.setattr(kline_api, "cn_now", lambda: _NOW)
monkeypatch.setattr(kline_api, "cn_today", lambda: _TODAY)
monkeypatch.setattr(kline_api, "in_continuous_session", lambda: in_session)
def _patch_live_fetch(monkeypatch) -> None:
import app.api.kline as kline_api
def _fake_fetch(symbol, trade_date, asset_type="stock"):
return pl.DataFrame({
"datetime": [datetime(2026, 8, 26, 9, 59)],
"close": [11.11],
})
monkeypatch.setattr(
kline_api.kline_sync, "fetch_minute_single", _fake_fetch
)
def test_minute_live_param_bypasses_local_first_during_session(monkeypatch):
_patch_market(monkeypatch, in_session=True)
_patch_live_fetch(monkeypatch)
resp = _client().get(
"/api/kline/minute", params={"symbol": "600000.SH", "live": 1}
)
assert resp.status_code == 200
body = resp.json()
assert body["source"] == "live"
assert body["rows"][0]["close"] == 11.11
def test_minute_without_live_keeps_local_first(monkeypatch):
_patch_market(monkeypatch, in_session=True)
_patch_live_fetch(monkeypatch)
resp = _client().get(
"/api/kline/minute", params={"symbol": "600000.SH"}
)
assert resp.status_code == 200
body = resp.json()
# 本地 30 根 >= expected(30)*0.9 → 完整, 走本地
assert body["source"] == "local"
assert body["rows"][0]["close"] == 10.0
def test_minute_live_param_falls_back_to_local_off_session(monkeypatch):
_patch_market(monkeypatch, in_session=False)
_patch_live_fetch(monkeypatch)
resp = _client().get(
"/api/kline/minute", params={"symbol": "600000.SH", "live": 1}
)
assert resp.status_code == 200
body = resp.json()
assert body["source"] == "local"
@pytest.mark.parametrize(
("hour", "minute", "expected"),
[
(9, 29, False),
(9, 30, True),
(11, 30, True),
(11, 31, False),
(12, 30, False),
(13, 0, True),
(15, 0, True),
(15, 1, False),
],
)
def test_in_continuous_session_boundaries(hour: int, minute: int, expected: bool):
now = datetime(2026, 8, 26, hour, minute, tzinfo=CN_TZ) # 周三
assert in_continuous_session(now) is expected
def test_in_continuous_session_rejects_weekend():
assert in_continuous_session(datetime(2026, 8, 29, 10, 0, tzinfo=CN_TZ)) is False
+62
View File
@@ -0,0 +1,62 @@
"""分钟源历史深度能力 (minute_history_days) 契约测试。
provider 可选类属性 minute_history_days 声明 1 分钟历史深度(交易日):
- stock-sdk = 5 (免费分时接口仅保留最近 5 个交易日)
- 未声明 / tickflow None (深历史)
preferences GET 带出该字段, 前端分时档位据此收窄 (浅源默认 5, 深源默认 20)
"""
from __future__ import annotations
from types import SimpleNamespace
from app.api import settings
from app.services import preferences
def _mock_resolver(monkeypatch, provider, fallback, err=None):
monkeypatch.setattr(
"app.services.kline_sync._resolve_minute_provider",
lambda name: (provider, fallback, err),
)
def test_stocksdk_declares_five_day_history():
from app.plugins.stocksdk.provider import StockSDKProvider
assert StockSDKProvider.minute_history_days == 5
def test_history_days_from_custom_provider(monkeypatch):
"""自定义浅源 → 声明值; 前端据此只显示 1/5 日档。"""
_mock_resolver(monkeypatch, SimpleNamespace(minute_history_days=5), False)
monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: "stocksdk")
assert settings._minute_history_days() == 5
def test_history_days_none_for_undeclared_provider(monkeypatch):
"""未声明的自定义源 → None (深历史基准)。"""
_mock_resolver(monkeypatch, SimpleNamespace(), False)
monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: "my_source")
assert settings._minute_history_days() is None
def test_history_days_none_for_tickflow(monkeypatch):
"""tickflow (回退路径) → None (深历史)。"""
_mock_resolver(monkeypatch, None, True)
monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: "tickflow")
assert settings._minute_history_days() is None
def test_history_days_none_when_resolver_fails(monkeypatch):
"""resolver 异常 (registry 损坏) → 降级 None, 不抛 500。"""
_mock_resolver(monkeypatch, None, True, err="registry broken")
monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: "stocksdk")
assert settings._minute_history_days() is None
def test_preferences_get_includes_history_days(monkeypatch):
"""GET /preferences 响应包含 minute_history_days 字段。"""
_mock_resolver(monkeypatch, SimpleNamespace(minute_history_days=5), False)
payload = settings.get_preferences()
assert payload["minute_history_days"] == 5
assert "minute_data_provider" in payload
+305
View File
@@ -0,0 +1,305 @@
"""盘中分钟增量刷新服务 (minute_refresh) 测试。
覆盖:
- 连续竞价时段判定 (含边界)
- 门控链: 开关关闭 / 自定义分钟源让位 / 能力缺失 / 时段外 / 放行
- 单轮: mock 边界层脉冲 + 落盘, 校验状态字段与 universe 来源
- 偏好读写: 默认关闭间隔 clamp [60, 300]
- API: /minute-refresh/status 无服务时 available=false
不发起真实网络请求: fetch_intraday_full_market_burst _write_minute_partition
monkeypatch 替换
"""
from __future__ import annotations
from datetime import datetime
import polars as pl
from app.services import minute_refresh, preferences
from app.services.minute_refresh import MinuteRefreshService, _in_continuous_session
def _isolated_prefs(tmp_path, monkeypatch):
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
preferences._invalidate_cache()
return path
class _FakeCapSet:
def __init__(self, has_intraday_batch: bool):
self._has = has_intraday_batch
def has(self, cap) -> bool:
from app.tickflow.capabilities import Cap
# 服务门控挂 INTRADAY_UNIVERSE, 修复轮用 INTRADAY_BATCH — 两者同档, 一起授/不授
return self._has and cap in (Cap.INTRADAY_BATCH, Cap.INTRADAY_UNIVERSE)
class _FakeAppState:
def __init__(self, has_intraday_batch: bool):
self.capabilities = _FakeCapSet(has_intraday_batch)
class _FakeRepo:
def __init__(self, symbols: list[str]):
from pathlib import Path
self._inst = pl.DataFrame({"symbol": symbols})
self.store = type("S", (), {"data_dir": Path(".")})()
def get_instruments(self) -> pl.DataFrame:
return self._inst
# ── 时段判定 ────────────────────────────────────────────────────────
def test_continuous_session_boundaries():
wk = datetime(2026, 8, 25, 10, 0) # 周二
assert _in_continuous_session(wk)
assert not _in_continuous_session(datetime(2026, 8, 25, 9, 29))
assert not _in_continuous_session(datetime(2026, 8, 25, 11, 31)) # 午休
assert _in_continuous_session(datetime(2026, 8, 25, 13, 0)) # 午后恢复
assert _in_continuous_session(datetime(2026, 8, 25, 15, 0)) # 收盘瞬时
assert not _in_continuous_session(datetime(2026, 8, 25, 15, 1))
assert not _in_continuous_session(datetime(2026, 8, 22, 10, 0)) # 周六
# ── 门控链 ──────────────────────────────────────────────────────────
def _svc(tmp_path, monkeypatch, *, enabled=True, custom_provider=False, capability=True, in_hours=True):
_isolated_prefs(tmp_path, monkeypatch)
preferences.save({"minute_refresh_enabled": enabled})
if custom_provider:
# 模拟已注册的自定义分钟源 (真实注册表在测试环境未加载)
monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: "a-stock-data")
svc = MinuteRefreshService(_FakeRepo(["600000.SH"]))
svc.set_app_state(_FakeAppState(capability))
monkeypatch.setattr(minute_refresh, "_in_continuous_session", lambda now=None: in_hours)
# 交易日探针默认未知 (None → 放行): 隔离真实网络探测, holiday 分支在
# test_trading_day.py 单独覆盖
from app.services import trading_day
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: None)
return svc
def test_gate_disabled(tmp_path, monkeypatch):
assert _svc(tmp_path, monkeypatch, enabled=False)._gate_reason() == "disabled"
def test_gate_custom_provider_yields(tmp_path, monkeypatch):
svc = _svc(tmp_path, monkeypatch, custom_provider=True)
assert svc._gate_reason() == "custom_minute_provider"
def test_gate_capability_missing(tmp_path, monkeypatch):
svc = _svc(tmp_path, monkeypatch, capability=False)
assert svc._gate_reason() == "capability"
def test_gate_outside_trading_hours(tmp_path, monkeypatch):
svc = _svc(tmp_path, monkeypatch, in_hours=False)
assert svc._gate_reason() == "outside_trading_hours"
def test_gate_pass(tmp_path, monkeypatch):
svc = _svc(tmp_path, monkeypatch)
assert svc._gate_reason() is None
assert svc.capability_ok() and not svc.custom_provider_active()
# ── 单轮 ────────────────────────────────────────────────────────────
def test_run_round_writes_partition_and_updates_status(tmp_path, monkeypatch):
svc = _svc(tmp_path, monkeypatch)
minute_df = pl.DataFrame({
"symbol": ["600000.SH"],
"datetime": [datetime(2026, 8, 25, 1, 30)],
"open": [10.0], "high": [10.5], "low": [9.9], "close": [10.2],
"volume": [1000.0], "amount": [10200.0],
})
calls: dict = {}
def fake_burst(symbols, capset, *, count=300):
calls["symbols"] = list(symbols)
return (minute_df, 1)
def fake_write(df, minute_dir):
calls["dir"] = minute_dir
calls["rows"] = df.height
return df.height
monkeypatch.setattr(
"app.services.kline_sync.fetch_intraday_full_market_burst", fake_burst
)
monkeypatch.setattr("app.services.kline_sync._write_minute_partition", fake_write)
svc._run_round()
assert calls["symbols"] == ["600000.SH"]
assert calls["rows"] == 1
st = svc.status()
assert st["rounds"] == 1
assert st["last_rows"] == 1
assert st["last_symbols"] == 1
assert st["last_requests"] == 1
assert st["last_round_at"] is not None
assert st["last_error"] is None
assert st["capability_ok"] is True
def test_run_round_records_error_when_burst_empty(tmp_path, monkeypatch):
svc = _svc(tmp_path, monkeypatch)
monkeypatch.setattr(
"app.services.kline_sync.fetch_intraday_full_market_burst",
lambda symbols, capset, *, count=300: (pl.DataFrame(), 3),
)
svc._run_round()
st = svc.status()
assert st["rounds"] == 0
assert "no data" in st["last_error"]
assert st["last_requests"] == 3
# ── 两段式模式选择: 冷启动全天 → 稳态增量 ───────────────────────────
def _patch_round(monkeypatch, *, lag, inc_df, burst_df):
calls: dict = {"modes": []}
monkeypatch.setattr(
minute_refresh.MinuteRefreshService, "_today_coverage_lag_minutes",
lambda self: lag, raising=True,
)
monkeypatch.setattr(
"app.services.kline_sync.fetch_intraday_universe_increment",
lambda *a, **k: (calls["modes"].append("increment"), (inc_df, 1))[1],
)
monkeypatch.setattr(
"app.services.kline_sync.fetch_intraday_full_market_burst",
lambda symbols, capset, *, count=300: (calls["modes"].append("full"), (burst_df, 28))[1],
)
monkeypatch.setattr(
"app.services.kline_sync._write_minute_partition",
lambda df, minute_dir: df.height,
)
return calls
def _inc_df():
return pl.DataFrame({
"symbol": ["600000.SH", "000001.SZ"],
"datetime": [datetime(2026, 8, 25, 10, 0)] * 2,
"open": [10.0] * 2, "high": [10.5] * 2, "low": [9.9] * 2, "close": [10.2] * 2,
"volume": [1000.0] * 2, "amount": [10200.0] * 2,
})
def _full_df():
return _inc_df()
def test_cold_start_no_local_data_uses_full_mode(tmp_path, monkeypatch):
"""当日无数据 (lag=None, 如 10 点冷启动) → 全天修复轮。"""
svc = _svc(tmp_path, monkeypatch)
calls = _patch_round(monkeypatch, lag=None, inc_df=_inc_df(), burst_df=_full_df())
svc._run_round()
assert calls["modes"] == ["full"]
st = svc.status()
assert st["last_mode"] == "full"
assert st["last_rows"] == 2
def test_healthy_coverage_uses_increment_mode(tmp_path, monkeypatch):
"""当日覆盖新鲜 (lag ≤ 3 分钟) → universe 单请求增量, 不打 burst。"""
svc = _svc(tmp_path, monkeypatch)
calls = _patch_round(monkeypatch, lag=0.2, inc_df=_inc_df(), burst_df=_full_df())
svc._run_round()
assert calls["modes"] == ["increment"]
st = svc.status()
assert st["last_mode"] == "increment"
assert st["last_requests"] == 1
assert st["last_symbols"] == 2
assert st["last_rows"] == 2
def test_stale_coverage_beyond_bar_headroom_falls_back_to_full(tmp_path, monkeypatch):
"""覆盖滞后超过 3 分钟 (超过 universe 3 根余量) → 全天修复轮。"""
svc = _svc(tmp_path, monkeypatch)
calls = _patch_round(monkeypatch, lag=5.0, inc_df=_inc_df(), burst_df=_full_df())
svc._run_round()
assert calls["modes"] == ["full"]
def test_consecutive_empty_rounds_escalate_to_full(tmp_path, monkeypatch):
"""universe 连续 2 轮空返回 → 第 3 轮自动升级全天修复 (自愈)。"""
svc = _svc(tmp_path, monkeypatch)
calls = _patch_round(
monkeypatch,
lag=0.2,
inc_df=pl.DataFrame(), # 增量恒空 (模拟 universe 端点持续异常)
burst_df=_full_df(),
)
svc._run_round()
svc._run_round()
assert calls["modes"] == ["increment", "increment"]
assert svc.status()["rounds"] == 0
svc._run_round()
assert calls["modes"] == ["increment", "increment", "full"]
assert svc.status()["last_mode"] == "full"
def test_status_reports_gate_reason_when_stopped(tmp_path, monkeypatch):
svc = _svc(tmp_path, monkeypatch, enabled=False)
st = svc.status()
assert st["enabled"] is False
assert st["running"] is False
assert st["gate_reason"] == "disabled"
assert st["interval_seconds"] == 6
# ── 偏好 ────────────────────────────────────────────────────────────
def test_refresh_preferences_defaults_and_clamp(tmp_path, monkeypatch):
_isolated_prefs(tmp_path, monkeypatch)
assert preferences.get_minute_refresh_enabled() is False
assert preferences.get_minute_refresh_interval() == 6
preferences.save({"minute_refresh_interval": 1})
assert preferences.get_minute_refresh_interval() == 3 # 下限
preferences.save({"minute_refresh_interval": 999})
assert preferences.get_minute_refresh_interval() == 300 # 上限
preferences.save({"minute_refresh_interval": 15})
assert preferences.get_minute_refresh_interval() == 15
def test_realtime_monitor_config_owns_refresh_keys(tmp_path, monkeypatch):
"""盘中增量配置归属实时监控端点 (set_realtime_monitor_config), 并 clamp 到 [3,300]。"""
_isolated_prefs(tmp_path, monkeypatch)
saved = preferences.set_realtime_monitor_config({
"minute_refresh_enabled": True,
"minute_refresh_interval": 1, # 越界 → clamp 到下限
})
assert saved["minute_refresh_enabled"] is True
assert saved["minute_refresh_interval"] == 3
saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 400})
assert saved["minute_refresh_interval"] == 300
saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 6})
assert saved["minute_refresh_interval"] == 6
def test_status_endpoint_without_service():
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.settings import router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
resp = client.get("/api/settings/minute-refresh/status")
assert resp.status_code == 200
assert resp.json() == {"available": False}
+526
View File
@@ -0,0 +1,526 @@
"""分钟策略 (minute_filter 后端) 测试。
覆盖:
- minute_red_streak 形态: 命中 / 不足根数不触发 / 最高K不红 / rank_by 两口径 /
乱序输入 / 最高价并列取更晚K线 / 开盘窗口(当日最早N根, 与最近N根区分)
- 引擎加载校验: 只能声明 filter_minute_historytimeframes 必须且只能是 ["1m"]
- 引擎 1m 运行: enriched 联表基础过滤 (剔除ST / 股价区间)entry hits
日线 context 拒绝
- ScreenerService 1m context: 当日分区优先缺失回退最近分区空库报错
非股票资产拒绝
"""
from __future__ import annotations
import datetime as _dt
import importlib.util
from datetime import date, datetime
from pathlib import Path
import polars as pl
from app.services.screener import ScreenerService
from app.strategy.engine import StrategyDataContext, StrategyEngine
# 分钟红7 已从内置策略改为自定义策略 (运行时 data/strategies/custom/, 不入库);
# 测试通过仓库内的参考实现夹具加载, 覆盖同一份策略逻辑。
STRATEGY_FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "strategies"
_spec = importlib.util.spec_from_file_location(
"minute_red_streak_fixture", STRATEGY_FIXTURE_DIR / "minute_red_streak.py"
)
minute_red_streak = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(minute_red_streak)
def _bars(symbol: str, candles: list[tuple[float, float, float]], start_hour: int = 9) -> pl.DataFrame:
"""candles: (open, close, high) 序列, 时间从 start_hour:30 起每分钟一根。"""
n = len(candles)
base = datetime(2026, 8, 25, start_hour, 30)
return pl.DataFrame({
"symbol": [symbol] * n,
"datetime": [base + _dt.timedelta(minutes=i) for i in range(n)],
"open": [float(c[0]) for c in candles],
"high": [float(c[2]) for c in candles],
"low": [float(min(c[0], c[1])) for c in candles],
"close": [float(c[1]) for c in candles],
"volume": [100.0] * n,
"amount": [10000.0] * n,
})
# ── 形态 ────────────────────────────────────────────────────────────
def test_pattern_hits_five_red_of_seven_with_red_top_two():
# 7根: 5红2绿, 绿K的最高价都压得比红K低 → 最高的两根(10.9/10.7)都是红
candles = [
(10.0, 10.2, 10.30), # 红
(10.2, 10.1, 10.25), # 绿 (低高点)
(10.1, 10.4, 10.50), # 红
(10.4, 10.6, 10.70), # 红 (次高)
(10.6, 10.5, 10.65), # 绿 (低高点)
(10.5, 10.7, 10.80), # 红
(10.7, 10.8, 10.90), # 红 (最高)
]
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
assert out["symbol"].to_list() == ["600000.SH"]
row = out.row(0, named=True)
assert row["red_count"] == 5
assert row["top_red_count"] == 2
assert row["close"] == 10.8
def test_pattern_insufficient_bars_never_triggers():
out = minute_red_streak.filter_minute_history(_bars("600000.SH", [(10.0, 10.2, 10.3)] * 6), {"require_limit_up": False})
assert out.is_empty()
def test_pattern_green_at_top_blocks_hit():
# 5红, 但最高的一根是绿 (高开回落) → 最高两根不全红, 不触发
candles = [
(10.0, 10.2, 10.30), # 红
(10.1, 10.4, 10.50), # 红
(10.3, 10.6, 10.70), # 红
(10.6, 10.5, 10.65), # 绿 (低高点)
(10.4, 10.5, 10.55), # 红 (低高点)
(11.5, 11.0, 12.00), # 绿 (最高)
(11.0, 11.4, 11.90), # 红 (次高)
]
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
assert out.is_empty()
def test_pattern_rank_by_close_uses_close_not_high():
# high 口径最高两根是绿K冲高; close 口径最高两根是红K → 仅 close 口径命中
candles = [
(10.0, 10.5, 10.60), # 红
(10.5, 10.9, 11.50), # 绿 (high 最高, 并列)
(10.9, 11.2, 11.40), # 红
(11.2, 11.3, 11.35), # 红
(11.3, 11.4, 11.45), # 红 (close 次高)
(11.4, 11.1, 11.50), # 绿 (high 最高, 并列)
(11.1, 11.5, 11.55), # 红 (close 最高)
]
by_high = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
by_close = minute_red_streak.filter_minute_history(
_bars("600000.SH", candles), {"rank_by_close": True, "require_limit_up": False}
)
assert by_high.is_empty()
assert by_close["symbol"].to_list() == ["600000.SH"]
def test_pattern_sorts_unordered_input_by_datetime():
bars = pl.concat([
_bars("600000.SH", [(10.0, 10.2, 10.30)]),
_bars("600000.SH", [
(10.2, 10.1, 10.25), (10.1, 10.4, 10.50), (10.4, 10.6, 10.70),
(10.6, 10.5, 10.65), (10.5, 10.7, 10.80), (10.7, 10.8, 10.90),
]),
]).sample(fraction=1.0, shuffle=True, seed=7)
out = minute_red_streak.filter_minute_history(bars, {"require_limit_up": False})
assert out["symbol"].to_list() == ["600000.SH"]
assert out.row(0, named=True)["close"] == 10.8 # 最后一根(时间最大)的收盘
def test_pattern_three_way_high_tie_prefers_later_bars():
# 三根 high 并列最高: 更早的绿K应被更晚的两根红K挤出 top2 → 命中
# (若并列取更早, top2 = {红, 绿} → 不命中; 该测试固定 "同值取更晚" 契约)
candles = [
(10.0, 10.2, 10.30), # 红
(10.1, 10.4, 10.50), # 红
(10.2, 10.1, 10.25), # 绿 (低高点)
(10.3, 10.6, 10.70), # 红
(10.8, 10.5, 10.90), # 绿 (并列最高, 最早 → 被 top2 排除)
(10.5, 10.6, 10.90), # 红 (并列最高, 中间)
(10.6, 10.8, 10.90), # 红 (并列最高, 最晚)
]
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
assert out["symbol"].to_list() == ["600000.SH"]
assert out.row(0, named=True)["top_red_count"] == 2
def test_pattern_min_red_threshold_respected():
# 4红3绿, 最高的两根红 → min_red=5 不命中, min_red=4 命中
candles = [
(10.0, 10.2, 10.30), # 红
(10.2, 10.1, 10.25), # 绿
(10.1, 10.4, 10.50), # 红
(10.4, 10.3, 10.45), # 绿
(10.3, 10.6, 10.70), # 红
(10.6, 10.5, 10.65), # 绿
(10.5, 10.8, 10.90), # 红
]
bars = _bars("600000.SH", candles)
assert minute_red_streak.filter_minute_history(bars, {"min_red": 5, "require_limit_up": False}).is_empty()
assert not minute_red_streak.filter_minute_history(bars, {"min_red": 4, "require_limit_up": False}).is_empty()
def test_pattern_uses_opening_bars_even_if_day_turns_green():
# 开盘7根 = 5红2绿命中; 第8/9根大绿回落 → 开盘窗口语义下仍命中,
# 且 close 取窗口末根 (10.8) 而非全天最新价
candles = [
(10.0, 10.2, 10.30), # 红
(10.2, 10.1, 10.25), # 绿 (低高点)
(10.1, 10.4, 10.50), # 红
(10.4, 10.6, 10.70), # 红 (次高)
(10.6, 10.5, 10.65), # 绿 (低高点)
(10.5, 10.7, 10.80), # 红
(10.7, 10.8, 10.90), # 红 (最高) ← 窗口末根
(10.8, 10.0, 10.85), # 开盘窗口外的绿
(10.0, 9.5, 10.05), # 开盘窗口外的绿
]
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
assert out["symbol"].to_list() == ["600000.SH"]
row = out.row(0, named=True)
assert row["red_count"] == 5
assert row["close"] == 10.8 # 窗口末根收盘, 不是第9根的 9.5
assert row["last_datetime"] == datetime(2026, 8, 25, 9, 36)
def test_pattern_opening_window_miss_not_rescued_by_late_reds():
# 开盘7根仅4红不命中; 第8/9根转红 (最近7根口径会命中) → 开盘窗口仍不触发
candles = [
(10.0, 10.2, 10.30), # 红
(10.2, 10.1, 10.25), # 绿
(10.1, 10.4, 10.50), # 红
(10.4, 10.3, 10.45), # 绿
(10.3, 10.6, 10.70), # 红
(10.6, 10.5, 10.65), # 绿
(10.5, 10.8, 10.90), # 红
(10.8, 10.9, 11.00), # 红 (窗口外)
(10.9, 11.0, 11.10), # 红 (窗口外)
]
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
assert out.is_empty()
# ── 涨停条件 (日线维度) ─────────────────────────────────────────────
_HIT_CANDLES = [
(10.0, 10.2, 10.30), # 红
(10.2, 10.1, 10.25), # 绿 (低高点)
(10.1, 10.4, 10.50), # 红
(10.4, 10.6, 10.70), # 红 (次高)
(10.6, 10.5, 10.65), # 绿 (低高点)
(10.5, 10.7, 10.80), # 红
(10.7, 10.8, 10.90), # 红 (最高)
]
def _daily(
symbol: str,
days: int,
flag_on: set[int] | None = None,
*,
broken: bool = False,
) -> pl.DataFrame:
"""days 个交易日的日线帧; flag_on 指定第几天 (0=最早) 触发涨停信号。"""
flag_on = flag_on or set()
base = date(2026, 8, 25)
return pl.DataFrame({
"symbol": [symbol] * days,
"date": [base - _dt.timedelta(days=days - i) for i in range(days)],
"signal_limit_up": [i in flag_on and not broken for i in range(days)],
"signal_broken_limit_up": [i in flag_on and broken for i in range(days)],
})
def test_pattern_limit_up_condition_filters_by_daily_signals():
bars = pl.concat([
_bars("600001.SH", _HIT_CANDLES),
_bars("600002.SH", _HIT_CANDLES),
_bars("600003.SH", _HIT_CANDLES),
])
daily = pl.concat([
_daily("600001.SH", 20, {3}), # 收盘涨停 → 过
_daily("600002.SH", 20, {15}, broken=True), # 炸板触及 → 过
_daily("600003.SH", 20), # 无涨停 → 剔除
])
out = minute_red_streak.filter_minute_history(bars, {}, daily=daily)
assert sorted(out["symbol"].to_list()) == ["600001.SH", "600002.SH"]
assert sorted(out["recent_limit_ups"].to_list()) == [1, 1]
def test_pattern_limit_up_lookback_window_boundary():
# 25 个交易日, 涨停仅发生在第 5 天 (0=最早): 回看 20 日窗口 = 最后 20 根
# (索引 5..24), 第 5 天在窗外 → 不命中; 回看放宽到 25 → 命中
bars = _bars("600000.SH", _HIT_CANDLES)
daily = _daily("600000.SH", 25, {4})
assert minute_red_streak.filter_minute_history(bars, {}, daily=daily).is_empty()
out = minute_red_streak.filter_minute_history(
bars, {"limit_up_days": 25}, daily=daily
)
assert out["symbol"].to_list() == ["600000.SH"]
def test_pattern_limit_up_fails_closed_without_daily():
# 日线窗口缺失时失败闭合 (宁可漏过不可错报)
out = minute_red_streak.filter_minute_history(_bars("600000.SH", _HIT_CANDLES), {})
assert out.is_empty()
def test_pattern_limit_up_disabled_ignores_daily():
out = minute_red_streak.filter_minute_history(
_bars("600000.SH", _HIT_CANDLES), {"require_limit_up": False}
)
assert out["symbol"].to_list() == ["600000.SH"]
assert "recent_limit_ups" not in out.columns
# ── 引擎加载与运行 ──────────────────────────────────────────────────
def test_custom_minute_strategy_loads_with_minute_filter_backend():
# 自定义策略与内置策略共用同一加载器: 夹具目录即一个 custom 目录
engine = StrategyEngine(strategy_dirs=[STRATEGY_FIXTURE_DIR])
assert not [e for e in engine.load_errors() if "minute" in e["file"]]
s = engine.get("minute_red_streak")
assert s.execution_backend == "minute_filter"
assert s.filter_minute_history_fn is not None
assert s.meta["timeframes"] == ["1m"]
assert s.source == "custom"
def _minute_code(sid: str, timeframes: str = '["1m"]', extra: str = "") -> str:
return f'''import polars as pl
META = {{"id": "{sid}", "name": "{sid}", "asset_types": ["stock"], "timeframes": {timeframes}}}
EXECUTION_BACKEND = "minute_filter"
{extra}
def filter_minute_history(df, params):
return df.group_by("symbol").agg(
close=pl.col("close").max(), last_datetime=pl.col("datetime").max()
)
'''
def test_minute_filter_backend_validation(tmp_path):
(tmp_path / "ok.py").write_text(_minute_code("m_ok"))
(tmp_path / "bad_filter.py").write_text(
_minute_code("m_bad1", extra="def filter(df, params):\n return pl.lit(True)")
)
(tmp_path / "bad_tf.py").write_text(_minute_code("m_bad2", timeframes='["1d", "1m"]'))
engine = StrategyEngine(strategy_dirs=[tmp_path])
ids = {m["id"] for m in engine.list_strategies(include_research=True)}
assert "m_ok" in ids
assert "m_bad1" not in ids
assert "m_bad2" not in ids
assert any("only filter_minute_history" in e["error"] for e in engine.load_errors())
assert any("timeframes" in e["error"] for e in engine.load_errors())
def test_minute_filter_daily_history_validation(tmp_path):
# 声明 daily_history_bars: fn 必须接受 daily 关键字, 且范围 [0, 250]
(tmp_path / "m_daily_ok.py").write_text(
'import polars as pl\n'
'META = {"id": "m_daily_ok", "name": "x", "asset_types": ["stock"], '
'"timeframes": ["1m"], "daily_history_bars": 20}\n'
'EXECUTION_BACKEND = "minute_filter"\n'
'def filter_minute_history(df, params, *, daily=None):\n'
' return df.group_by("symbol").agg(close=pl.col("close").max())\n'
)
(tmp_path / "m_daily_kw.py").write_text(
'import polars as pl\n'
'META = {"id": "m_daily_kw", "name": "x", "asset_types": ["stock"], '
'"timeframes": ["1m"], "daily_history_bars": 20}\n'
'EXECUTION_BACKEND = "minute_filter"\n'
'def filter_minute_history(df, params):\n'
' return df.group_by("symbol").agg(close=pl.col("close").max())\n'
)
(tmp_path / "m_daily_range.py").write_text(
'import polars as pl\n'
'META = {"id": "m_daily_range", "name": "x", "asset_types": ["stock"], '
'"timeframes": ["1m"], "daily_history_bars": 300}\n'
'EXECUTION_BACKEND = "minute_filter"\n'
'def filter_minute_history(df, params, *, daily=None):\n'
' return df.group_by("symbol").agg(close=pl.col("close").max())\n'
)
engine = StrategyEngine(strategy_dirs=[tmp_path])
assert engine.has("m_daily_ok")
assert engine.get("m_daily_ok").minute_daily_bars == 20
assert not engine.has("m_daily_kw")
assert not engine.has("m_daily_range")
assert any("'daily' keyword" in e["error"] for e in engine.load_errors())
assert any("[0, 250]" in e["error"] for e in engine.load_errors())
def test_minute_run_injects_daily_history(tmp_path):
# fn 直接消费 daily (对涨停信号求和), 验证引擎把 context.daily_history 注入
(tmp_path / "m_use_daily.py").write_text(
'import polars as pl\n'
'META = {"id": "m_use_daily", "name": "x", "asset_types": ["stock"], '
'"timeframes": ["1m"], "daily_history_bars": 10}\n'
'EXECUTION_BACKEND = "minute_filter"\n'
'def filter_minute_history(df, params, *, daily=None):\n'
' if daily is None:\n'
' return pl.DataFrame(schema={"symbol": pl.Utf8})\n'
' return daily.group_by("symbol").agg(\n'
' close=pl.col("signal_limit_up").sum() + 10.0)\n'
)
engine = StrategyEngine(strategy_dirs=[tmp_path])
context = StrategyDataContext(
asset_type="stock",
timeframe="1m",
as_of=date(2026, 8, 25),
current=pl.DataFrame({
"symbol": ["600001.SH"],
"name": ["正常股"],
"total_shares": [1e8],
"float_shares": [5e7],
"amount": [3e8],
"change_pct": [0.01],
}),
history=_bars("600001.SH", [(10.0, 10.2, 10.3)] * 7),
daily_history=_daily("600001.SH", 10, {2}),
)
result = engine.run("m_use_daily", context)
assert result.total == 1
assert result.rows[0]["close"] == 11 # 10 + 窗口内 1 次收盘涨停
def test_minute_context_run_applies_enriched_basic_filter(tmp_path):
(tmp_path / "m_basic.py").write_text(_minute_code("m_basic"))
engine = StrategyEngine(strategy_dirs=[tmp_path])
hist = pl.concat([
_bars("600001.SH", [(10.0, 20.0, 25.0)] * 7), # 命中, 收盘 20
_bars("600002.SH", [(10.0, 20.0, 25.0)] * 7), # 命中但 ST → 剔除
_bars("600003.SH", [(10.0, 20.0, 25.0)] * 7), # 命中
_bars("600004.SH", [(100.0, 200.0, 250.0)] * 7), # 命中但收盘 200 → 超上限剔除
])
current = pl.DataFrame({
"symbol": ["600001.SH", "600002.SH", "600003.SH", "600004.SH"],
"name": ["正常股", "ST垃圾", "正常股2", "高价股"],
"total_shares": [1e8, 1e8, 1e8, 1e8],
"float_shares": [5e7, 5e7, 5e7, 5e7],
"amount": [3e8, 3e8, 3e8, 3e8],
"change_pct": [0.01, 0.01, 0.01, 0.01],
})
context = StrategyDataContext(
asset_type="stock",
timeframe="1m",
as_of=date(2026, 8, 25),
current=current,
history=hist,
)
result = engine.run(
"m_basic", context, overrides={"basic_filter": {"price_max": 150.0}}
)
symbols = {r["symbol"] for r in result.rows}
assert symbols == {"600001.SH", "600003.SH"}
assert all("name" in r for r in result.rows) # enriched 列已联表
assert {h["symbol"] for h in result.entry_signal_hits} == symbols
def test_minute_strategy_rejects_daily_context(tmp_path):
(tmp_path / "m_daily.py").write_text(_minute_code("m_daily"))
engine = StrategyEngine(strategy_dirs=[tmp_path])
context = StrategyDataContext(
asset_type="stock",
timeframe="1d",
as_of=date(2026, 8, 25),
current=pl.DataFrame({"symbol": ["600001.SH"]}),
)
try:
engine.run("m_daily", context)
raise AssertionError("expected ValueError")
except ValueError as e:
assert "timeframe" in str(e)
# ── ScreenerService 1m context ──────────────────────────────────────
class _FakeMinuteRepo:
def __init__(self, partitions: dict[date, pl.DataFrame]):
self.partitions = partitions
def get_minute_by_dates(self, symbols, dates, asset_type="stock"):
frames = [self.partitions[d] for d in dates if d in self.partitions]
if not frames:
return pl.DataFrame()
return pl.concat(frames).filter(pl.col("symbol").is_in(symbols))
def latest_minute_date_global(self):
return max(self.partitions) if self.partitions else None
def _svc(partitions: dict[date, pl.DataFrame], asset_type: str = "stock") -> ScreenerService:
return ScreenerService(_FakeMinuteRepo(partitions), asset_type=asset_type) # type: ignore[arg-type]
def test_minute_context_prefers_as_of_partition():
d1, d2 = date(2026, 8, 24), date(2026, 8, 25)
svc = _svc({
d1: _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3),
d2: _bars("600001.SH", [(10.0, 10.2, 10.3)] * 4),
})
ctx = svc.build_strategy_context(
None, d1, [], timeframe="1m",
current=pl.DataFrame({"symbol": ["600001.SH"], "name": ["x"]}),
)
assert ctx.history.height == 3 # as_of 当日分区, 不取更新的 d2
assert ctx.timeframe == "1m"
def test_minute_context_falls_back_to_latest_partition():
d1, d2 = date(2026, 8, 24), date(2026, 8, 25)
svc = _svc({
d1: _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3),
d2: _bars("600001.SH", [(10.0, 10.2, 10.3)] * 4),
})
ctx = svc.build_strategy_context(
None, date(2026, 8, 20), [], timeframe="1m",
current=pl.DataFrame({"symbol": ["600001.SH"]}),
)
assert ctx.history.height == 4 # 回退到最近分区 d2
def test_minute_context_empty_store_raises_with_guidance():
svc = _svc({})
try:
svc.build_strategy_context(
None, date(2026, 8, 25), [], timeframe="1m",
current=pl.DataFrame({"symbol": ["600001.SH"]}),
)
raise AssertionError("expected ValueError")
except ValueError as e:
assert "分钟K" in str(e)
def test_minute_context_rejects_non_stock_asset():
svc = _svc({date(2026, 8, 25): _bars("510300.SH", [(10.0, 10.2, 10.3)] * 3)}, asset_type="etf")
try:
svc.build_strategy_context(
None, date(2026, 8, 25), [], timeframe="1m",
current=pl.DataFrame({"symbol": ["510300.SH"]}),
)
raise AssertionError("expected ValueError")
except ValueError as e:
assert "A 股" in str(e)
def test_minute_context_loads_daily_history_for_declared_strategies():
class _FakeEngine:
def minute_daily_history_bars(self, strategy_ids):
return 5
daily = _daily("600001.SH", 6, {1})
repo = _FakeMinuteRepo({date(2026, 8, 25): _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3)})
repo.get_enriched_history = lambda target_date, lookback_days: daily # type: ignore[method-assign]
repo.get_instruments_asset = lambda asset_type: None # type: ignore[method-assign]
svc = ScreenerService(repo, asset_type="stock") # type: ignore[arg-type]
ctx = svc.build_strategy_context(
_FakeEngine(), date(2026, 8, 25), ["m_x"], timeframe="1m",
current=pl.DataFrame({"symbol": ["600001.SH"], "name": ["x"]}),
)
assert ctx.daily_history is not None
assert ctx.daily_history.height == 6 # 引擎声明 5 → 装配日线窗口
def test_minute_context_without_engine_skips_daily_history():
svc = _svc({date(2026, 8, 25): _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3)})
ctx = svc.build_strategy_context(
None, date(2026, 8, 25), [], timeframe="1m",
current=pl.DataFrame({"symbol": ["600001.SH"]}),
)
assert ctx.daily_history is None # 无引擎声明 → 不装配日线
@@ -0,0 +1,185 @@
"""分钟 K datetime 北京墙钟契约测试。
契约 (CONTRIBUTING §3.3): kline_minute.datetime 必须是北京墙钟 naive
守卫 _enforce_minute_beijing_wallclock 在两个源头入口强制:
- _normalize_minute (TickFlow , timestamp 毫秒为 UTC 基准)
- _try_custom_minute (插件/自定义源帧)
覆盖: 显式转换 / 北京墙钟直通 / UTC 特征自愈 +8 / tz-aware 换算 /
fail-closed 拒收 / 路由级契约违规回退 TickFlow
"""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock
import polars as pl
import pytest
from app.services import kline_sync
def _minute_frame(datetimes: list, symbol: str = "600519.SH") -> pl.DataFrame:
n = len(datetimes)
return pl.DataFrame({
"symbol": [symbol] * n,
"datetime": datetimes,
"open": [10.0] * n,
"high": [10.5] * n,
"low": [9.5] * n,
"close": [10.2] * n,
"volume": [100.0] * n,
"amount": [1020.0] * n,
})
def _beijing_day() -> list[datetime]:
"""一个正常交易日墙钟样本: 开盘/午盘首/收盘。"""
return [
datetime(2026, 1, 15, 9, 30),
datetime(2026, 1, 15, 13, 0),
datetime(2026, 1, 15, 15, 0),
]
# ---------- TickFlow 路径: timestamp 毫秒 (UTC 基准) → 北京墙钟 ----------
def test_tickflow_timestamp_normalizes_to_beijing_wallclock():
"""09:30 北京 = 01:30 UTC; SDK 帧 timestamp 毫秒归一后必须回到 09:30。"""
ts_ms = [
int(datetime(2026, 1, 15, 1, 30, tzinfo=UTC).timestamp() * 1000), # 09:30 北京
int(datetime(2026, 1, 15, 5, 0, tzinfo=UTC).timestamp() * 1000), # 13:00 北京
int(datetime(2026, 1, 15, 7, 0, tzinfo=UTC).timestamp() * 1000), # 15:00 北京
]
df = pl.DataFrame({
"symbol": ["600519.SH"] * 3,
"timestamp": ts_ms,
"open": [10.0] * 3, "high": [10.5] * 3,
"low": [9.5] * 3, "close": [10.2] * 3,
"volume": [100.0] * 3, "amount": [1020.0] * 3,
})
out = kline_sync._normalize_minute(df)
assert out["datetime"].to_list() == _beijing_day()
def test_tickflow_timestamp_partial_day_lunch_unaffected():
"""午间 11:30 (03:30 UTC) 同样正确归一, 不被误判为越界。"""
df = pl.DataFrame({
"symbol": ["000001.SZ"],
"timestamp": [int(datetime(2026, 1, 15, 3, 30, tzinfo=UTC).timestamp() * 1000)],
"open": [1.0], "high": [1.0], "low": [1.0], "close": [1.0],
"volume": [1.0], "amount": [1.0],
})
out = kline_sync._normalize_minute(df)
assert out["datetime"].to_list() == [datetime(2026, 1, 15, 11, 30)]
# ---------- 守卫: 各口径分类 ----------
def test_guard_beijing_naive_passthrough():
df = _minute_frame(_beijing_day())
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
assert out["datetime"].to_list() == _beijing_day()
def test_guard_utc_naive_selfhealed_plus8():
"""01:30/05:00/07:00 (UTC 墙钟特征) → 自动 +8 → 09:30/13:00/15:00。"""
df = _minute_frame([
datetime(2026, 1, 15, 1, 30),
datetime(2026, 1, 15, 5, 0),
datetime(2026, 1, 15, 7, 0),
])
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
assert out["datetime"].to_list() == _beijing_day()
def test_guard_selfheal_is_idempotent():
df = _minute_frame([datetime(2026, 1, 15, 1, 30), datetime(2026, 1, 15, 3, 0)])
once = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
twice = kline_sync._enforce_minute_beijing_wallclock(once, source="t")
assert once["datetime"].to_list() == twice["datetime"].to_list()
def test_guard_tzaware_utc_converted():
"""tz-aware UTC 01:30 → 北京墙钟 09:30 (naive)。"""
df = _minute_frame([datetime(2026, 1, 15, 1, 30, tzinfo=UTC)])
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
assert out["datetime"].dtype == pl.Datetime("us")
assert out["datetime"].to_list() == [datetime(2026, 1, 15, 9, 30)]
def test_guard_tzaware_shanghai_converted():
"""tz-aware +08:00 09:30 → 北京墙钟 09:30 (naive), 数值不变。"""
df = _minute_frame([datetime(2026, 1, 15, 9, 30, tzinfo=_shanghai_tz())])
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
assert out["datetime"].to_list() == [datetime(2026, 1, 15, 9, 30)]
def _shanghai_tz():
from zoneinfo import ZoneInfo
return ZoneInfo("Asia/Shanghai")
def test_guard_unrecognized_convention_fails_closed():
"""21:30/22:15 (境外墙钟特征) 既非北京时段也非 UTC 平移 → 拒收。"""
df = _minute_frame([datetime(2026, 1, 15, 21, 30), datetime(2026, 1, 15, 22, 15)])
with pytest.raises(ValueError, match="口径无法识别"):
kline_sync._enforce_minute_beijing_wallclock(df, source="t")
def test_guard_all_null_datetimes_passthrough():
"""全 null datetime 维持原行为 (下游落盘过滤), 不误伤。"""
df = _minute_frame([None, None])
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
assert out.height == 2
assert out["datetime"].null_count() == 2
def test_guard_string_datetimes_classified_after_parse():
"""trade_time 字符串路径: 先解析再分类 (UTC 特征串同样自愈)。"""
df = pl.DataFrame({
"symbol": ["600519.SH"],
"trade_time": ["2026-01-15 01:30:00"],
"open": [1.0], "high": [1.0], "low": [1.0], "close": [1.0],
"volume": [1.0], "amount": [1.0],
}).rename({"trade_time": "datetime"})
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
assert out["datetime"].to_list() == [datetime(2026, 1, 15, 9, 30)]
# ---------- 路由级: 自定义源契约违规 → 回退 TickFlow ----------
def _setup_custom_provider(monkeypatch, provider: object) -> None:
monkeypatch.setattr(kline_sync.preferences, "get_minute_data_provider", lambda: "mock_src")
monkeypatch.setattr("app.data_providers.custom.provider_has_dataset", lambda name, ds: True)
monkeypatch.setattr("app.data_providers.custom.get_provider", lambda name: provider)
def test_custom_provider_utc_frame_selfhealed(monkeypatch):
"""插件返回 UTC 墙钟帧 → 路由层守卫 +8 后下发, 不回退。"""
mock_provider = MagicMock()
mock_provider.get_minute = MagicMock(return_value=_minute_frame(
[datetime(2026, 1, 15, 1, 30), datetime(2026, 1, 15, 5, 0)]))
_setup_custom_provider(monkeypatch, mock_provider)
df, fallback = kline_sync._try_custom_minute(
["600519.SH"], datetime(2026, 1, 15, 9, 25), datetime(2026, 1, 15, 15, 5),
asset_type="stock",
)
assert fallback is False
assert df["datetime"].to_list() == [datetime(2026, 1, 15, 9, 30), datetime(2026, 1, 15, 13, 0)]
def test_custom_provider_garbage_datetime_falls_back(monkeypatch):
"""插件返回无法识别口径 → fail-closed 回退 TickFlow。"""
mock_provider = MagicMock()
mock_provider.get_minute = MagicMock(return_value=_minute_frame(
[datetime(2026, 1, 15, 21, 30)]))
_setup_custom_provider(monkeypatch, mock_provider)
df, fallback = kline_sync._try_custom_minute(
["600519.SH"], datetime(2026, 1, 15, 9, 25), datetime(2026, 1, 15, 15, 5),
asset_type="stock",
)
assert fallback is True
assert df is None
+48
View File
@@ -0,0 +1,48 @@
"""实时行情轮询间隔下限契约测试。
下限语义 (中立能力原则):
- 实时源路由到插件/自定义源时, TickFlow 档位限速保护不适用 通用下限 1s;
- 实时源为 tickflow , 仍按当前订阅档位查表 (none/free=6s, starter=6s, pro=3s, expert=1s);
- 默认间隔 6s 不因路由变化而改变 (只放宽下限, 不动存量偏好)
"""
from __future__ import annotations
from app.services.quote_service import QuoteService
def _route(monkeypatch, provider: str, tier: str) -> None:
from app.services import preferences
monkeypatch.setattr(preferences, "get_realtime_data_provider", lambda: provider)
monkeypatch.setattr(QuoteService, "_current_tier", classmethod(lambda cls: tier))
def _bare() -> QuoteService:
"""绕过 __init__ (单例/线程副作用), 只用无状态方法。"""
return QuoteService.__new__(QuoteService)
def test_custom_provider_min_interval_1s(monkeypatch):
"""插件/自定义源: 不受 TickFlow 档位保护, 下限放宽到 1s。"""
_route(monkeypatch, "fuyao", "none")
assert _bare().get_min_interval() == 1.0
def test_custom_provider_clamp_allows_1s(monkeypatch):
"""fuyao 路由下设置 1s 不再被抬到 6s; 超过上限仍被压回。"""
_route(monkeypatch, "fuyao", "none")
qs = _bare()
assert qs._clamp_interval(1.0) == 1.0
assert qs._clamp_interval(0.5) == 1.0
assert qs._clamp_interval(120.0) == QuoteService.MAX_INTERVAL
def test_tickflow_tier_floor_unchanged(monkeypatch):
"""TickFlow 路由: 档位查表行为不变 (none/free/starter=6s, pro=3s, expert=1s)。"""
for tier, expect in (("none", 6.0), ("free", 6.0), ("starter", 6.0), ("pro", 3.0), ("expert", 1.0)):
_route(monkeypatch, "tickflow", tier)
assert _bare().get_min_interval() == expect, tier
def test_default_interval_unchanged():
"""默认间隔仍是 6s — 放宽的只是下限, 不是默认值。"""
assert QuoteService.DEFAULT_INTERVAL == 6.0
+31
View File
@@ -0,0 +1,31 @@
"""回归测试: 实时行情模式判定 — 免费档不再提供自选实时降级。
watchlist(自选前 5 )模式已于 2026-08 移除: 自定义实时源( fuyao)
全市场快照免费且更优, TickFlow 免费档不再保留降级通路锁定判定结果,
防止该通路被无意恢复
"""
from app.services.quote_service import QuoteService
def test_custom_realtime_source_is_full_market(monkeypatch):
"""自定义实时源(如 fuyao)无视 TickFlow 档位, 恒为全市场。"""
from app.services import preferences
monkeypatch.setattr(preferences, "get_realtime_data_provider", lambda: "fuyao")
monkeypatch.setattr(QuoteService, "_current_tier", lambda: "free")
assert QuoteService.realtime_mode() == "full_market"
def test_tickflow_free_has_no_realtime(monkeypatch):
"""TickFlow 免费档 = 无实时(不再降级为自选模式)。"""
from app.services import preferences
monkeypatch.setattr(preferences, "get_realtime_data_provider", lambda: "tickflow")
monkeypatch.setattr(QuoteService, "_current_tier", lambda: "free")
assert QuoteService.realtime_mode() == "none"
assert QuoteService.is_realtime_allowed() is False
def test_tickflow_paid_is_full_market(monkeypatch):
from app.services import preferences
monkeypatch.setattr(preferences, "get_realtime_data_provider", lambda: "tickflow")
monkeypatch.setattr(QuoteService, "_current_tier", lambda: "pro")
assert QuoteService.realtime_mode() == "full_market"
+14 -7
View File
@@ -41,6 +41,7 @@ def test_all_builtin_strategies_declare_asset_types_and_timeframes():
assert engine.load_errors() == []
for meta in engine.list_strategies():
assert meta["asset_types"]
# 分钟红7已迁至自定义策略目录, 内置策略均为日线
assert meta["timeframes"] == ["1d"]
@@ -48,11 +49,13 @@ def test_all_builtin_strategies_use_matrix_backend_only():
engine = _engine()
assert engine.load_errors() == []
strategies = [engine.get(meta["id"]) for meta in engine.list_strategies()]
assert len(strategies) == 18
assert all(strategy.execution_backend == "matrix_native" for strategy in strategies)
assert all(strategy.matrix_strategy is not None for strategy in strategies)
assert all(strategy.filter_fn is None for strategy in strategies)
assert all(strategy.filter_history_fn is None for strategy in strategies)
matrix_strategies = [s for s in strategies if s.execution_backend == "matrix_native"]
assert len(matrix_strategies) == 18
assert all(s.matrix_strategy is not None for s in matrix_strategies)
assert all(s.filter_fn is None for s in matrix_strategies)
assert all(s.filter_history_fn is None for s in matrix_strategies)
# 分钟形态策略 (minute_filter) 已迁至自定义策略目录, 不在 builtin 加载范围
assert [s.meta["id"] for s in strategies if s.execution_backend == "minute_filter"] == []
def test_all_builtin_matrix_formulas_accept_base_market_matrix():
@@ -79,10 +82,14 @@ def test_all_builtin_matrix_formulas_accept_base_market_matrix():
from app.backtest.matrix import build_market_data_matrix
fields = set()
for strategy in (engine.get(meta["id"]) for meta in engine.list_strategies()):
matrix_metas = [
m for m in engine.list_strategies()
if engine.get(m["id"]).execution_backend == "matrix_native"
]
for strategy in (engine.get(meta["id"]) for meta in matrix_metas):
fields.update(engine._matrix_field_columns(strategy))
market = build_market_data_matrix(panel, field_columns=fields)
for meta in engine.list_strategies():
for meta in matrix_metas:
strategy = engine.get(meta["id"])
signals = strategy.matrix_strategy.compute_signals(market, {})
assert signals.shape == market.shape, meta["id"]
+79
View File
@@ -226,3 +226,82 @@ def test_builtin_not_editable():
raise AssertionError("expected ValueError for builtin")
except ValueError:
pass
# ---------- 分钟 open 数据卫生 (stock-sdk 上游区间模式给日级常量伪 open) ----------
def _sdk_rows(day: str, opens, closes):
"""构造 bridge 返回形状的分钟行 (timestamp 为北京墙钟对应 UTC 毫秒)。"""
from datetime import UTC, datetime, timedelta
rows = []
for i, (o, c) in enumerate(zip(opens, closes, strict=True)):
dt = datetime.fromisoformat(f"{day} 09:30:00") + timedelta(minutes=i)
ts = int(dt.replace(tzinfo=UTC).timestamp() * 1000) - 8 * 3600_000
rows.append({"timestamp": ts, "open": o, "high": max(o, c), "low": min(o, c),
"close": c, "volume": 100 + i, "amount": 1000.0 + i})
return rows
def test_minute_degenerate_open_nulled():
"""历史日 open 为全天常量(uniq=1)而 close 多值 → open 置 null。"""
n = 30
rows = _sdk_rows("2026-08-27", [8.0] * n, [10 + i * 0.01 for i in range(n)])
df = StockSDKProvider._minute_df(rows, "600664.SH")
assert df.height == n
assert df["open"].null_count() == n # 伪 open 全部置 null
assert df["close"].null_count() == 0 # close/high/low 保留
def test_minute_real_open_kept():
"""真实分钟 open (多唯一值) 原样保留。"""
n = 30
rows = _sdk_rows("2026-08-28", [10 + i * 0.01 for i in range(n)], [10.05 + i * 0.01 for i in range(n)])
df = StockSDKProvider._minute_df(rows, "600664.SH")
assert df["open"].null_count() == 0
assert df["open"].n_unique() == n
def test_minute_short_day_open_kept():
"""短交易日 (rows<=10, 如半日/首日少量bar) 不误杀。"""
rows = _sdk_rows("2026-08-26", [8.0] * 6, [8.0 + i * 0.1 for i in range(6)])
df = StockSDKProvider._minute_df(rows, "600664.SH")
assert df["open"].null_count() == 0
def test_get_minute_splits_tail_into_single_day_jobs(monkeypatch):
"""多日区间 → 末尾 3 自然日(跳过周末)逐日单拉 (保住最新交易日真实 open)。"""
from datetime import datetime
jobs = []
def fake_run_job(job, timeout=None):
jobs.append(job)
return {"ok": True, "op": "minute", "rows": {}}
monkeypatch.setattr(sp.bridge, "run_job", fake_run_job)
p = StockSDKProvider()
p.get_minute(["600519.SH"], datetime(2026, 8, 20), datetime(2026, 8, 29, 23, 0))
# 末尾 4 个自然日 (08-26..08-29) 逐日单拉, 周六 08-29 跳过;
# 前段 = [08-20, 08-25] 一个区间任务
spans = [(j["start"], j["end"]) for j in jobs]
for day in ("20260826", "20260827", "20260828"):
assert (day, day) in spans
assert ("20260829", "20260829") not in spans # 周六不单拉
assert ("20260820", "20260825") in spans
def test_get_minute_single_day_no_split(monkeypatch):
"""单日区间不拆分, 保持一个任务。"""
from datetime import datetime
jobs = []
def fake_run_job(job, timeout=None):
jobs.append(job)
return {"ok": True, "op": "minute", "rows": {}}
monkeypatch.setattr(sp.bridge, "run_job", fake_run_job)
StockSDKProvider().get_minute(["600519.SH"], datetime(2026, 8, 28), datetime(2026, 8, 28, 15, 0))
assert len(jobs) == 1
assert jobs[0]["start"] == "20260828"
+257
View File
@@ -0,0 +1,257 @@
"""交易日探针 (trading_day oracle) 与两个消费方接入的测试。
不依赖真实网络: 探测函数 (_probe_fuyao / _probe_tickflow) 全部 monkeypatch
覆盖: 周末零成本直判探测链优先级 (fuyao 日历权威, 无开盘缓冲问题)
tickflow 戳的 OR 语义与开盘缓冲窗失败/无权限 NoneTTL 缓存
实时行情门控与分钟增量 gate_reason holiday 分支
"""
from __future__ import annotations
from datetime import date, datetime, time as dt_time, timezone, timedelta
import pytest
from app.services import trading_day
from app.services.trading_day import is_trading_day, reset_cache
CN = timezone(timedelta(hours=8))
@pytest.fixture(autouse=True)
def _clean_cache():
reset_cache()
yield
reset_cache()
def _no_probes(monkeypatch):
"""探测函数替换为爆炸 — 用于验证未被打到。"""
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: (_ for _ in ()).throw(AssertionError("不应探测")))
monkeypatch.setattr(trading_day, "_probe_tickflow", lambda now: (_ for _ in ()).throw(AssertionError("不应探测")))
# ---- 周末零成本直判 ----
def test_weekend_returns_false_without_probing(monkeypatch):
sat = datetime(2026, 8, 29, 10, 0, tzinfo=CN) # 周六
sun = datetime(2026, 8, 30, 10, 0, tzinfo=CN) # 周日
_no_probes(monkeypatch)
assert is_trading_day(sat) is False
assert is_trading_day(sun) is False
# ---- 探测链优先级 ----
def test_fuyao_calendar_is_authoritative_even_before_open(monkeypatch):
"""fuyao 日历结论无时段依赖: 开盘缓冲窗内也直接生效。"""
holiday_mon = datetime(2026, 9, 7, 9, 31, tzinfo=CN) # 周一 (缓冲窗内)
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: False)
monkeypatch.setattr(
trading_day, "_probe_tickflow",
lambda now: (_ for _ in ()).throw(AssertionError("fuyao 已有结论不应继续探测")),
)
assert is_trading_day(holiday_mon) is False
def test_chain_falls_through_to_tickflow_when_fuyao_unknown(monkeypatch):
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: None)
monkeypatch.setattr(trading_day, "_probe_tickflow", lambda now: True)
assert is_trading_day(monday) is True
def test_all_probes_unknown_returns_none(monkeypatch):
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: None)
monkeypatch.setattr(trading_day, "_probe_tickflow", lambda now: None)
assert is_trading_day(monday) is None
# ---- tickflow 戳语义 ----
def test_tickflow_stale_stamp_before_buffer_is_unknown(monkeypatch):
"""开盘缓冲窗内戳停在昨日: 可能是集合竞价未翻新, 保守判未知。"""
monday_935 = datetime(2026, 9, 7, 9, 35, tzinfo=CN)
import app.tickflow.client as tf_client_mod
class _FakeQuotes:
def get(self, symbols):
# 周一 9:35 拉到上周五 15:30 的戳
return [{"symbol": s, "timestamp": 1787902251001} for s in symbols]
class _FakeClient:
quotes = _FakeQuotes()
monkeypatch.setattr(tf_client_mod, "get_client", lambda: _FakeClient())
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: None)
assert trading_day._probe_tickflow(monday_935) is None
def test_tickflow_stale_stamp_after_buffer_is_holiday(monkeypatch):
monday_1041 = datetime(2026, 9, 7, 10, 41, tzinfo=CN)
import app.tickflow.client as tf_client_mod
class _FakeQuotes:
def get(self, symbols):
return [{"symbol": s, "timestamp": 1787902251001} for s in symbols] # 上周五
class _FakeClient:
quotes = _FakeQuotes()
monkeypatch.setattr(tf_client_mod, "get_client", lambda: _FakeClient())
assert trading_day._probe_tickflow(monday_1041) is False
def test_tickflow_fresh_stamp_is_trading_anytime(monkeypatch):
"""缓冲窗内只要戳是今天就判交易日 (OR 语义, 任一翻新即可)。"""
monday_920 = datetime(2026, 9, 7, 9, 20, tzinfo=CN)
import app.tickflow.client as tf_client_mod
class _FakeQuotes:
def get(self, symbols):
# 一只翻新 + 其余停在周五 → max 为今日
fresh_ms = int(monday_920.timestamp() * 1000)
return [
{"symbol": "000001.SZ", "timestamp": fresh_ms},
{"symbol": "600519.SH", "timestamp": 1787902251001},
]
class _FakeClient:
quotes = _FakeQuotes()
monkeypatch.setattr(tf_client_mod, "get_client", lambda: _FakeClient())
assert trading_day._probe_tickflow(monday_920) is True
def test_tickflow_error_returns_none(monkeypatch):
monday = datetime(2026, 9, 7, 10, 41, tzinfo=CN)
import app.tickflow.client as tf_client_mod
def _boom():
raise RuntimeError("无实时权限 (free 档)")
monkeypatch.setattr(tf_client_mod, "get_client", _boom)
assert trading_day._probe_tickflow(monday) is None
# ---- TTL 缓存 ----
def test_verdict_cached_within_ttl(monkeypatch):
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
calls = {"n": 0}
def _counting_probe(now):
calls["n"] += 1
return True
monkeypatch.setattr(trading_day, "_probe_fuyao", _counting_probe)
assert is_trading_day(monday) is True
assert is_trading_day(monday) is True
assert is_trading_day(monday) is True
assert calls["n"] == 1 # 命中缓存, 只探一次
def test_unknown_verdict_retries_after_short_ttl(monkeypatch):
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
calls = {"n": 0}
def _counting_probe(now):
calls["n"] += 1
return None
monkeypatch.setattr(trading_day, "_probe_fuyao", _counting_probe)
monkeypatch.setattr(trading_day, "_probe_tickflow", lambda now: None) # 隔离真实网络
assert is_trading_day(monday) is None
# 手动把缓存时间拨回 10 分钟前 (超过 unknown TTL 300s) → 重探
with trading_day._CACHE_LOCK:
trading_day._CACHE.probed_at -= 600
assert is_trading_day(monday) is None
assert calls["n"] == 2
# ---- 消费方 1: 实时行情门控 ----
def test_quote_service_holiday_gate_blocks_polling(monkeypatch):
from app.services.quote_service import QuoteService
qs = QuoteService()
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: False)
assert qs._should_poll_for_phase("morning") is False
assert qs._should_poll_for_phase("preopen") is False
# final 定版同样被剔除: 休市日没有需要定版的当日行情
assert qs._should_poll_for_phase("close_final") is False
def test_quote_service_polls_when_trading_or_unknown(monkeypatch):
from app.services.quote_service import QuoteService
qs = QuoteService()
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: True)
assert qs._should_poll_for_phase("morning") is True
# 未知 → 维持现状 (周几近似)
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: None)
assert qs._should_poll_for_phase("morning") is True
# ---- 消费方 2: 分钟增量 gate_reason ----
def _minute_service(monkeypatch):
from app.services.minute_refresh import MinuteRefreshService
svc = MinuteRefreshService.__new__(MinuteRefreshService)
svc._app_state = None
monkeypatch.setattr(
"app.services.minute_refresh.preferences.get_minute_refresh_enabled",
lambda: True,
)
monkeypatch.setattr(svc, "custom_provider_active", lambda: False)
monkeypatch.setattr(svc, "capability_ok", lambda: True)
return svc
def test_minute_refresh_gate_returns_holiday(monkeypatch):
"""周几+时段门控放行 (周一盘中) 但探针判休市 → holiday。"""
svc = _minute_service(monkeypatch)
monday_1030 = datetime(2026, 9, 7, 10, 30, tzinfo=CN)
monkeypatch.setattr(
"app.services.minute_refresh._in_continuous_session", lambda now=None: True
)
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: False)
assert svc._gate_reason() == "holiday"
def test_minute_refresh_gate_passes_when_trading(monkeypatch):
svc = _minute_service(monkeypatch)
monkeypatch.setattr(
"app.services.minute_refresh._in_continuous_session", lambda now=None: True
)
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: True)
assert svc._gate_reason() is None
# ---- fuyao 日历解析 ----
def test_fuyao_provider_trading_days_conversion(monkeypatch):
from app.plugins.fuyao.provider import FuyaoProvider
from app.plugins.fuyao import provider as fp
class _CalClient:
def trading_days(self):
# 上海零点戳按 provider 的 _ms_of_date 口径构造
def _midnight_ms(d):
import calendar
return (calendar.timegm(d.timetuple()) - 28800) * 1000
d4, d7 = date(2026, 9, 4), date(2026, 9, 7)
return [
{"date": "20260904", "date_ms": _midnight_ms(d4)},
{"date": "20260907", "date_ms": _midnight_ms(d7)},
]
monkeypatch.setattr(
fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: _CalClient()})
)
monkeypatch.setattr(fp, "get_api_key", lambda: "test-key")
days = FuyaoProvider().trading_days()
assert days == {date(2026, 9, 4), date(2026, 9, 7)}
+274
View File
@@ -0,0 +1,274 @@
"""轮询放量监控 (volume_delta) 测试: 引擎命中/冷却/批量合并 + 基础过滤 + 快照差值边界。"""
from __future__ import annotations
from datetime import date
import polars as pl
import pytest
from app.strategy import monitor_rules
from app.strategy.monitor import MonitorRuleEngine
def _df(rows: list[dict]) -> pl.DataFrame:
"""rows 每项: symbol/_volume_delta 必填, 其余可选 (close/amount/total_shares/float_shares)。"""
base = {
"symbol": [], "close": [], "change_pct": [],
"_volume_delta": [], "_volume_delta_amount": [], "_volume_delta_span": [],
}
optional = ["amount", "total_shares", "float_shares"]
for r in rows:
base["symbol"].append(r["symbol"])
base["close"].append(r.get("close", 10.0))
base["change_pct"].append(0.01)
base["_volume_delta"].append(r["_volume_delta"])
base["_volume_delta_amount"].append(r.get("_volume_delta_amount", r["_volume_delta"] * 1000.0))
base["_volume_delta_span"].append(6.0)
data = {k: v for k, v in base.items()}
for col in optional:
vals = [r.get(col) for r in rows]
if any(v is not None for v in vals):
data[col] = [v if v is not None else 0.0 for v in vals]
return pl.DataFrame(data)
def _rule(**kw):
r = {
"id": "vd1", "name": "轮询放量", "type": "volume_delta",
"asset_type": "stock", "scope": "all", "enabled": True,
"threshold_volume": 9000, "cooldown_seconds": 300,
"severity": "warn",
}
r.update(kw)
return r
def test_volume_delta_hits_above_threshold():
eng = MonitorRuleEngine()
eng.set_rules([_rule()])
events = eng.evaluate(_df([
{"symbol": "S1.SH", "_volume_delta": 9500.0, "close": 10.0},
{"symbol": "S2.SH", "_volume_delta": 8999.0, "close": 20.0},
]))
assert [e["symbol"] for e in events] == ["S1.SH"]
ev = events[0]
assert ev["source"] == "volume_delta"
assert "9,500" in ev["message"] and "9,000" in ev["message"] and "间隔 6s" in ev["message"]
assert ev["volume_delta"] == 9500.0
def test_volume_delta_no_column_degrades_silently():
eng = MonitorRuleEngine()
eng.set_rules([_rule()])
plain = pl.DataFrame({"symbol": ["S1.SH"], "close": [10.0]})
assert eng.evaluate(plain) == []
def test_volume_delta_cooldown_suppresses_repeat():
eng = MonitorRuleEngine()
eng.set_rules([_rule(cooldown=300)])
df = _df([{"symbol": "S1.SH", "_volume_delta": 12000.0}])
assert len(eng.evaluate(df)) == 1
assert eng.evaluate(df) == []
def test_volume_delta_batch_merge_over_five():
eng = MonitorRuleEngine()
eng.set_rules([_rule()])
rows = [{"symbol": f"S{i}.SH", "_volume_delta": 20000.0 + i} for i in range(8)]
events = eng.evaluate(_df(rows))
assert len(events) == 1
assert events[0]["symbol"] == ""
assert "共 8 只" in events[0]["message"]
def test_volume_delta_scope_filters():
eng = MonitorRuleEngine()
eng.set_rules([_rule(scope="symbols", symbols=["S2.SH"])])
events = eng.evaluate(_df([
{"symbol": "S1.SH", "_volume_delta": 9500.0},
{"symbol": "S2.SH", "_volume_delta": 9500.0},
]))
assert [e["symbol"] for e in events] == ["S2.SH"]
def test_volume_delta_metric_amount():
eng = MonitorRuleEngine()
eng.set_rules([_rule(metric="amount", threshold_amount=5e6)])
events = eng.evaluate(_df([
{"symbol": "S1.SH", "_volume_delta": 100.0, "_volume_delta_amount": 6e6},
{"symbol": "S2.SH", "_volume_delta": 20000.0, "_volume_delta_amount": 4.9e6},
]))
assert [e["symbol"] for e in events] == ["S1.SH"]
assert "万元" in events[0]["message"]
def test_volume_delta_basic_filter_price_and_amount():
eng = MonitorRuleEngine()
eng.set_rules([_rule(basic_filter={
"price_min": 5, "price_max": 100, "amount_min": 1e8, "exclude_st": False,
})])
events = eng.evaluate(_df([
# 价低被滤
{"symbol": "LOW.SH", "_volume_delta": 20000.0, "close": 3.0, "amount": 5e8},
# 价过高被滤
{"symbol": "HIGH.SH", "_volume_delta": 20000.0, "close": 200.0, "amount": 5e8},
# 成交额不足被滤
{"symbol": "THIN.SH", "_volume_delta": 20000.0, "close": 10.0, "amount": 5e7},
# 通过
{"symbol": "OK.SH", "_volume_delta": 20000.0, "close": 10.0, "amount": 5e8},
]))
assert [e["symbol"] for e in events] == ["OK.SH"]
def test_volume_delta_basic_filter_market_cap():
eng = MonitorRuleEngine()
eng.set_rules([_rule(basic_filter={
"market_cap_min": 20e8, "price_min": None, "price_max": None,
"amount_min": None, "exclude_st": False,
})])
# close × total_shares: BIG 10×3e8=30亿 通过; SMALL 10×1e8=10亿 被滤
events = eng.evaluate(_df([
{"symbol": "BIG.SH", "_volume_delta": 20000.0, "total_shares": 3e8},
{"symbol": "SMALL.SH", "_volume_delta": 20000.0, "total_shares": 1e8},
]))
assert [e["symbol"] for e in events] == ["BIG.SH"]
def test_volume_delta_basic_filter_exclude_st():
eng = MonitorRuleEngine()
eng.set_name_map({"STOCK.SH": "平安银行", "STK.SH": "ST 某某"})
eng.set_rules([_rule(basic_filter={
"price_min": None, "price_max": None, "amount_min": None, "exclude_st": True,
})])
events = eng.evaluate(_df([
{"symbol": "STOCK.SH", "_volume_delta": 20000.0},
{"symbol": "STK.SH", "_volume_delta": 20000.0},
]))
assert [e["symbol"] for e in events] == ["STOCK.SH"]
def test_validate_and_normalize_defaults():
r = monitor_rules.normalize({"id": "vd2", "type": "volume_delta"})
assert r["threshold_volume"] == 9000
assert r["scope"] == "all"
assert r["cooldown_seconds"] == 300
assert r["metric"] == "volume"
assert r["basic_filter"]["price_min"] == 3
assert r["basic_filter"]["exclude_st"] is True
# 用户字段覆盖默认
r2 = monitor_rules.normalize({"id": "vd5", "type": "volume_delta", "basic_filter": {"price_min": 1, "exclude_st": False}})
assert r2["basic_filter"]["price_min"] == 1
assert r2["basic_filter"]["exclude_st"] is False
assert r2["basic_filter"]["price_max"] == 300 # 未覆盖项保留默认
monitor_rules.validate({"id": "vd2", "name": "n", "type": "volume_delta", "threshold_volume": 1})
with pytest.raises(ValueError):
monitor_rules.validate({"id": "vd3", "name": "n", "type": "volume_delta", "threshold_volume": 0})
with pytest.raises(ValueError):
monitor_rules.validate({"id": "vd4", "name": "n", "type": "volume_delta", "asset_type": "etf"})
with pytest.raises(ValueError):
monitor_rules.validate({"id": "vd6", "name": "n", "type": "volume_delta",
"metric": "amount", "threshold_amount": 0})
with pytest.raises(ValueError):
monitor_rules.validate({"id": "vd7", "name": "n", "type": "volume_delta",
"basic_filter": {"price_min": -1}})
with pytest.raises(ValueError):
monitor_rules.validate({"id": "vd8", "name": "n", "type": "volume_delta",
"basic_filter": {"unknown_field": 1}})
# ── 快照差值状态 (QuoteService) ──────────────────────────
def _qs(monkeypatch, *, continuous=True):
from app.services.quote_service import QuoteService
qs = QuoteService.__new__(QuoteService)
qs._prev_stock_volume = None
qs._prev_volume_fetched_at = None
qs._prev_volume_date = None
qs._volume_delta = {}
qs._volume_delta_span_s = 0.0
monkeypatch.setattr(QuoteService, "_is_continuous_trading", lambda self: continuous)
monkeypatch.setattr(
QuoteService, "_continuous_session_start_ms",
staticmethod(lambda: 0.0),
)
monkeypatch.setattr("app.services.quote_service.cn_today", lambda: date(2026, 8, 25))
return qs
def test_delta_computed_and_prev_updated(monkeypatch):
qs = _qs(monkeypatch)
t0 = 1_000_000.0
qs._update_volume_delta(
[{"symbol": "S1.SH", "volume": 10000, "amount": 5e6},
{"symbol": "S2.SH", "volume": 500, "amount": 1e6}], t0,
)
assert qs._volume_delta == {} # 首轮无 prev
qs._update_volume_delta(
[{"symbol": "S1.SH", "volume": 19500, "amount": 9.5e6},
{"symbol": "S2.SH", "volume": 400, "amount": 2e6}], t0 + 6000,
)
# S2 volume cur < prev (重置) → 丢弃; S1 差值 (9500 手, 450 万元)
assert qs._volume_delta == {"S1.SH": (9500.0, 4.5e6)}
assert qs._volume_delta_span_s == 6.0
def test_delta_cross_day_reset(monkeypatch):
import app.services.quote_service as qsm
qs = _qs(monkeypatch)
qs._update_volume_delta([{"symbol": "S1.SH", "volume": 10000}], 1000.0)
assert qs._prev_volume_date == date(2026, 8, 25)
monkeypatch.setattr(qsm, "cn_today", lambda: date(2026, 8, 26))
qs._update_volume_delta([{"symbol": "S1.SH", "volume": 20000}], 2000.0)
assert qs._volume_delta == {}
assert qs._prev_volume_date == date(2026, 8, 26)
def test_delta_open_protection(monkeypatch):
qs = _qs(monkeypatch)
# 9:29 的 prev (早于 9:30 时段起点) → 9:31 本轮不触发
session_start = 1_000_000.0
monkeypatch.setattr(
type(qs), "_continuous_session_start_ms",
staticmethod(lambda: session_start),
)
qs._update_volume_delta([{"symbol": "S1.SH", "volume": 10000}], session_start - 60_000)
qs._update_volume_delta([{"symbol": "S1.SH", "volume": 99999}], session_start + 60_000)
assert qs._volume_delta == {}
# 之后一轮 prev 已在时段内 → 恢复计算
qs._update_volume_delta([{"symbol": "S1.SH", "volume": 109999}], session_start + 66_000)
assert qs._volume_delta == {"S1.SH": (10000.0, 0.0)}
def test_delta_not_continuous_trading(monkeypatch):
qs = _qs(monkeypatch, continuous=False)
qs._update_volume_delta([{"symbol": "S1.SH", "volume": 10000}], 1000.0)
qs._update_volume_delta([{"symbol": "S1.SH", "volume": 99999}], 7000.0)
# 非连续竞价 (如午休) 不产差值, 但 prev 持续更新
assert qs._volume_delta == {}
assert qs._prev_stock_volume == {"S1.SH": (99999.0, 0.0)}
def test_inject_volume_delta_join():
from app.services.quote_service import QuoteService
qs = QuoteService.__new__(QuoteService)
qs._volume_delta = {"S1.SH": (900.0, 9e5), "S9.SH": (500.0, 5e5)}
qs._volume_delta_span_s = 6.0
base = pl.DataFrame({"symbol": ["S1.SH", "S2.SH"], "close": [10.0, 20.0]})
out = qs._inject_volume_delta(base)
assert out.filter(pl.col("symbol") == "S1.SH")["_volume_delta"][0] == 900.0
assert out.filter(pl.col("symbol") == "S1.SH")["_volume_delta_amount"][0] == 9e5
# 未命中股票为 null (不触发)
assert out.filter(pl.col("symbol") == "S2.SH")["_volume_delta"][0] is None
# 空差值原样返回
qs._volume_delta = {}
assert qs._inject_volume_delta(base).columns == ["symbol", "close"]
def test_session_start_ms_matches_clock():
from app.services.quote_service import QuoteService
from datetime import datetime, time as dt_time, timedelta, timezone
now = QuoteService._continuous_session_start_ms() / 1000.0
start_dt = datetime.fromtimestamp(now, tz=timezone(timedelta(hours=8)))
assert start_dt.time() in (dt_time(9, 30), dt_time(13, 0))
-123
View File
@@ -1,123 +0,0 @@
"""回归测试: Free 档自选实时 symbols 超过 capability batch 上限时分批请求 (PR #46 问题 4)。"""
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import polars as pl
from app.services.quote_service import QuoteService
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
def _make_svc(engine_rules: dict) -> QuoteService:
"""创建最小可用的 QuoteService 实例 (跳过 __init__)。"""
svc = QuoteService.__new__(QuoteService)
svc._app_state = MagicMock()
svc._repo = MagicMock()
svc._lock = MagicMock()
engine = MagicMock()
engine.rules = engine_rules
svc._app_state.monitor_engine = engine
svc._app_state.repo = svc._repo
svc._repo.get_index_symbol_set.return_value = {"000001.SH"}
svc._repo.get_etf_symbol_set.return_value = set()
return svc
def _run_fetch(svc, tf, watchlist: list[str], capset: CapabilitySet):
"""在完整 patch 环境下执行 _fetch_watchlist_quotes。"""
with ExitStack() as stack:
stack.enter_context(patch(
"app.services.preferences.get_realtime_watchlist_symbols",
return_value=watchlist,
))
stack.enter_context(patch(
"app.tickflow.client.get_paid_realtime_client", return_value=tf,
))
stack.enter_context(patch(
"app.tickflow.policy.detect_capabilities", return_value=capset,
))
stack.enter_context(patch("app.tickflow.rate_limits.sleep_between_batches"))
# patch 分批之后的下游处理
stack.enter_context(patch.object(
QuoteService, "_build_daily", return_value=pl.DataFrame(),
))
stack.enter_context(patch.object(
QuoteService, "_build_quote_extra", return_value=pl.DataFrame(),
))
stack.enter_context(patch.object(
QuoteService, "_build_index_quotes", return_value=pl.DataFrame(),
))
stack.enter_context(patch.object(QuoteService, "_broadcast_quote_updated"))
stack.enter_context(patch.object(QuoteService, "_evaluate_monitors"))
stack.enter_context(patch("app.services.quote_service._persist_last_fetch"))
svc._fetch_watchlist_quotes()
def test_watchlist_batch_respects_capability_limit():
"""6 symbols / batch 5 → 分 2 批请求, 不整轮失败。"""
engine_rules = {
"r_idx": {"enabled": True, "asset_type": "index", "scope": "symbols",
"symbols": ["000001.SH"]},
}
svc = _make_svc(engine_rules)
tf = MagicMock()
tf.quotes.get.return_value = [
{"symbol": "600000.SH", "last_price": 10.0, "prev_close": 9.9, "ext": {}},
]
capset = CapabilitySet({Cap.QUOTE_BY_SYMBOL: CapabilityLimits(batch=5, rpm=60)})
_run_fetch(svc, tf,
["600000.SH", "600001.SH", "600002.SH", "600003.SH", "600004.SH"],
capset)
# 5 股票 + 1 指数 = 6 symbols, batch 5 → 2 批
assert tf.quotes.get.call_count == 2
first_batch = tf.quotes.get.call_args_list[0][1]["symbols"]
second_batch = tf.quotes.get.call_args_list[1][1]["symbols"]
assert len(first_batch) == 5
assert len(second_batch) == 1
assert "000001.SH" in second_batch
def test_watchlist_batch_partial_failure_keeps_other_batches():
"""某一批拉取失败不影响其他批次 (已有股票实时刷新不丢失)。"""
engine_rules = {
"r_idx": {"enabled": True, "asset_type": "index", "scope": "symbols",
"symbols": ["000001.SH"]},
}
svc = _make_svc(engine_rules)
tf = MagicMock()
# 第一批 (股票) 成功, 第二批 (指数) 失败
tf.quotes.get.side_effect = [
[{"symbol": "600000.SH", "last_price": 10.0, "prev_close": 9.9, "ext": {}}],
ConnectionError("timeout"),
]
capset = CapabilitySet({Cap.QUOTE_BY_SYMBOL: CapabilityLimits(batch=5, rpm=60)})
_run_fetch(svc, tf,
["600000.SH", "600001.SH", "600002.SH", "600003.SH", "600004.SH"],
capset)
# 两批都被尝试 (第二批失败不阻断)
assert tf.quotes.get.call_count == 2
def test_watchlist_no_index_rules_no_extra_symbols():
"""无指数监控规则时, symbols 不追加指数标的。"""
svc = _make_svc({}) # 无规则
tf = MagicMock()
tf.quotes.get.return_value = [
{"symbol": "600000.SH", "last_price": 10.0, "prev_close": 9.9, "ext": {}},
]
capset = CapabilitySet({Cap.QUOTE_BY_SYMBOL: CapabilityLimits(batch=5, rpm=60)})
_run_fetch(svc, tf, ["600000.SH", "600001.SH"], capset)
# 2 symbols / batch 5 → 1 批
assert tf.quotes.get.call_count == 1
assert tf.quotes.get.call_args_list[0][1]["symbols"] == ["600000.SH", "600001.SH"]
@@ -1,19 +0,0 @@
"""Free 档自选实时资产分流测试。"""
from app.services.quote_service import QuoteService
def test_split_records_by_asset():
records = [
{"symbol": "600000.SH"}, {"symbol": "510300.SH"}, {"symbol": "000001.SH"},
]
index, etf, stock = QuoteService._split_records_by_asset(
records, {"000001.SH"}, {"510300.SH"},
)
assert [r["symbol"] for r in index] == ["000001.SH"]
assert [r["symbol"] for r in etf] == ["510300.SH"]
assert [r["symbol"] for r in stock] == ["600000.SH"]
# etf 优先于 index (与 resolve_asset_type 判定顺序一致)
index2, etf2, stock2 = QuoteService._split_records_by_asset(
[{"symbol": "X"}], {"X"}, {"X"},
)
assert etf2 and not index2 and not stock2
+4 -4
View File
@@ -2493,15 +2493,15 @@ wheels = [
[[package]]
name = "tickflow"
version = "0.1.24"
version = "0.1.25"
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" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/c9/facd0cd7568ea3c7dcfb0d52ded7d0b52ad871ca45fa97d7df0925b26091/tickflow-0.1.25.tar.gz", hash = "sha256:a86929bc99167014567c3d8b99af1c2f498922ad476bb4507edb57164c576d04", size = 37357, upload-time = "2026-08-29T04:43:27.255Z" }
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" },
{ url = "https://files.pythonhosted.org/packages/ea/37/f80b8f6e435f1825ea8384b1605a01c323998f00b1c112c59e72a9d7dd9a/tickflow-0.1.25-py3-none-any.whl", hash = "sha256:69687235e85b44eae077262325ddef15e82d5adafda075945612f8cf0815a44c", size = 43510, upload-time = "2026-08-29T04:43:25.785Z" },
]
[package.optional-dependencies]
@@ -2513,7 +2513,7 @@ all = [
[[package]]
name = "tickflow-stock-panel-backend"
version = "0.2.1"
version = "0.2.2"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
+25 -5
View File
@@ -125,7 +125,23 @@ datasets:
建议实时接口额外提供 `amount``change_pct``change_amount``amplitude``turnover_rate``name`。缺失时部分字段会由 pipeline 回算,但精度取决于可用输入。
`change_pct``amplitude` 使用小数制,例如 `0.0366` 表示 `3.66%`(`turnover_rate` 同)。若接口直接返回百分数值 `3.66`,实时行情会按截面中位数自动归一为小数制,但仍建议接口直接提供小数制以避免小样本歧义。
`change_pct``amplitude``turnover_rate` 统一使用小数制,例如 `0.0366` 表示 `3.66%`。百分制单位必须在 realtime 数据集上**显式声明**,不做数值猜测(数值无法区分两种单位:`0.05` 既可能是 0.05% 也可能是 5%):
```yaml
datasets:
realtime:
url: https://api.example.com/snapshot
pct_unit: percent # 接口返回 3.66 表示 3.66%;小数制源声明 decimal 或省略
```
处理规则:
| 声明 | 行为 |
| --- | --- |
| `pct_unit: percent` | `change_pct` / `amplitude` / `turnover_rate` 无条件 `/100` |
| `pct_unit: decimal` | 三列原样透传 |
| 未声明 | `change_pct` 按截面中位数归一(A 股涨跌停 30% 上限使两种单位物理可分);`amplitude` / `turnover_rate` **置 `None`** 交由 pipeline 按价格与股本口径重算,并记录 WARNING |
| 列已配置 `transforms` | 视为用户已接管该列单位,原样透传 |
## 请求约定
@@ -210,7 +226,7 @@ cp docs/examples/custom-data-source/mock_source.yaml data/data_sources/mock_sour
5. 保存数据源选择:
- 日K: `mock_source`
- 除权因子: `same_as_daily``mock_source`
- 除权因子: `mock_source` (或保持默认 `tickflow`)
- 实时行情: `mock_source`
6. 触发同步或开启实时行情。
@@ -273,12 +289,16 @@ cp docs/examples/custom-data-source/mock_source.yaml data/data_sources/mock_sour
amount = 成交额
change_pct = 涨跌幅 (小数, 0.0366 = 3.66%)
change_amount = 涨跌额
amplitude = 振幅
turnover_rate = 换手率 (小数, 0.05 = 5%; 若上游返回 5 表示 5%, 配置 transforms: turnover_rate: "value / 100")
amplitude = 振幅 (小数, 0.024 = 2.4%)
turnover_rate = 换手率 (小数, 0.05 = 5%)
# 上游若返回百分数值 (3.66 表示 3.66%), 在 realtime 数据集声明 pct_unit: percent,
# 不要依赖数值自动识别; 逐列转换也可用 transforms: turnover_rate: "value / 100"
分钟K (minute):
symbol = 股票代码
datetime = 时间戳 (YYYY-MM-DD HH:MM:SS)
# datetime 必须是北京时间墙钟 (如 2026-08-28 09:35:00), 不要返回 UTC;
# 入口守卫会自动纠偏 UTC 特征帧, 但契约仍要求源头写对
datetime = 北京时间墙钟 (YYYY-MM-DD HH:MM:SS)
open / high / low / close = OHLC
volume = 成交量
amount = 成交额
+22 -4
View File
@@ -16,7 +16,7 @@
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
全 A 股一次扫表,Polars 毫秒级返回。选股页点策略卡片即可扫描,结果支持导出。
全 A 股一次扫表,Polars 毫秒级返回。选股页点策略卡片即可扫描,结果支持导出。**分钟策略**与日线策略共用同一个标的池,按策略声明的周期自动路由执行(分钟策略依赖数据页开启分钟数据落盘)。
**ETF 支持**:选股页顶部可切换 `股票 / ETF`。ETF 复用已算好的 `kline_etf_enriched` 技术指标,仅开放**技术类内置策略**(趋势/量价/反转/波动);依赖涨停信号的策略(连板股、断板反包)为股票专有,ETF 模式下不显示。需先在数据页开启 ETF 拉取(`pipeline_pull_etf`)并跑一次盘后管道。
@@ -26,7 +26,7 @@
## 📊 指标流水线(Indicators)
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet(基础行情列),指标在读取时现算并带进程内快照缓存:
- **均线 / 趋势**:MA(5-60) · EMA · MACD · 动量 · 布林带
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
@@ -54,6 +54,10 @@
输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。SSE 流式进度支持切页重连,不会丢失回测任务。
**分钟策略回测**:分钟级策略逐交易日回放信号、以分钟收盘价入场,输出分钟级成交明细,与日线回测同一套 T+1 / 费用约束。
**结果沉淀**:回测结果可**导出 CSV**(概要 / 净值曲线 / 交易明细 / 分标的统计四段),并可**保存候选**(完整参数与指标快照);候选在回测页「候选方案」中查看,支持**一键载入复测**,回填全部参数继续研究。
**因子与策略挖掘**:复用已有日频因子和 matrix-native 策略,通过 T-1 市场环境、相关去重和嵌套样本外验证生成研究候选。任务在 spawn worker 中运行,支持持久 run ID、取消、刷新重连和显式发布;自动周度任务默认关闭且永不自动发布。完整口径见 [因子与策略挖掘](./mining.md)。
**市场阶段与主线**:市场环境页在原 5 档状态之外新增情绪周期阶段(冰点/启动/主升/高潮/退潮/修复,由连板梯队的高度、宽度、晋级率、梯队完整度判定,平均段长约 10 天)与主线识别(概念/行业维度的涨停梯队聚合排名,可配置宽基标签过滤)。完整口径见 [市场阶段与主线识别](./market-phase.md)。
@@ -88,6 +92,20 @@
---
## 🚨 异动监控(Abnormal Moves)
独立的异动页,按交易时间线组织为三个 tab,覆盖盘前到多日:
| Tab | 口径 |
| :--- | :--- |
| 竞价异动(盘前) | fuyao 同花顺盘前风向标:当日竞价强势榜(5-6 只/日,含竞价涨幅与概念标签),附带**当日 / 次日真实收益对照**与**追高风险标记**(高开 ≥5% 追高当日均值显著为负,来自 60 日回测) |
| 盘中异动 | 涨停 / 炸板 / 翘板 / 跌停 / 新高 / 新低 / 放量等当日信号聚合,按信号优先级 + 涨跌幅排序,复用 enriched 信号列,零新增采集 |
| 偏移异动(多日累计) | 交易所异动偏离值口径(按板块分档:主板 3 日 ±20% · 创业板/科创板 ±30% · 北交所 ±40%;10 日 +100%/50%,30 日 +200%/70%),实时计算接近度(≥100% 已触发 · ≥70% 边缘 · ≥50% 观察) |
竞价数据来自 fuyao 数据源(历史按日缓存,当日不缓存);未配置 fuyao key 时竞价 tab 显示引导提示。
---
## 📈 个股分析(Beta)
以「行情 + 关键价位」为主体的单标的决策页:
@@ -102,7 +120,7 @@
- **连板梯队**:实时统计各连板层级(首板 / 2 连板 / 3 连板...)的标的与封单,捕捉市场情绪与题材热度
- **概念涨幅轮动**:基于 ths 概念 / 行业,统计概念板块涨幅与 RPS 轮动,AI 分析资金主线
- **盘后 AI 复盘**:盘后自动生成市场复盘,推送飞书群
- **盘后 AI 复盘**:盘后自动生成市场复盘,注入**龙虎榜资金动向**(席位净买/机构与游资 Top5)与**盘前风向标**对照,可定时执行、推送飞书群、下载 Markdown;复盘页内亦有龙虎榜卡片(可排序席位明细)
---
@@ -110,7 +128,7 @@
### 数据源插件化
内置 [TickFlow](https://tickflow.org) 官方 SDK(日 K / 分钟 K / 指数 / 财务 / 实时行情),同时支持接入第三方数据源:YAML 声明自有 HTTP 接口,或开发插件源(参考实现 stock-sdk,Docker 镜像已内置)。个数据集(daily / adj_factor / realtime / minute / financial)按源声明自由组合,在「设置 → 数据源」切换。
内置 [TickFlow](https://tickflow.org) 官方 SDK(日 K / 分钟 K / 指数 / 财务 / 实时行情),同时支持接入第三方数据源:YAML 声明自有 HTTP 接口,或开发插件源(参考实现 stock-sdk,Docker 镜像已内置)。个数据集(日K / 除权因子 / 实时行情 / 分钟K / 五档盘口 / 财务,注册表持续扩展)按源声明自由组合,在「设置 → 数据源」切换。
详见 [custom-data-source.md](./custom-data-source.md) 与 [plugin-development.md](./plugin-development.md)。
+156 -34
View File
@@ -1,9 +1,13 @@
# 数据源插件开发指南
数据源插件是可选的行情数据来源(stock-sdk、akshare 等),作为独立模块放在
`backend/app/plugins/` 下。用户**手动安装依赖**后才可用(开发模式);不安装完全不影响主功能。
数据源插件是可选的行情数据来源(fuyao、stock-sdk、akshare 等),作为独立模块放在
`backend/app/plugins/` 下。services 层(kline_sync / quote_service / financial_sync)
全部通过统一路由点分流:插件声明了某数据集就走插件,未声明自动回退 TickFlow。
因此**一个合格的插件只需要正确实现契约,不需要改动任何 service / API 代码**;
反过来,插件也必须遵守内部数据契约(单位、代码格式、复权口径),框架不会替你转换。
> ⚠️ **Docker 默认不打包 stock-sdk**(合规考虑:它抓取第三方财经网站接口,存在版权与反爬风险)。如需在 Docker 中启用,构建时传 `--build-arg INCLUDE_STOCKSDK=1`,使用风险自负。下方"手动安装依赖"适用于开发模式及自定义 Docker 构建。
> 无代码接入(纯 HTTP YAML 配置)请看 [custom-data-source.md](./custom-data-source.md),
> 两种方式遵循同一套内部数据契约。
## 快速上手
@@ -13,7 +17,7 @@
backend/app/plugins/<your_plugin>/
├── plugin.yaml # 清单(必需)
├── provider.py # Provider 实现(必需)
├── ... # 桥接/依赖文件(按需)
├── ... # client/桥接/依赖文件(按需)
```
### plugin.yaml 字段
@@ -21,16 +25,20 @@ backend/app/plugins/<your_plugin>/
```yaml
name: my_source # 唯一标识, 只允许 [a-z0-9_], 也是 provider name
display_name: "我的数据源" # 设置页显示名
runtime: python # 运行时类型: node | python | none
runtime: none # 运行时类型: node | python | none
entry: app.plugins.my_source.provider:MyProvider # provider 类的导入路径
check: app.plugins.my_source.bridge:availability # 可用性检测函数(可选)
datasets: [daily, adj_factor, minute, realtime] # 支持的数据集
datasets: [realtime] # 支持的数据集: daily/adj_factor/minute/realtime/financial
api_key_env: MY_SOURCE_API_KEY # (可选)声明后设置页提供 Key 输入框
hidden: false # (可选)true = 已加载但对设置页隐藏,不注册不展示
description: "数据源描述"
install_hint: "pip install xxx" # 未装依赖时显示的安装提示
homepage: "https://example.com" # (可选)官网/申请地址, 显示在设置页 Key 配置说明中
```
只声明真实提供的数据集;未声明的数据集 `provider_has_dataset` 返回 False,自动回退
TickFlow。不要声明做不了的数据集(粒度含义见下文"能力声明的粒度")。
#### api_key_env(界面配置 API Key)
声明 `api_key_env` 的插件可以在设置页的数据源卡片中直接填写 Key, 对齐
@@ -49,36 +57,74 @@ TickFlow 的「先探后存」语义:
| runtime | 含义 | 典型场景 |
|---|---|---|
| `python` | 纯 Python 依赖, `pip install` | akshare、tushare |
| `node` | 需要 Node.js 运行时, `npm install` | stock-sdk(Docker 默认不打包,见 [deployment.md](./deployment.md)) |
> stock-sdk 在 Docker 中默认不打包(合规考虑);如需启用,构建时传 `--build-arg INCLUDE_STOCKSDK=1`,开发模式下需手动 `npm install`
| `node` | 需要 Node.js 运行时, `npm install` | stock-sdk |
| `none` | 无额外依赖 | 纯 HTTP API 源 |
> ⚠️ stock-sdk 在 Docker 中默认不打包(合规考虑:它抓取第三方财经网站接口,存在版权与
> 反爬风险)。如需启用,构建时传 `--build-arg INCLUDE_STOCKSDK=1`,使用风险自负。
> 详见 [deployment.md](./deployment.md)。
`runtime` 字段当前仅用于 UI 展示, 实际依赖检测由 `check` 函数负责。
### check 函数
插件自己负责检测依赖是否已安装。后端启动时会调用此函数:
插件自己负责检测依赖/Key 是否就绪。后端启动时会调用此函数:
```python
# app/plugins/my_source/bridge.py
# app/plugins/my_source/provider.py (或 bridge.py)
def availability() -> tuple[bool, str]:
"""返回 (是否可用, 原因)。不抛异常。"""
try:
import akshare # noqa: F401
if not get_api_key():
return False, "未配置 MY_SOURCE_API_KEY(可在设置页数据源卡片中直接填写)"
return True, "ok"
except ImportError:
return False, "未安装 akshare, 运行: pip install akshare"
```
- **可用** → 插件注册进路由表, 设置页可切换
- **不可用** → 设置页显示插件卡片但灰显, 展示 `install_hint`
- **不可用** → 设置页显示插件卡片但灰显, 展示原因/`install_hint`
## 内部数据契约(所有数据集必须遵守)
以下口径是全项目红线(详见 CONTRIBUTING §3)。金融数据错误往往不抛异常,而是生成
**看似合理的错误结果**——单位、代码格式、复权口径错了,页面照样能渲染,只是数字全错。
插件必须在 provider 内完成适配。
### 代码格式
- symbol 统一带交易所后缀: `600519.SH` / `000001.SZ` / `300750.SZ`; ETF、指数同格式。
- 接口返回裸代码(如 `600519`)或异构格式时,在 client 层实测一页并归一,不要直接透传。
### 单位制
| 字段 | 契约 | 说明 |
| --- | --- | --- |
| `change_pct` | **小数制**, `0.0366` = 3.66% | 接口给百分数(3.66)时必须在 provider 内显式 /100 |
| `turnover_rate`(realtime 入口) | **小数制**, `0.05` = 5% | 下游 enriched 管道统一转百分数值存储 |
| `volume` | 股 | |
| `amount` / `turnover` | 元 | |
| 日K OHLC | **不复权原始价** | 复权由 adj_factor + enriched 管道处理, provider 不得自行复权 |
### 缺字段与空数据
- 接口不提供的字段返回 `None`,禁止"数值小于 1 就乘 100"之类启发式补全——那会掩盖
真实的数据错误。
- 可推导字段按固定口径推导: `change_pct = change_amount / prev_close`(小数制,不乘 100)。
- 接口结构整体变化(如所有行都识别不出 symbol)要打明确告警日志,不要静默返回空数据。
## 能力声明的粒度(重要)
`datasets` 声明是**数据集级**的,不是资产类型级的:声明了 `realtime`,整个全市场实时
轮询周期(含指数与 ETF 部分)就全部路由给插件。若你的快照只覆盖 A 股股票:
- 指数行情自动降级为日线推导值(非实时),不报错;
- ETF 实时计数为 0。
这是当前框架的设计行为。要么在数据里尽量覆盖指数/ETF,要么接受降级并在
`description` 里向用户说明覆盖范围。
## Provider 接口契约
Provider 是一个普通 Python 类(无需继承基类), 实现以下方法签名。方法签名对齐
`GenericHTTPProvider`, 这样 services 层(kline_sync / quote_service 等)的路由逻辑
零改动即可路由到插件。
Provider 是普通 Python 类(无需继承基类),方法签名对齐 `GenericHTTPProvider`,
services 层零改动即可路由。只实现已声明数据集对应的方法,其余可缺省。
```python
class MyProvider:
@@ -91,22 +137,72 @@ class MyProvider:
def close(self) -> None:
"""清理资源(load_all 重建注册表时会调)。"""
def get_daily(self, symbols, start_time, end_time, asset_type="stock", on_chunk_done=None) -> pl.DataFrame:
"""日K: 返回 schema [symbol, date, open, high, low, close, volume, amount]"""
def get_daily(self, symbols, start_time, end_time, asset_type="stock",
on_chunk_done=None) -> pl.DataFrame:
"""日K: [symbol, date, open, high, low, close, volume, amount]; 不复权"""
def get_adj_factors(self, symbols, start_time, end_time, asset_type="stock", on_chunk_done=None) -> pl.DataFrame:
"""除权因子: 返回 schema [symbol, trade_date, ex_factor]"""
def get_adj_factors(self, symbols, start_time, end_time, asset_type="stock",
on_chunk_done=None) -> pl.DataFrame:
"""除权因子: [symbol, trade_date, ex_factor]"""
def get_minute(self, symbols, start_time, end_time, asset_type="stock", on_chunk_done=None, freq="1m") -> pl.DataFrame:
"""分钟K: 返回 schema [symbol, datetime, open, high, low, close, volume, amount]"""
def get_minute(self, symbols, start_time, end_time, asset_type="stock",
on_chunk_done=None, freq="1m") -> pl.DataFrame:
"""分钟K: [symbol, datetime(北京墙钟), open, high, low, close, volume, amount]"""
def get_realtime(self) -> list[dict]:
"""全市场实时快照: 返回 list[dict], 每行含 symbol/last_price/prev_close/open/high/low/volume"""
"""全市场实时快照 list[dict]。失败软返回 [], 不抛异常(不阻断轮询线程)。"""
def get_financials(self, table, symbols, latest_only=False) -> pl.DataFrame:
"""财务数据(声明 financial 数据集时实现, table 见 financial_sync 调用)。"""
def get_instruments(self, asset_type="stock") -> list[dict]:
"""标的维表(可选): 返回 tickflow Instrument 形状的行, 供 instrument_sync 复用 flatten"""
"""(可选)标的维表: 返回 tickflow Instrument 形状的行, 供 instrument_sync 复用 flatten"""
def test_dataset(self, dataset: str, symbols=None) -> dict:
"""(强烈建议)设置页"试拉"按钮。
返回 {provider, dataset, rows, columns, preview, error?}; 未支持的数据集
返回 error 字段说明会回退 TickFlow。"""
```
### get_minute 的 datetime 时区契约
`datetime` 必须是**北京时间墙钟**(naive,如 `2026-08-28 09:35:00`),与日K的
`date` 语义对齐;不要返回 UTC 或带时区的时间。前端分时图按交易时段时轴
09:3011:30 / 13:0015:00)映射每根K线,UTC 口径的帧会导致全部点位落在时轴外、
分时图空白。
入口守卫(`kline_sync._enforce_minute_beijing_wallclock`)对所有分钟源强制归一:
带时区 → 自动换算成北京墙钟;naive 但整体呈 UTC 特征(如 01:30)→ 自动 +8 纠偏并
记日志;完全无法识别的口径 → 拒收并回退 TickFlow。契约仍要求源头写对,守卫只是兜底。
可选类属性 `minute_history_days = 5` 声明 1 分钟历史深度(交易日);未声明视为
深历史(TickFlow 基准)。浅源(如 stock-sdk 免费分时仅保留最近 5 个交易日)声明后,
个股分时档位自动收窄为可行选项并默认 5 日,深源默认 20 日。
### 异常语义
| 方法 | 失败行为 |
| --- | --- |
| `get_realtime` | **软失败**: 返回 `[]` + warning 日志, 保证轮询线程不中断 |
| `get_minute` | 抛异常时调用方自动回退 TickFlow 重试 |
| `get_daily` / `get_adj_factors` / `get_financials` | 异常由上层同步流程捕获记录; 无数据返回空 DataFrame |
### get_realtime 行字段
| 字段 | 必需 | 契约 |
| --- | --- | --- |
| `symbol` | ✅ | 标准代码带后缀 |
| `last_price` | ✅ | 最新价 |
| `prev_close` | ✅ | 昨收, 涨跌幅推导基准 |
| `open` / `high` / `low` | ✅ | 当日 OHLC |
| `volume` | ✅ | 股 |
| `amount` | 建议 | 成交额(元) |
| `change_pct` | 建议 | **小数制**; 缺失时下游按 change_amount/prev_close 推导 |
| `change_amount` | 建议 | 涨跌额(元) |
| `timestamp` | 建议 | 毫秒; 优先用服务端时间(行情归属), 缺失退本地时间 |
| `name` | 可选 | 快照无名称时置 None, 下游用标的维表关联 |
| `amplitude` / `turnover_rate` / `session` | 可选 | 缺失置 None, 不启发式伪造; turnover_rate 入口为小数制 |
### config.datasets 的作用
`provider_has_dataset(name, dataset)` 通过 `dataset in provider.config.datasets` 判断。
@@ -118,14 +214,40 @@ class MyConfig:
datasets = {"daily": ..., "realtime": ...} # key 是数据集名, value 任意
```
## 限频与性能
- realtime 默认 6s 轮询一轮。优先确认服务端单次 limit 上限: fuyao 实测单页
limit=6000 可一次拉完全市场(~5600 只), 1 请求/轮; 若服务端强制小页, 必须做
页间隔/自限速(参考 fuyao 的 0.15s 页间隔兜底), 并建议用户把轮询间隔调大(15-30s)。
- 分页必须有页数上限(防 count 异常导致死循环)和空页终止条件。
- 拉取由 fetch 锁串行化, 慢不会并发重叠; 实际刷新周期 = 轮询间隔 + 拉取耗时,
串行分页的全量快照本身就需要数秒, 不要按"6s 内必须完成"设计。
## 测试要求
插件 PR 必须带契约测试(CONTRIBUTING §9), **不依赖真实网络与 API Key**——用假
Client/桥接注入。以 `backend/tests/test_fuyao_provider.py` 为范本, 至少覆盖:
1. 字段映射与单位转换: 百分数→小数制、volume 股→手、*ms 零点戳时区换算、缺失字段按口径推导、缺失字段置 None 不伪造
2. 接口响应结构变体: 实测结构 vs 官方文档示例双兼容(供应商文档与实际不一致是常态)
3. 分页: 多页合并、空页终止、页数上限
4. 软失败: 接口报错返回 []; 整页 schema 变化有告警而非静默空数据
5. 能力声明: 未声明数据集 `provider_has_dataset` 为 False
6. Key 语义: 先探后存(无效不落盘)、secrets.json > .env 优先级、availability 两态
7. loader 集成: 清单解析后正确注册(或 hidden 时正确跳过)
```bash
cd backend && uv run --extra dev python -m pytest tests/test_<your_plugin>_provider.py -q
uv run --extra dev python -m ruff check app/plugins/<your_plugin>/ tests/test_<your_plugin>_provider.py
```
## 现有插件参考
- **`backend/app/plugins/fuyao/`** — 同花顺官方 REST 数据源(runtime: none, 纯 HTTP 零依赖)
- 当前提供 `realtime`(A 股全市场快照, 分页拉取); Key 在设置页卡片直接配置(先探后存), 或 `.env``FUYAO_API_KEY`
- `client.py` — httpx 客户端(X-api-key 认证 + 统一信封解包 + 分页)
- `provider.py` — Provider 实现(字段映射、百分数→小数制单位转换、软失败、Key 探测)
- 单位口径注意: 扶摇 `price_change_ratio_pct` 为百分数数值(1.74 = +1.74%),
内部 `change_pct` 契约为小数制, provider 内显式 / 100(见 CONTRIBUTING §3.1)
- 提供 `realtime`(A 股全市场快照, 分页拉取)`daily`(原始价日K三档: 近端窗口走 daily-k-10d dump, 深窗口走 daily-k 10 年全量 dump(172MB 一次下载、缓存复用、10d 补尾), 兜底单标的接口按 10 年自动分片)、`adj_factor`(事件 dump + 前收盘价从本地日K dump 一次取齐、缺价标的回退单标的接口, 按交易所公式推导单事件比值, 涨跌停自检; 全市场配价从逐标的 ~13 分钟降为秒级); Key 在设置页卡片直接配置(先探后存), 或 `.env``FUYAO_API_KEY`
- `client.py` — httpx 客户端(X-api-key 认证 + 统一信封解包 + 分页 + 页间隔限频 + 单标的日K + dump 预签名下载, S3 下载不带 Key 头)
- `provider.py` — Provider 实现(实测/文档双字段映射、百分数→小数制、volume 股→手、上海零点戳 +8h 时区、dump 按 release 版本缓存、软失败、Key 探测)
- `tests/test_fuyao_provider.py` — 73 个契约测试, 是新插件的测试范本
- **`backend/app/plugins/stocksdk/`** — Node 型插件, 通过 subprocess 桥接调用 stock-sdk
- `bridge.py` — Python↔Node 桥接 + availability 检测
- `bridge.mjs` — Node 端(并发池、重试、SDK 解析)
@@ -133,9 +255,9 @@ class MyConfig:
## 路由机制(无需关心, 仅参考)
后端启动时, `loader.py``_load_builtin_plugins()` 扫描 `plugins/` 目录:
后端启动时, `loader.py` 扫描 `plugins/` 目录:
1. 读每个子目录的 `plugin.yaml`
2. 调 `check` 函数检测可用性
2. `hidden: true` → 跳过(不注册不展示); 否则`check` 函数检测可用性
3. 可用 → 动态 import `entry` 指向的 Provider 类 → 注册进 `_PROVIDERS`
4. 不可用 → 记录状态, 设置页显示但不可切换
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.2.1",
"version": "0.2.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -66,6 +66,8 @@ export function CandlestickChart({ data, height = 480 }: Props) {
width: el.clientWidth,
height,
layout: {
// 关闭 TV 角标 (licence 归属改由 README 技术栈外链承担)
attributionLogo: false,
background: { color: THEME.background },
textColor: ctRef.current.text,
fontFamily: 'JetBrains Mono, monospace',
@@ -1,12 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Building2, ChevronRight, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
import { Link } from 'react-router-dom'
import {
createChart,
LineStyle,
type IChartApi,
type ISeriesApi,
type LineData,
type Time,
} from 'lightweight-charts'
import { Activity, Building2, ChevronRight, Database, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { boardTag } from '@/components/stock-table/primitives'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { api, type DimensionIntradayPoint, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { useChartTheme } from '@/lib/theme'
export type DimensionKind = 'concept' | 'industry'
@@ -218,6 +228,16 @@ function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit<P
<Summary label="平均涨跌" value={fmtPct(stats.average)} className={priceColorClass(stats.average)} />
</div>
{source && (
<DimensionIntradaySection
configId={source.configId}
field={source.field}
value={target.value}
date={target.date}
kind={target.kind}
/>
)}
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
@@ -300,3 +320,219 @@ function Summary({ label, value, className }: { label: string; value: string | n
</div>
)
}
// ---------------------------------------------------------------------------
// 板块分时 (等权): 点击触发 + 60s 轮询续期, 不预计算
// ---------------------------------------------------------------------------
const INTRADAY_SECTOR_COLOR: Record<DimensionKind, string> = {
concept: '#F97316',
industry: '#0EA5E9',
}
const INTRADAY_MARKET_COLOR = '#94A3B8'
// 横轴伪时间戳基点 (2020-01-01 UTC), 每点 +60s 保持均匀间距
const INTRADAY_BASE_TS = 1577836800
function lastNonNull(points: DimensionIntradayPoint[], key: 'sector' | 'market'): number | null {
for (let i = points.length - 1; i >= 0; i--) {
const value = points[i]?.[key]
if (value != null) return value
}
return null
}
function DimensionIntradaySection({ configId, field, value, date, kind }: {
configId: string
field: string
value: string
date?: string
kind: DimensionKind
}) {
const query = useQuery({
queryKey: QK.dimensionIntraday(configId, field, value, date),
queryFn: () => api.dimensionIntraday(configId, { field, value, date }),
staleTime: 15_000,
refetchInterval: 60_000,
})
const data = query.data
return (
<section className="shrink-0 border-b border-border bg-surface/30">
<div className="flex items-center gap-2 px-4 pt-2">
<Activity className="h-3 w-3 text-muted" />
<span className="text-[10px] font-medium text-muted"> · </span>
{data?.member_count != null && data.members_with_minute != null && (
<span className="rounded bg-elevated px-1 py-px font-mono text-[9px] text-muted" title="有当日分钟数据的成分股数">
{data.members_with_minute}/{data.member_count}
</span>
)}
{data?.basis && data.basis !== 'prev_close' && (
<span
className="rounded bg-amber-500/10 px-1 py-px text-[9px] text-amber-600 dark:text-amber-400"
title="前一交易日收盘缺失, 部分标的以当日首根分钟价为基准, 曲线起点约为 0"
>
基准:当日首价
</span>
)}
{data?.status === 'ok' && (
<div className="ml-auto flex items-center gap-2.5 font-mono text-[10px]">
<span className="inline-flex items-center gap-1">
<span className="h-[3px] w-3 rounded-full" style={{ background: INTRADAY_SECTOR_COLOR[kind] }} />
<span className="text-muted"></span>
<span className={priceColorClass(lastNonNull(data.points, 'sector'))}>
{fmtPct(lastNonNull(data.points, 'sector'))}
</span>
</span>
<span className="inline-flex items-center gap-1">
<span className="h-[3px] w-3 rounded-full" style={{ background: INTRADAY_MARKET_COLOR }} />
<span className="text-muted"></span>
<span className={priceColorClass(lastNonNull(data.points, 'market'))}>
{fmtPct(lastNonNull(data.points, 'market'))}
</span>
</span>
{data.date && <span className="text-muted">{data.date}</span>}
</div>
)}
</div>
{query.isLoading ? (
<div className="mx-4 mb-2 mt-1.5 h-[132px] animate-pulse rounded-md bg-elevated/50" />
) : query.isError ? (
<div className="mx-4 mb-2 mt-1 grid h-[72px] place-items-center rounded-md border border-dashed border-border px-4 text-center text-[11px] text-muted">
:{String((query.error as Error).message)}
</div>
) : data?.status === 'no_data' ? (
<div className="mx-4 mb-2 mt-1 flex h-[96px] flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border">
<Database className="h-4 w-4 text-muted" />
<p className="text-[11px] text-muted">, </p>
<p className="text-[10px] text-muted/70"> TickFlow Pro+ / Expert , </p>
<Link to="/data" className="text-[10px] text-accent hover:text-accent/80"> </Link>
</div>
) : !data || data.status === 'empty' || data.points.length < 2 ? (
<div className="grid h-[44px] place-items-center text-[11px] text-muted">
{data?.reason === 'no_member_bars' ? '成分股当日无分钟数据 (ETF 等标的无分钟落盘)' : '暂无成分股分时数据'}
</div>
) : (
<IntradayChart points={data.points} kind={kind} />
)}
</section>
)
}
function IntradayChart({ points, kind }: { points: DimensionIntradayPoint[]; kind: DimensionKind }) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const sectorRef = useRef<ISeriesApi<'Line'> | null>(null)
const marketRef = useRef<ISeriesApi<'Line'> | null>(null)
const ct = useChartTheme()
const ctRef = useRef(ct)
ctRef.current = ct
// v4 不支持字符串时间: 用均匀伪时间戳作横轴, 标签经 formatter 映射回 HH:MM
const labelsRef = useRef<string[]>([])
const labelAt = (time: number) => labelsRef.current[time - INTRADAY_BASE_TS] ?? ''
useEffect(() => {
const el = containerRef.current
if (!el) return
const chart = createChart(el, {
width: el.clientWidth,
height: 132,
layout: {
// 关闭 TV 角标 (licence 归属改由 README 技术栈外链承担)
attributionLogo: false,
background: { color: 'transparent' },
textColor: ctRef.current.text,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 10,
},
grid: {
vertLines: { color: ctRef.current.grid },
horzLines: { color: ctRef.current.grid },
},
rightPriceScale: { borderColor: ctRef.current.border, scaleMargins: { top: 0.12, bottom: 0.04 } },
timeScale: {
borderColor: ctRef.current.border,
rightOffset: 2,
barSpacing: 4,
tickMarkFormatter: (time: number) => labelAt(time),
},
localization: {
timeFormatter: (time: number) => labelAt(time),
},
crosshair: {
vertLine: { labelVisible: false },
horzLine: { labelVisible: true },
},
handleScroll: false,
handleScale: false,
})
const sector = chart.addLineSeries({
color: INTRADAY_SECTOR_COLOR[kind],
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: true,
priceFormat: { type: 'custom', formatter: (v: number) => `${(v * 100).toFixed(2)}%`, minMove: 0.0001 },
crosshairMarkerRadius: 3,
})
const market = chart.addLineSeries({
color: INTRADAY_MARKET_COLOR,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerRadius: 2,
})
sector.createPriceLine({
price: 0,
color: ctRef.current.border,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
axisLabelVisible: false,
})
chartRef.current = chart
sectorRef.current = sector
marketRef.current = market
const observer = new ResizeObserver(() => {
chart.applyOptions({ width: el.clientWidth })
})
observer.observe(el)
return () => {
observer.disconnect()
chart.remove()
chartRef.current = null
sectorRef.current = null
marketRef.current = null
}
}, [kind])
useEffect(() => {
chartRef.current?.applyOptions({
layout: { textColor: ct.text },
grid: { vertLines: { color: ct.grid }, horzLines: { color: ct.grid } },
rightPriceScale: { borderColor: ct.border },
timeScale: { borderColor: ct.border },
})
}, [ct])
useEffect(() => {
const sectorSeries = sectorRef.current
const marketSeries = marketRef.current
if (!sectorSeries || !marketSeries) return
labelsRef.current = points.map(p => p.time)
const toData = (key: 'sector' | 'market'): LineData[] =>
points
.map((p, i) => ({ time: (INTRADAY_BASE_TS + i * 60) as Time, value: p[key] }))
.filter((d): d is LineData => d.value != null)
sectorSeries.setData(toData('sector'))
marketSeries.setData(toData('market'))
chartRef.current?.timeScale().fitContent()
}, [points])
return (
<div className="px-2 pb-2 pt-1">
<div ref={containerRef} className="w-full" />
</div>
)
}
+12 -2
View File
@@ -72,6 +72,9 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
const volumes = new Array(FULL_DAY_TIMES.length).fill(null) as (any | null)[]
const volNeutral = 'rgba(161,161,170,0.5)'
// 量柱着色基准: 前一分钟 close; 第一根用昨收。
// 不用 row.open — stock-sdk 历史日无真实分钟 open(为 null), close-vs-open 会全偏。
let prevRef: number | null = prevClose ?? null
for (let i = 0; i < data.length; i++) {
const timeKey = formatMinuteTime(data[i].datetime)
const idx = timeIndexMap.get(timeKey)
@@ -83,9 +86,16 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
volumes[idx] = {
value: data[i].volume,
itemStyle: {
color: data[i].close > data[i].open ? THEME.volUp : data[i].close < data[i].open ? THEME.volDown : volNeutral,
color: prevRef == null
? volNeutral
: data[i].close > prevRef
? THEME.volUp
: data[i].close < prevRef
? THEME.volDown
: volNeutral,
},
}
prevRef = data[i].close
}
}
@@ -570,7 +580,7 @@ export function EChartsIntraday({
<>
{date && <span className="text-muted">{date}</span>}
<span className="text-muted"></span>
<span style={{ color: priceClr }}>{d.open.toFixed(2)}</span>
<span style={{ color: priceClr }}>{d.open != null ? d.open.toFixed(2) : '—'}</span>
<span className="text-muted"></span>
<span style={{ color: priceClr }}>{d.high.toFixed(2)}</span>
<span className="text-muted"></span>
@@ -74,6 +74,9 @@ function buildModel(sessions: MinuteKlineSession[]) {
const dayValues: (number | null)[] = []
const dayAverages: (number | null)[] = []
// 量柱着色基准: 前一分钟 close; 当日第一根用 session 昨收。
// 不用 row.open — stock-sdk 历史日无真实分钟 open(为 null), close-vs-open 会全偏。
let prevRef: number | null = session.prev_close
for (const time of FULL_DAY_TIMES) {
const point = rowsByTime.get(time)
const index = categories.length
@@ -91,13 +94,16 @@ function buildModel(sessions: MinuteKlineSession[]) {
volumeData.push({
value: row.volume,
itemStyle: {
color: row.close > row.open
color: prevRef == null
? COLORS.volumeFlat
: row.close > prevRef
? COLORS.volumeUp
: row.close < row.open
: row.close < prevRef
? COLORS.volumeDown
: COLORS.volumeFlat,
},
})
prevRef = row.close
priceValues.push(row.low, row.high, average)
pointByIndex.set(index, {
date: session.date,
@@ -408,7 +414,7 @@ export function EChartsMultiDayIntraday({
{info ? (
<>
<span className="text-muted">{info.date} {formatMinuteTime(info.row.datetime)}</span>
<span className="text-muted"></span><span style={{ color: infoColor }}>{info.row.open.toFixed(2)}</span>
<span className="text-muted"></span><span style={{ color: infoColor }}>{info.row.open != null ? info.row.open.toFixed(2) : '—'}</span>
<span className="text-muted"></span><span style={{ color: infoColor }}>{info.row.high.toFixed(2)}</span>
<span className="text-muted"></span><span style={{ color: infoColor }}>{info.row.low.toFixed(2)}</span>
<span className="text-muted"></span><span className="font-semibold" style={{ color: infoColor }}>{info.row.close.toFixed(2)}</span>
+128 -84
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, Suspense } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState, Suspense } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
@@ -10,7 +10,7 @@ import { AiReportBubble } from '@/components/financials/AiReportBubble'
import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost'
import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble'
import {
useCapabilities,
useCapabilityMatrix,
useSettings,
usePreferences,
useQuoteStatus,
@@ -53,7 +53,7 @@ import {
PanelLeftOpen,
} from 'lucide-react'
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { api, type CapabilityMatrix, type IndexQuote } from '@/lib/api'
import { cn } from '@/lib/cn'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import { computeGroupPcts, groupPctColor, groupPctTitle } from '@/lib/watchlistGroupStats'
@@ -171,80 +171,140 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
)
}
// ===== 档位卡片 =====
function TierBadge({ label, hasKey, providerName, isTickflow }: { label: string; hasKey?: boolean; providerName: string; isTickflow: boolean }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase()
const isNone = base === 'none'
// ===== 数据源能力健康卡 =====
// 能力路由架构下的侧栏状态: 不再展示「主数据源 + TickFlow 档位」(单源时代遗留 —
// 五个能力各自路由, 拿日K的源代表全局是随意的), 改为回答「各能力当前是否都有源在供」。
// 档位/订阅信息归设置页 TickFlow 介绍卡 (档位词仅出现在 TickFlow 专属界面的设计规则)。
// 单能力方格: 可用=绿 / 日K缺失=红 / 其他缺失=琥珀 (与悬浮卡中同色, 一眼对应)
function capSquareCls(c: { id: string; usable: boolean }) {
return c.usable ? 'bg-accent' : c.id === 'daily' ? 'bg-danger' : 'bg-warning/80'
}
const tierConfig: Record<string, {
desc: string
dotStyle: React.CSSProperties
tagBg: React.CSSProperties
labelTextStyle: React.CSSProperties
}> = {
none: {
desc: '未配置 Key · 仅历史日K',
dotStyle: { background: '#52525b' },
tagBg: { background: 'rgba(113,113,122,0.15)' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 自选实时',
dotStyle: { background: '#71717a' },
tagBg: { background: 'rgba(113,113,122,0.3)' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '批量同步 · 行情池',
dotStyle: { background: '#3b82f6' },
tagBg: { background: 'rgba(59,130,246,0.2)' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
desc: '分钟K · 实时行情 · 盘口',
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
expert: {
desc: 'WebSocket · 财务数据',
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefined }) {
const caps = matrix?.capabilities ?? []
const loading = caps.length === 0
const usableCount = caps.filter(c => c.usable).length
const down = caps.filter(c => !c.usable)
// 日K是核心能力 (其他一切派生于它): 挂了用危险色; 一般缺项琥珀; 全可用绿
const level = loading
? 'loading'
: down.length === 0 ? 'ok' : down.some(c => c.id === 'daily') ? 'danger' : 'warn'
const countCls = level === 'ok' ? 'text-accent/80'
: level === 'danger' ? 'text-danger'
: level === 'warn' ? 'text-warning'
: 'text-muted'
// 悬浮卡: 侧栏 aside 是 overflow-hidden, 用 fixed 定位逃逸裁剪 (坐标取自徽标实时位置)。
// 徽标靠近屏幕顶部时居中定位会把卡片上半截推出视口 → 渲染后按实际高度钳制进视口。
const linkRef = useRef<HTMLAnchorElement>(null)
const popRef = useRef<HTMLDivElement>(null)
const closeTimer = useRef<number | undefined>(undefined)
const [popPos, setPopPos] = useState<{ left: number; top: number } | null>(null)
const openPop = () => {
window.clearTimeout(closeTimer.current)
const rect = linkRef.current?.getBoundingClientRect()
if (rect) setPopPos({ left: rect.right, top: rect.top + rect.height / 2 })
}
const t = tierConfig[base] || tierConfig.none
const displayLabel = isNone ? 'None' : (label || 'None')
const descText = isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc
const closePop = () => {
closeTimer.current = window.setTimeout(() => setPopPos(null), 80)
}
useEffect(() => () => window.clearTimeout(closeTimer.current), [])
useLayoutEffect(() => {
if (!popPos || !popRef.current) return
const h = popRef.current.offsetHeight
const margin = 8
const minCenter = margin + h / 2
const maxCenter = window.innerHeight - margin - h / 2
const clamped = Math.min(maxCenter, Math.max(minCenter, popPos.top))
if (clamped !== popPos.top) setPopPos({ ...popPos, top: clamped })
}, [popPos])
return (
<>
<NavLink
ref={linkRef}
to="/settings?tab=data-sources"
aria-label={`数据源能力 ${usableCount}/${caps.length || 5} 可用, 点击前往数据源配置`}
onMouseEnter={openPop}
onMouseLeave={closePop}
onFocus={openPop}
onBlur={closePop}
onKeyDown={e => { if (e.key === 'Escape') setPopPos(null) }}
className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70"
title={`数据源 · ${providerName}${descText}`}
>
<span
className="pointer-events-none absolute inset-y-1.5 left-0 w-[2px] rounded-full bg-accent/50 transition-colors group-hover:bg-accent"
style={base === 'expert' ? { background: 'linear-gradient(180deg, #60a5fa, #c084fc, #fbbf24)' } : undefined}
/>
<span className="pointer-events-none absolute inset-y-1.5 left-0 w-[2px] rounded-full bg-accent/50 transition-colors group-hover:bg-accent" />
<DatabaseZap className="h-3.5 w-3.5 shrink-0 text-muted group-hover:text-accent transition-colors" />
<span className="min-w-0 truncate text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors">
{providerName || '数据源'}
{/* 能力方格 (按注册顺序: 实时/日K/分钟/除权/财务), 与悬浮卡逐格同色对应 */}
<span className="flex items-center gap-1 shrink-0">
{loading
? Array.from({ length: 5 }, (_, i) => (
<span key={i} className="h-2 w-2 rounded-[2px] bg-muted animate-pulse" />
))
: caps.map(c => (
<span key={c.id} className={`h-2 w-2 rounded-[2px] ${capSquareCls(c)}`} />
))}
</span>
<span
className="h-1.5 w-1.5 rounded-full shrink-0"
style={{ ...t.dotStyle, ...(base === 'expert' ? { animation: 'pulse 2s infinite' } : {}) }}
/>
{isTickflow && (
<span
className="ml-auto inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold font-mono leading-none shrink-0"
style={t.tagBg}
>
<span className="truncate" style={t.labelTextStyle}>{displayLabel}</span>
{!loading && (
<span className={`ml-auto text-[10px] font-mono font-bold leading-none shrink-0 ${countCls}`}>
{usableCount}/{caps.length}
</span>
)}
</NavLink>
{popPos && (
<div
ref={popRef}
className="fixed z-50 -translate-y-1/2 pl-3"
style={{ left: popPos.left, top: popPos.top }}
onMouseEnter={() => window.clearTimeout(closeTimer.current)}
onMouseLeave={closePop}
>
<motion.div
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="w-64 rounded-md border border-border bg-surface py-2.5 pl-3 pr-3.5 shadow-2xl shadow-black/40"
>
<div className="mb-2 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<DatabaseZap className="h-3.5 w-3.5 text-accent" />
</span>
<span className={`text-[10px] font-mono font-bold ${countCls}`}>
{loading ? '获取中…' : `${usableCount}/${caps.length} 可用`}
</span>
</div>
<div className="space-y-1.5 border-t border-border/60 pt-2">
{loading ? (
<div className="py-0.5 text-[11px] text-muted"></div>
) : caps.map(c => (
<div key={c.id} className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-[2px] ${capSquareCls(c)}`} />
<span className="shrink-0 text-xs font-medium text-secondary">{c.label}</span>
<span className="ml-auto flex min-w-0 shrink items-center gap-1.5">
{c.usable ? (
<>
<span className="truncate text-[11px] text-muted">{c.effective_display}</span>
<CheckCircle2 className="h-3 w-3 shrink-0 text-accent" />
</>
) : (
<span className="text-[11px] text-muted/70"></span>
)}
</span>
</div>
))}
</div>
{/* 分时有分钟K功能替身 (intraday_monitor_support 三路可达), 不单独占能力格, 在此备注 */}
<div className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
K
</div>
<div className="mt-2 flex items-center gap-1 border-t border-border/60 pt-1.5 text-[10px] text-muted">
<ChevronRight className="h-3 w-3" />
</div>
</motion.div>
</div>
)}
</>
)
}
@@ -275,8 +335,8 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
export function Layout() {
// ===== 共享 hooks (替代内联 useQuery) =====
const { data: caps } = useCapabilities()
const { data: settingsState } = useSettings()
const { data: matrix } = useCapabilityMatrix()
const { data: versionData } = useVersion()
const { data: prefs } = usePreferences()
// 数据源列表 (用于实时行情状态显示当前数据源名称)
@@ -446,13 +506,6 @@ export function Layout() {
? '关闭实时行情'
: '开启实时行情'
// 当前主数据源 (用于侧边栏数据源状态卡)
const activeProvider = prefs?.daily_data_provider || 'tickflow'
const activeProviderName = activeProvider === 'tickflow'
? 'TickFlow'
: (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider)
const isCustomActive = activeProvider !== 'tickflow'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒; 后台标签页由 SSE 事件驱动, 不轮询)
const alertsTotalQuery = useQuery({
queryKey: ['alerts-total'],
@@ -522,10 +575,6 @@ export function Layout() {
toast('当前数据源无实时行情能力, 请先配置数据源', 'error')
return
}
if (fresh.mode === 'watchlist' && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
navigate('/watchlist')
return
}
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
@@ -575,12 +624,7 @@ export function Layout() {
{/* 状态卡 — 收起时隐藏 */}
{!navCollapsed && (
<div className="mt-2.5 border-t border-border/60 pt-1">
<TierBadge
label={caps?.label ?? ''}
hasKey={settingsState?.mode !== 'none'}
providerName={activeProviderName}
isTickflow={!isCustomActive}
/>
<DataSourceHealthBadge matrix={matrix} />
<div className="mx-2 border-t border-border/45" aria-hidden="true" />
<AIConfigBadge
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
@@ -754,7 +798,7 @@ export function Layout() {
,
<button
type="button"
onClick={() => navigate('/settings?tab=data-sources')}
onClick={() => navigate('/settings?tab=data-sources&highlight=data-sources')}
className="mx-0.5 text-accent/80 hover:text-accent hover:underline"
>
@@ -777,7 +821,7 @@ export function Layout() {
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => navigate('/settings?tab=monitoring')}
onClick={() => navigate('/settings?tab=monitoring&highlight=quotes')}
aria-label="打开实时监控设置"
className="flex h-7 w-7 items-center justify-center rounded-btn text-muted transition-colors hover:bg-elevated hover:text-foreground"
title="实时监控设置"
@@ -54,6 +54,8 @@ interface Props {
onDataChange?: (result: StockDailyKChartResult) => void
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
extColumns?: string
/** 日K自动刷新间隔(ms)。undefined = 不轮询(默认)。个股对话框实时刷新时传入, 盘中今日蜡烛随之更新 */
refetchIntervalMs?: number
}
function isValidRow(r: any): boolean {
@@ -136,6 +138,7 @@ export function StockDailyKChart({
onPriceDoubleClick,
onDataChange,
extColumns,
refetchIntervalMs,
}: Props) {
const [activeIndicators, setActiveIndicators] = useState<string[]>(['vol'])
const [showMarkers, setShowMarkers] = useState(true)
@@ -150,6 +153,7 @@ export function StockDailyKChart({
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns),
enabled: !!symbol,
refetchInterval: refetchIntervalMs,
placeholderData: (prev) => prev,
})
@@ -36,7 +36,9 @@ export function StockIntradayChart({
const minute = useQuery({
queryKey: QK.klineMinute(symbol, date ?? ''),
queryFn: () => api.klineMinute(symbol, date ?? undefined),
// 轮询上下文 (个股详情) 传 live: 当日盘中后端直接实时拉取最新K,
// 避免读到分钟增量落盘的上一轮本地分区; 历史日期后端自行忽略 live。
queryFn: () => api.klineMinute(symbol, date ?? undefined, refetchIntervalMs != null),
enabled: !!symbol && !!date,
refetchInterval: refetchIntervalMs,
})
@@ -37,7 +37,8 @@ export function StockMultiDayIntradayChart({
})
const latest = useQuery({
queryKey: QK.klineMinute(symbol, ''),
queryFn: () => api.klineMinute(symbol),
// live: 当日盘中直接实时拉取, 不被分钟增量落盘的本地分区(≥60s一轮)拖慢
queryFn: () => api.klineMinute(symbol, undefined, true),
enabled: !!symbol,
refetchInterval: refetchIntervalMs,
})
+1
View File
@@ -167,6 +167,7 @@ export function StockPanel({
onDataChange={setDailyResult}
visibleBars={showIntraday ? 40 : 60}
extColumns={extColumns}
refetchIntervalMs={refetchIntervalMs}
/>
{showIntraday && selectedDate && !intradayDismissed && (
+28 -16
View File
@@ -14,7 +14,7 @@ import { DatePicker } from '@/components/DatePicker'
import { RuleEditor } from '@/components/monitor/RuleEditor'
import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog'
import { buildMonitorPriceLines } from '@/lib/price-alerts'
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
import { usePreferences } from '@/lib/useSharedQueries'
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
import { storage } from '@/lib/storage'
@@ -50,11 +50,11 @@ interface PriceAlertDraft {
}
const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const
function loadIntradayDays(): number {
function loadIntradayDays(): number | null {
const saved = storage.stockPreviewIntradayDays.get(10)
return INTRADAY_DAY_OPTIONS.includes(saved as typeof INTRADAY_DAY_OPTIONS[number])
? saved
: 10
: null
}
function boardTag(symbol: string): { label: string; color: string } | null {
@@ -81,7 +81,7 @@ function fmtAbnormalCalcTime(asofSec: number): string {
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) {
const [view, setView] = useState<PreviewView>('daily')
const [intradayDays, setIntradayDays] = useState(loadIntradayDays)
const [intradayDays, setIntradayDays] = useState<number | null>(loadIntradayDays)
const [dateRange, setDateRange] = useState(getDefaultRange)
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
const [priceAlertDraft, setPriceAlertDraft] = useState<PriceAlertDraft | null>(null)
@@ -161,15 +161,26 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
return () => clearFocusSymbol()
}, [symbol])
// 分时图实时轮询: 复用自选列表的「分时刷新开关 + 间隔」偏好。
// 实时行情运行 且 用户开启分时刷新时才轮询; 否则 undefined (定格)
// 分时图实时轮询: 详情打开即独立轮询, 不再依赖自选列表的「分时刷新开关
// 实时行情运行状态 (打开详情就是要看实时分时); 间隔沿用偏好, 默认 6s
// 最新一根K由后端 live 参数直接实时拉取, 与行情列表节奏一致。
const { data: prefs } = usePreferences()
const { data: quoteStatus } = useQuoteStatus()
const realtimeRunning = quoteStatus?.running ?? false
const intradayRefreshOn = prefs?.minute_intraday_refresh ?? false
const intradayRefetchMs = (intradayRefreshOn && realtimeRunning)
? (prefs?.minute_intraday_refresh_interval ?? 6) * 1000
: undefined
const intradayRefetchMs = (prefs?.minute_intraday_refresh_interval ?? 6) * 1000
// 分时档位按分钟源历史深度收窄: 浅源(如 stock-sdk=5日)只显示可行档位、默认 5日;
// 深源(tickflow/未声明)全档位、默认 20日。用户已保存的可行选择优先保留。
const minuteHistoryDays = prefs?.minute_history_days ?? null
const dayOptions = useMemo<number[]>(
() => INTRADAY_DAY_OPTIONS.filter(d => minuteHistoryDays == null || d <= minuteHistoryDays),
[minuteHistoryDays],
)
const defaultIntradayDays = minuteHistoryDays != null && minuteHistoryDays < 20 ? 5 : 20
const effectiveIntradayDays = intradayDays ?? defaultIntradayDays
useEffect(() => {
if (!dayOptions.includes(effectiveIntradayDays)) {
setIntradayDays(defaultIntradayDays)
}
}, [dayOptions, effectiveIntradayDays, defaultIntradayDays])
const handleRefresh = () => {
if (!symbol) return
@@ -274,14 +285,14 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
) : (
<div className="flex items-center gap-1">
<div className="inline-flex shrink-0 items-center rounded border border-border bg-elevated p-0.5" aria-label="分时周期">
{INTRADAY_DAY_OPTIONS.map(days => (
{dayOptions.map(days => (
<button
key={days}
type="button"
aria-pressed={intradayDays === days}
aria-pressed={effectiveIntradayDays === days}
onClick={() => selectIntradayDays(days)}
className={`h-5 rounded px-1.5 font-mono text-[10px] transition-colors ${
intradayDays === days
effectiveIntradayDays === days
? 'bg-accent/20 text-accent'
: 'text-muted hover:text-secondary'
}`}
@@ -479,6 +490,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
dateRange={dateRange}
priceLines={monitorPriceLines}
onPriceDoubleClick={openPriceAlert}
refetchIntervalMs={intradayRefetchMs}
/>
) : (
<>
@@ -489,7 +501,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
/>
<StockMultiDayIntradayChart
symbol={symbol}
days={intradayDays}
days={effectiveIntradayDays}
height={480}
refetchIntervalMs={intradayRefetchMs}
priceLines={monitorPriceLines}
@@ -5,8 +5,9 @@ import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { MissingCapChip } from '@/lib/capability-labels'
export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: {
caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined
// hasCap: 日K批量能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源)
export function ExtendHistoryPanel({ hasCap, isRunning, earliestDate, onStart }: {
hasCap: boolean
isRunning: boolean
earliestDate: string | null
onStart: () => void
@@ -14,7 +15,7 @@ export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: {
const qc = useQueryClient()
const [value, setValue] = useState(6)
const [unit, setUnit] = useState<'month' | 'year'>('month')
const hasBatchCap = !!caps?.capabilities?.['kline.daily.batch']
const hasBatchCap = hasCap
const extend = useMutation({
mutationFn: () => api.extendHistory(value, unit),
@@ -5,7 +5,8 @@ import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { MissingCapChip } from '@/lib/capability-labels'
export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; onJobStart?: (jobId: string) => void }) {
// hasCap: 分钟K能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源)
export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJobStart?: (jobId: string) => void }) {
const qc = useQueryClient()
const prefs = useQuery({
queryKey: QK.preferences,
@@ -17,7 +18,7 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch']
const hasMinuteCap = hasCap
const enabled = prefs.data?.minute_sync_enabled ?? false
const days = prefs.data?.minute_sync_days ?? 5
const segmentDays = prefs.data?.minute_sync_segment_days ?? 20
@@ -27,6 +28,9 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
useEffect(() => { setLocalDays(days) }, [days])
useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays])
useEffect(() => { setLocalDays(days) }, [days])
useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays])
const handleToggle = () => {
if (!hasMinuteCap) return
update.mutate({ enabled: !enabled, days: localDays })
@@ -18,14 +18,15 @@ function daysAgo(n: number): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
export function RepairDailyPanel({ caps, isRunning, latestDate, onStart }: {
caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined
// hasCap: 日K批量能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源)
export function RepairDailyPanel({ hasCap, isRunning, latestDate, onStart }: {
hasCap: boolean
isRunning: boolean
latestDate: string | null
onStart: () => void
}) {
const qc = useQueryClient()
const hasBatchCap = !!caps?.capabilities?.['kline.daily.batch']
const hasBatchCap = hasCap
// 默认起始日期: 最新数据往前推 30 天 (兼顾补缺口 + 复核近期数据, 成本不高)
const [startDate, setStartDate] = useState(daysAgo(30))
+9 -3
View File
@@ -32,6 +32,10 @@ export function Pill({ label, value }: { label: string; value: number | string }
)
}
// 卡片能力徽章的输入: TickFlow 套餐限额对象, 或路由门控合并后的布尔可用性
// (矩阵 usable 合并进 caps 时, 非对象真值表示「经其他数据源可用, 无套餐限额可显」)。
export type CapLimitValue = { rpm: number | null; batch: number | null; subscribe: number | null } | boolean
function CapBadge({ hasCap, isLocal, missingCapName, capInfo, localSuffix, customProvider }: {
hasCap: boolean
isLocal: boolean
@@ -101,7 +105,7 @@ export function StatCard({
skipped?: boolean
stagePct?: number
tierKey?: string
capLimits?: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }>
capLimits?: Record<string, CapLimitValue>
customProvider?: string | null
onSettings?: () => void
onShowFields?: (table?: string) => void
@@ -123,8 +127,10 @@ export function StatCard({
const meta = tierKey ? CARD_META[tierKey] : undefined
const isLocal = meta?.capKey === ''
const capInfo = meta?.capKey ? capLimits?.[meta.capKey] : undefined
const hasCap = isLocal || !!capInfo
// 布尔值 (路由门控合并) 只表达可用性; 限额信息仅当值为套餐对象时展示
const rawCap = meta?.capKey ? capLimits?.[meta.capKey] : undefined
const capInfo = rawCap && typeof rawCap === 'object' ? rawCap : undefined
const hasCap = isLocal || !!rawCap
// 渲染字段说明入口图标
// - fieldTabs 提供时: 返回 null (图标由 renderSubLabelInline 内联到文字后)
+147 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Activity, Building2, ChartNoAxesCombined, Check, ChevronDown, ChevronUp, Eraser, Layers3, ListPlus, Plus, RadioTower, Save, Search, Siren, Tags, TrendingUp, Waypoints, X } from 'lucide-react'
import { Activity, BarChart3, Building2, ChartNoAxesCombined, Check, ChevronDown, ChevronUp, Eraser, Layers3, ListPlus, Plus, RadioTower, Save, Search, Siren, Tags, TrendingUp, Waypoints, X } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition, type SectorKind, type SectorMonitorTarget, type StrategyNotifyEvent } from '@/lib/api'
import { DEFAULT_STRATEGY_NOTIFY_EVENTS, LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS } from '@/lib/strategyMonitorEvents'
import { QK } from '@/lib/queryKeys'
@@ -9,7 +9,7 @@ import { boardTag } from '@/components/stock-table/primitives'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { MONITOR_INTRADAY_SIGNAL_OPTIONS, SIGNAL_OPTIONS, cnSignal } from '@/lib/signals'
import { usePreferences } from '@/lib/useSharedQueries'
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
interface Props {
/** 编辑现有规则;null=新建 */
@@ -23,7 +23,7 @@ interface Props {
}
const TYPE_DEFAULT_NAME: Record<string, string> = {
signal: '信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控', sector: '板块监控', abnormal: '异动监控',
signal: '信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控', sector: '板块监控', abnormal: '异动监控', volume_delta: '轮询放量监控',
}
const TYPE_ICONS = {
@@ -33,6 +33,7 @@ const TYPE_ICONS = {
strategy: Waypoints,
sector: Layers3,
abnormal: Siren,
volume_delta: BarChart3,
}
const SECTOR_KIND_OPTIONS: Array<{ key: SectorKind; label: string; icon: typeof ChartNoAxesCombined }> = [
@@ -73,6 +74,7 @@ const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
cooldown_seconds: 3600,
severity: 'info',
message: '',
threshold_volume: 9000,
...preset,
})
@@ -80,6 +82,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const { data: prefs } = usePreferences()
const { data: quoteStatus } = useQuoteStatus()
const quoteInterval = quoteStatus?.interval_s
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const wecomConfigured = !!(prefs?.wecom_webhook_url)
const [editing] = useState(!!rule)
@@ -211,6 +215,18 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
if ((d.threshold_pct ?? 0) < 1 || (d.threshold_pct ?? 0) > 150) {
throw new Error('接近度阈值必须在 1 到 150 之间 (70=边缘, 100=已触发)')
}
} else if (d.type === 'volume_delta') {
delete d.score_min
delete d.score_max
d.conditions = []
delete d.notify_events
if (d.metric === 'amount') {
if (!Number.isFinite(d.threshold_amount) || (d.threshold_amount ?? 0) < 1) {
throw new Error('金额阈值必须是 ≥1 的数字 (万元)')
}
} else if (!Number.isFinite(d.threshold_volume) || (d.threshold_volume ?? 0) < 1) {
throw new Error('单轮放量阈值必须是 ≥1 的手数')
}
} else {
delete d.score_min
delete d.score_max
@@ -591,12 +607,24 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
return {
...d,
type,
// 轮询放量依赖全市场股票快照, 仅支持个股
asset_type: type === 'volume_delta' ? 'stock' : d.asset_type,
notify_events: type === 'strategy'
? [...(d.notify_events ?? DEFAULT_STRATEGY_NOTIFY_EVENTS)]
: undefined,
scope: type === 'sector' || type === 'abnormal'
scope: type === 'sector' || type === 'abnormal' || type === 'volume_delta'
? 'all'
: type === 'strategy' && d.scope === 'symbols' && d.symbols.length === 0 ? 'all' : d.scope,
// 轮询放量: 冷却期默认 300s (持续放量会连续多轮达标); 切走时还原 3600
cooldown_seconds: type === 'volume_delta' && d.type !== 'volume_delta' ? 300
: type !== 'volume_delta' && d.type === 'volume_delta' ? 3600
: d.cooldown_seconds,
// 轮询放量: metric / 金额阈值 / 基础过滤默认 (与策略 basic_filter 对齐)
metric: type === 'volume_delta' && d.type !== 'volume_delta' ? 'volume' : d.metric,
threshold_amount: type === 'volume_delta' && d.type !== 'volume_delta' ? 1e6 : d.threshold_amount,
basic_filter: type === 'volume_delta' && d.type !== 'volume_delta'
? { price_min: 3, price_max: 300, market_cap_min: 10e8, float_cap_min: null, float_cap_max: null, amount_min: 0.2e8, exclude_st: true }
: d.basic_filter,
direction: type === 'sector' ? 'up'
: type === 'abnormal' ? 'both'
: d.type === 'sector' || d.type === 'abnormal' ? 'entry' : d.direction,
@@ -895,6 +923,119 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
</div>
)}
{draft.type === 'volume_delta' && (
<div className="space-y-4 border-t border-border/60 pt-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<div className="grid h-9 grid-cols-2 overflow-hidden rounded-btn border border-border bg-base">
{([['volume', '按手数'], ['amount', '按金额']] as const).map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={(draft.metric ?? 'volume') === key}
onClick={() => setDraft(d => ({ ...d, metric: key }))}
className={`text-[11px] font-medium transition-colors cursor-pointer ${
(draft.metric ?? 'volume') === key ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
</div>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">
{draft.metric === 'amount' ? ' (万元)' : ' (手)'}
</span>
<span className="relative block">
<input
type="number"
min="1"
step={draft.metric === 'amount' ? 10 : 1000}
value={draft.metric === 'amount'
? Math.round((draft.threshold_amount ?? 1e6) / 1e4)
: (draft.threshold_volume ?? 9000)}
onChange={event => setDraft(d => {
const v = Number(event.target.value)
return d.metric === 'amount'
? { ...d, threshold_amount: v * 1e4 }
: { ...d, threshold_volume: v }
})}
className="h-9 w-full rounded-btn border border-border bg-base pl-3 pr-12 text-xs font-mono text-foreground"
/>
<span className="absolute right-3 top-2.5 text-xs text-muted">
{draft.metric === 'amount' ? '万元' : '手'}
</span>
</span>
<span className="block text-[10px] text-muted/70">
( {Math.round(quoteInterval ?? 6)} ) ;
</span>
</label>
</div>
<div className="space-y-2">
<span className="text-[11px] text-muted"> (, )</span>
<div className="grid gap-2 sm:grid-cols-3">
<label className="space-y-1">
<span className="text-[10px] text-muted/70"> ()</span>
<div className="flex items-center gap-1">
<input type="number" min="0" step="0.5" placeholder="下限"
value={draft.basic_filter?.price_min ?? ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, price_min: e.target.value === '' ? null : Number(e.target.value) } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
<span className="text-[10px] text-muted"></span>
<input type="number" min="0" step="0.5" placeholder="上限"
value={draft.basic_filter?.price_max ?? ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, price_max: e.target.value === '' ? null : Number(e.target.value) } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</div>
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted/70"> (亿)</span>
<input type="number" min="0" step="1" placeholder="不限"
value={draft.basic_filter?.market_cap_min != null ? draft.basic_filter.market_cap_min / 1e8 : ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, market_cap_min: e.target.value === '' ? null : Number(e.target.value) * 1e8 } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted/70"> ()</span>
<input type="number" min="0" step="100" placeholder="不限"
value={draft.basic_filter?.amount_min != null ? draft.basic_filter.amount_min / 1e4 : ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, amount_min: e.target.value === '' ? null : Number(e.target.value) * 1e4 } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted/70"> (亿)</span>
<input type="number" min="0" step="1" placeholder="不限"
value={draft.basic_filter?.float_cap_min != null ? draft.basic_filter.float_cap_min / 1e8 : ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, float_cap_min: e.target.value === '' ? null : Number(e.target.value) * 1e8 } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted/70"> (亿)</span>
<input type="number" min="0" step="1" placeholder="不限"
value={draft.basic_filter?.float_cap_max != null ? draft.basic_filter.float_cap_max / 1e8 : ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, float_cap_max: e.target.value === '' ? null : Number(e.target.value) * 1e8 } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</label>
<label className="flex items-center gap-2 pt-4">
<input type="checkbox" checked={draft.basic_filter?.exclude_st ?? true}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, exclude_st: e.target.checked } }))}
className="h-3.5 w-3.5 accent-[hsl(var(--accent))]" />
<span className="text-[11px] text-secondary"> ST / </span>
</label>
</div>
</div>
<div className="rounded-btn bg-base px-3 py-2 text-[10px] leading-relaxed text-muted">
(),
; , 5
</div>
</div>
)}
{/* 作用范围 */}
{draft.type !== 'sector' && <div className="space-y-2">
<span className="text-[11px] text-muted"></span>
@@ -1106,7 +1247,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
</div>}
{/* 触发条件 (非 strategy) */}
{draft.type !== 'strategy' && draft.type !== 'sector' && draft.type !== 'abnormal' && (
{draft.type !== 'strategy' && draft.type !== 'sector' && draft.type !== 'abnormal' && draft.type !== 'volume_delta' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"></span>
@@ -1402,7 +1543,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
return (
<p className="text-[10px] leading-relaxed text-warning/80">
{unconfigured.join('、')},
<Link to="/settings?tab=monitoring" className="text-accent hover:text-accent/80"> </Link>
<Link to="/settings?tab=monitoring&highlight=webhooks" className="text-accent hover:text-accent/80"> </Link>
</p>
)
})()}

Some files were not shown because too many files have changed in this diff Show More