docs(data): 归档数据层验证产物 + 数据层总览README
- scripts/data_platform/_archive/legacy/: 归档20个独立探针/诊断/旧降级脚本(零引用验证) - docs/archive/data/: 归档17个数据相关旧设计/plan/report(保留fusion spec作深读) - docs/data-platform/README.md: 数据层单一权威记录(8节:架构/布局/源/管线/铁律/API/缺口/待办) - 删除 _mootdx_depth_result.txt - Phase2待办: 15m灌库链+旧回填import链(有测试/wrapper依赖,VPS schtask确认后归档)
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
# 需求规格文档:本地数据源体系建设
|
||||
|
||||
**任务ID**: data-platform-20260502
|
||||
**节点**: pangtong_requirements
|
||||
**作者**: 庞统(副军师)
|
||||
**日期**: 2026-05-02
|
||||
|
||||
---
|
||||
|
||||
## 一、项目背景与核心问题
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
| 资产 | 状态 | 位置 |
|
||||
|------|------|------|
|
||||
| NAS日线Parquet | ✅ 2010-2026年全市场,按年分目录 | `/Volumes/stock/A股数据/日线数据/daily/{year}/sh{code}_daily.parquet` |
|
||||
| NAS分钟线Parquet | ⚠️ 仅84只15分钟线 | `/Volumes/stock/minute_kline/15min/sz{code}_15min.parquet` |
|
||||
| vnpy quant_trading.db | ❌ **空库(8KB,0张表)** | `/Volumes/stock/sanguo_vnpy/data/quant_trading.db` |
|
||||
| 回测服务 | ✅ 运行中(http://192.168.2.154:8088) | Docker容器 |
|
||||
| 本地数据适配器 | ⚠️ 已有但路径硬编码Mac本地 | `vnpy_local_data_adapter.py`(指向`/Users/chufeng/nas/stock/...`) |
|
||||
|
||||
### 1.2 核心问题
|
||||
|
||||
**vnpy回测服务的数据库是空的**,回测引擎 `engine.load_data()` 从数据库读取数据 → 无数据 → 所有回测任务必然失败。
|
||||
|
||||
回测服务executor.py关键代码(L171-175):
|
||||
```python
|
||||
engine.load_data() # 从vnpy SQLite数据库加载
|
||||
```
|
||||
如果没有数据,直接抛出 `ValueError("无法加载历史数据")`。
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
打通 **NAS Parquet → vnpy SQLite DB → 回测引擎** 的数据通路,让回测服务可以正常执行回测任务。
|
||||
|
||||
---
|
||||
|
||||
## 二、功能需求
|
||||
|
||||
### P1:打通vnpy数据通路
|
||||
|
||||
#### P1-1:确认Docker volume映射路径
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 确认Mac写入的文件,Docker容器内能读到 |
|
||||
| 输入 | NAS目录结构、Docker容器配置 |
|
||||
| 输出 | 明确的映射关系文档:Mac路径 ↔ 容器内路径 |
|
||||
| 验证 | 在Mac写入测试文件,容器内能读到;反之亦然 |
|
||||
|
||||
**关键证据**:
|
||||
- 回测服务配置 `base_dir = "/app/backtest_jobs"`
|
||||
- 数据目录 `data_dir = settings.base_dir.replace("backtest_jobs", "data")` → `/app/data`
|
||||
- quant_trading.db 位于 `/Volumes/stock/sanguo_vnpy/data/`
|
||||
- 需确认Docker容器启动时是否挂载了 `/Volumes/stock/sanguo_vnpy/data` → `/app/data`
|
||||
|
||||
#### P1-2:编写vnpy DB导入脚本
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 将NAS日线Parquet数据批量导入vnpy SQLite数据库 |
|
||||
| 输入 | `/Volumes/stock/A股数据/日线数据/daily/{year}/sh{code}_daily.parquet` |
|
||||
| 输出 | quant_trading.db 中有完整的日线bar数据 |
|
||||
| 验证 | 回测引擎 `load_data()` 能读出数据 |
|
||||
| 约束 | 幂等操作(INSERT OR REPLACE),可重复执行 |
|
||||
|
||||
**vnpy DB Schema要求**(待姜维确认):
|
||||
- vnpy 4.x的BacktestingEngine通过 `MainEngine` + `BaseDataManager` 加载数据
|
||||
- 数据表名和字段名由vnpy内部定义
|
||||
- 必须先搞清楚vnpy 4.x期望的数据库结构,再写导入脚本
|
||||
|
||||
**Parquet字段**:
|
||||
```
|
||||
date, open, high, low, close, volume, amount, outstanding_share, turnover, year
|
||||
```
|
||||
|
||||
**导入脚本功能要求**:
|
||||
1. 扫描 `/Volumes/stock/A股数据/日线数据/daily/` 下所有年份目录
|
||||
2. 每个Parquet文件解析股票代码(从文件名提取,如 `sh600000` → `600000.SSE`)
|
||||
3. 转换为vnpy DB格式并批量写入
|
||||
4. 支持增量导入(只导入新增数据)
|
||||
5. 支持断点续传(中断后可继续)
|
||||
6. 记录导入日志(成功/失败数、耗时)
|
||||
|
||||
#### P1-3:全量导入日线
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 运行导入脚本,将全市场2010-2026年日线数据全部导入 |
|
||||
| 输入 | P1-2的导入脚本 + NAS日线Parquet |
|
||||
| 输出 | quant_trading.db 填满日线数据 |
|
||||
| 验证 | 统计导入记录数,抽查几只股票确认数据完整 |
|
||||
| 风险 | 导入耗时长(预估2-4小时),需支持断点续传 |
|
||||
|
||||
#### P1-4:验证回测服务可用
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 提交一个简单回测任务,确认回测引擎能加载数据并完成回测 |
|
||||
| 输入 | 回测服务API + 简单策略代码 |
|
||||
| 输出 | 回测成功返回统计结果 |
|
||||
| 验证 | total_trades > 0 或 total_days > 0 |
|
||||
|
||||
---
|
||||
|
||||
### P2:数据基础设施
|
||||
|
||||
#### P2-1:多源降级管理器 `fallback.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 统一数据获取入口,支持多数据源顺序降级 |
|
||||
| 降级链(日线) | akshare `stock_zh_a_hist` → 腾讯K线API |
|
||||
| 降级链(实时) | 新浪实时 → 东方财富 → 腾讯 |
|
||||
| 接口 | `get_daily(symbol, start, end)` / `get_realtime(symbol)` |
|
||||
| 行为 | 第一个源失败自动切下一个,记录使用的源 |
|
||||
| 产出 | ~150行 |
|
||||
|
||||
#### P2-2:数据校验层 `validator.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 入库前校验数据质量,fatal级拒绝入库 |
|
||||
| V1规则(7条fatal) | D1: close/open/high/low > 0;D2: OHLC一致性(high≥max(open,close), low≤min(open,close));D3: volume ≥ 0;D6: 同股同日不重复;D7: date ≤ 今天;R1: 实时价格 > 0;R7: 必须携带source+fetched_at |
|
||||
| 接口 | `validate(df) → (passed: bool, errors: List[str])` |
|
||||
| 产出 | ~150行 |
|
||||
|
||||
#### P2-3:实时行情三源降级 `realtime.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 获取实时行情,支持3个源降级 |
|
||||
| 降级链 | 新浪实时 → 东方财富 → 腾讯 |
|
||||
| 接口 | `get_realtime_quote(symbol) → dict` |
|
||||
| 产出 | ~200行 |
|
||||
|
||||
#### P2-4:增量更新 `updater.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 每日增量更新,Parquet+vnpy DB双写 |
|
||||
| 流程 | 1.获取最新日期 2.拉取增量数据 3.校验 4.写Parquet(原子:临时文件+rename) 5.写vnpy DB(INSERT OR REPLACE幂等) 6.一致性校验 |
|
||||
| 约束 | Parquet是真相源;vnpy DB失败不影响Parquet |
|
||||
| 接口 | `update_daily() → UpdateResult` |
|
||||
| 产出 | ~150行 |
|
||||
|
||||
#### P2-5:cron定时任务
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 每交易日15:30自动执行增量更新 |
|
||||
| 配置 | Mac crontab(Mac已确认永不休眠) |
|
||||
| 验证 | 下一个交易日检查是否自动执行 |
|
||||
|
||||
---
|
||||
|
||||
### P3:分钟线数据
|
||||
|
||||
#### P3-1:P0限频验证
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 验证腾讯API限频阈值 |
|
||||
| 测试1 | 100只股票15分钟线连续下载,是否成功 |
|
||||
| 测试2 | 连续1小时请求,记录每分钟成功次数、封禁恢复时间 |
|
||||
| 输出 | 限频验证报告(每分钟最大请求数、封禁时长、恢复策略) |
|
||||
| 决策 | 报告决定P3-2/P3-3的实现策略(分批间隔、每批数量) |
|
||||
|
||||
#### P3-2/P3-3:分钟线全量下载
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 下载HS300/全市场15分钟线 |
|
||||
| 前置 | P3-1限频验证通过 |
|
||||
| 数据源 | 腾讯mkline API(唯一可用源,akshare分钟线已失效) |
|
||||
| 存储路径 | `/Volumes/stock/minute_kline/15min/` |
|
||||
| 约束 | 15分钟线优先,1分钟线暂缓 |
|
||||
|
||||
#### P3-4:分钟线导入vnpy DB
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 将分钟线Parquet导入vnpy DB |
|
||||
| 前置 | P1-2已确认vnpy DB Schema + 分钟线Parquet已下载 |
|
||||
| 不确定项 | vnpy 4.x如何区分不同周期(15min vs 1min)的分钟线 |
|
||||
|
||||
---
|
||||
|
||||
### P4:配套skill与自动化
|
||||
|
||||
#### P4-1/P4-2:更新skill文档
|
||||
|
||||
更新 `data-acquisition` 和 `quant-backtest` SKILL.md,补充vnpy数据通路说明。
|
||||
|
||||
#### P4-3:全量校验脚本
|
||||
|
||||
关羽设计的V2规则(14条),用于定期全量扫描。
|
||||
|
||||
#### P4-4:周维护cron
|
||||
|
||||
每周校验Parquet与vnpy DB一致性。
|
||||
|
||||
---
|
||||
|
||||
## 三、交付物清单
|
||||
|
||||
### 代码文件(放到 `~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/`)
|
||||
|
||||
| 文件 | 功能 | 阶段 | 预估行数 |
|
||||
|------|------|------|---------|
|
||||
| `import_vnpy.py` | Parquet → vnpy DB 导入 | P1 | ~200 |
|
||||
| `fallback.py` | 多源降级管理器 | P2 | ~150 |
|
||||
| `validator.py` | 数据校验(V1 7条fatal) | P2 | ~150 |
|
||||
| `realtime.py` | 实时行情三源降级 | P2 | ~200 |
|
||||
| `updater.py` | 增量更新(双写) | P2 | ~150 |
|
||||
| `validate_full.py` | 全量校验(V2 14条) | P4 | ~100 |
|
||||
|
||||
### 文档文件
|
||||
|
||||
| 文件 | 内容 | 位置 |
|
||||
|------|------|------|
|
||||
| 需求规格文档 | 本文档 | `docs/data-platform/01-requirements.md` |
|
||||
| 设计方案文档 | 接口设计、数据流、Schema映射 | `docs/data-platform/02-design.md` |
|
||||
| 验证报告 | 限频验证、导入验证、回测验证 | `docs/data-platform/reports/` |
|
||||
|
||||
### 配置文件
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| crontab配置 | 每日15:30增量更新 |
|
||||
| vnpy DB路径映射 | Mac ↔ Docker |
|
||||
|
||||
---
|
||||
|
||||
## 四、假设与不确定项
|
||||
|
||||
| # | 假设/不确定项 | 影响范围 | 验证人 | 验证时机 |
|
||||
|---|-------------|---------|--------|---------|
|
||||
| 1 | **Docker volume映射**:Mac写入NAS的文件Docker容器能读到 | P1全部 | 姜维 | P1开始前 |
|
||||
| 2 | **vnpy 4.x DB Schema**:回测引擎load_data()期望的表结构和字段 | P1-2, P3-4 | 姜维 | P1开始前 |
|
||||
| 3 | **vnpy分钟线周期区分**:vnpy如何存储/区分不同粒度分钟线 | P3-4 | 姜维 | P3开始前 |
|
||||
| 4 | **腾讯API限频**:连续请求的频率上限和封禁恢复时间 | P3全部 | 赵云 | P3开始前 |
|
||||
| 5 | **全量导入耗时**:5500只×17年数据的导入时间 | P1-3 | 张飞/赵云 | P1-3执行时 |
|
||||
| 6 | **SQLite并发**:cron写入+回测读取是否冲突 | P2-5 | 姜维 | P2-5配置时 |
|
||||
| 7 | NAS存储空间充足(1.5TB可用,只需28GB) | 全局 | 已确认 | - |
|
||||
| 8 | Mac永不休眠(cron可靠执行) | P2-5 | 已确认 | - |
|
||||
| 9 | 不引新依赖(只用akshare+urllib+已有库) | 全局 | 约束 | - |
|
||||
|
||||
**关键阻塞项**:#1和#2如果不明确,P1无法开始。**建议姜维先验证这两项。**
|
||||
|
||||
---
|
||||
|
||||
## 五、约束
|
||||
|
||||
1. 所有产出放到 `~/.openclaw/sanguo_projects/sanguo_vnpy/` 目录下
|
||||
2. 不引新依赖(只用akshare + urllib + 已有的库)
|
||||
3. 不改Docker/NAS配置,数据通过volume映射
|
||||
4. Parquet是唯一真相源,vnpy DB是可重建的派生缓存
|
||||
5. 双写顺序:先Parquet(原子写入)→ 再vnpy DB(幂等写入)
|
||||
6. 腾讯API是唯一可用的分钟线源
|
||||
7. 15分钟线优先,1分钟线暂缓
|
||||
8. 不确定项遇到阻塞时,用最大尝试轮数限制,不无限重试
|
||||
9. 每个阶段先输出需求和设计方案,经评审再编码
|
||||
|
||||
---
|
||||
|
||||
## 六、成功标准
|
||||
|
||||
| # | 标准 | 验证方法 |
|
||||
|---|------|---------|
|
||||
| 1 | vnpy DB有全市场日线数据 | `SELECT count(*) FROM ...` > 0 |
|
||||
| 2 | 回测服务能完成一次完整回测 | 提交回测任务返回成功 |
|
||||
| 3 | 增量更新可自动执行 | crontab触发后日志显示成功 |
|
||||
| 4 | 数据校验拦截bad data | 构造异常数据,校验返回fatal |
|
||||
| 5 | 多源降级正常工作 | 关掉主源,自动切到备用源 |
|
||||
| 6 | 分钟线P0验证有结论 | 限频报告有明确数字 |
|
||||
|
||||
---
|
||||
|
||||
## 七、数据流架构
|
||||
|
||||
```
|
||||
Layer 1: 远程数据源
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ akshare │ │ 新浪实时 │ │ 腾讯API │
|
||||
│ (日线主源) │ │ (实时主源) │ │ (分钟线唯一源)│
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└────────┬────────┴────────┬────────┘
|
||||
│ fallback.py │
|
||||
│ 降级管理 │
|
||||
▼ │
|
||||
Layer 2: 校验层 │ │
|
||||
validator.py │
|
||||
(7条fatal规则) │
|
||||
│ │
|
||||
▼ ▼
|
||||
Layer 3: NAS持久层 (唯一真相源)
|
||||
/Volumes/stock/A股数据/日线数据/daily/{year}/{code}_daily.parquet
|
||||
/Volumes/stock/minute_kline/15min/{code}_15min.parquet
|
||||
│
|
||||
│ import_vnpy.py / updater.py
|
||||
▼
|
||||
Layer 4: vnpy SQLite DB (派生缓存)
|
||||
/Volumes/stock/sanguo_vnpy/data/quant_trading.db
|
||||
│
|
||||
│ engine.load_data()
|
||||
▼
|
||||
Layer 5: 回测引擎
|
||||
BacktestingEngine → 回测结果
|
||||
```
|
||||
@@ -0,0 +1,265 @@
|
||||
# P2 需求规格文档:数据基础设施建设
|
||||
|
||||
**任务ID**: data-platform-p2-20260502
|
||||
**节点**: pangtong_requirements
|
||||
**作者**: 庞统(副军师)
|
||||
**日期**: 2026-05-02
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
### 1.1 P1已完成的基础
|
||||
|
||||
| 项 | 状态 | 详情 |
|
||||
|----|------|------|
|
||||
| vnpy DB日线数据 | ✅ | 5191只,1281万行,2010~2026-03-27 |
|
||||
| 回测服务可用 | ✅ | 端到端验证通过 |
|
||||
| 导入脚本 | ✅ | `import_vnpy_daily_fast.py`(126行,pandas向量化) |
|
||||
| DB路径 | ✅ | `/Volumes/stock/sanguo_vnpy/data/quant_trading.db`(1.4GB) |
|
||||
| 已有适配器 | ⚠️ | `vnpy_local_data_adapter.py`(路径硬编码Mac本地,仅日线) |
|
||||
|
||||
### 1.2 当前数据缺口
|
||||
|
||||
- NAS日线数据停在 **2026-03-27**,需补约 **25个交易日**(至2026-05-02)
|
||||
- 无增量更新机制(每次需手动全量导入)
|
||||
- 无数据校验(异常数据入库无拦截)
|
||||
- 无多源降级(akshare挂了无备用)
|
||||
- 无实时行情能力
|
||||
- 无自动定时任务
|
||||
|
||||
### 1.3 关键设计决策(P1已确认)
|
||||
|
||||
| 决策 | 结论 |
|
||||
|------|------|
|
||||
| Source of Truth | NAS Parquet是唯一真相源 |
|
||||
| vnpy DB定位 | 可重建的派生缓存 |
|
||||
| 双写顺序 | 先Parquet(原子写入:临时文件+rename)→ 再vnpy DB(INSERT OR REPLACE幂等) |
|
||||
| SMB写入策略 | SQLite写本地/tmp,完成后复制到NAS(避免SMB锁库) |
|
||||
|
||||
---
|
||||
|
||||
## 二、功能需求
|
||||
|
||||
### P2-1:多源降级管理器 `fallback.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 统一数据获取入口,支持多数据源顺序降级 |
|
||||
| 产出 | `~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/fallback.py` |
|
||||
|
||||
**日线降级链**:
|
||||
1. akshare `stock_zh_a_hist()` → 成功则返回
|
||||
2. 腾讯K线API → 成功则返回
|
||||
3. 全部失败 → 抛异常
|
||||
|
||||
**接口设计**:
|
||||
```python
|
||||
class FallbackManager:
|
||||
def get_daily(self, symbol: str, start_date: str, end_date: str) -> pd.DataFrame
|
||||
def get_realtime(self, symbol: str) -> dict
|
||||
def get_source_used(self) -> str # 返回实际使用的数据源名称
|
||||
```
|
||||
|
||||
**行为要求**:
|
||||
- 第一个源失败自动切下一个
|
||||
- 记录使用的源(写入返回数据的metadata)
|
||||
- 每个源的超时控制(单次请求10秒超时)
|
||||
- 日志记录降级事件(哪个源失败、切到哪个、耗时)
|
||||
|
||||
**预估行数**:~150行
|
||||
|
||||
### P2-2:数据校验层 `validator.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 入库前校验数据质量,fatal级拒绝入库 |
|
||||
| 产出 | `~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/validator.py` |
|
||||
|
||||
**V1规则(7条fatal)**:
|
||||
|
||||
| 规则ID | 检查逻辑 | 级别 |
|
||||
|--------|---------|------|
|
||||
| D1 | close/open/high/low > 0 | fatal |
|
||||
| D2 | high ≥ max(open,close),low ≤ min(open,close) | fatal |
|
||||
| D3 | volume >= 0 | fatal |
|
||||
| D6 | 同股同日不能两条记录 | fatal |
|
||||
| D7 | date <= 当前日期 | fatal |
|
||||
| R1 | 实时价格 current > 0, prev_close > 0 | fatal |
|
||||
| R7 | 必须携带 source + fetched_at 字段 | fatal |
|
||||
|
||||
**接口设计**:
|
||||
```python
|
||||
class DataValidator:
|
||||
def validate(self, df: pd.DataFrame, data_type: str = "daily") -> ValidationResult
|
||||
|
||||
class ValidationResult:
|
||||
passed: bool
|
||||
fatal_errors: List[str] # 阻断入库
|
||||
warnings: List[str] # 标记但不阻断
|
||||
checked_rows: int
|
||||
failed_rows: int
|
||||
```
|
||||
|
||||
**行为要求**:
|
||||
- fatal错误 → 拒绝整批入库,返回具体失败行号和原因
|
||||
- warning → 标记但允许入库(数据中附加warning字段)
|
||||
- 校验报告可序列化为JSON
|
||||
|
||||
**预估行数**:~150行
|
||||
|
||||
### P2-3:实时行情三源降级 `realtime.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 获取实时行情,支持3个源降级 |
|
||||
| 产出 | `~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/realtime.py` |
|
||||
|
||||
**降级链**:
|
||||
1. 新浪实时接口 → 成功则返回
|
||||
2. 东方财富接口 → 成功则返回
|
||||
3. 腾讯实时接口 → 成功则返回
|
||||
4. 全部失败 → 抛异常
|
||||
|
||||
**接口设计**:
|
||||
```python
|
||||
def get_realtime_quote(symbol: str) -> dict
|
||||
# 返回: {symbol, name, current, prev_close, open, high, low, volume, amount,
|
||||
# bid1_price, ask1_price, timestamp, source, fetched_at}
|
||||
```
|
||||
|
||||
**行为要求**:
|
||||
- 返回标准化的字段(不同数据源字段名不同,需统一映射)
|
||||
- 每个源10秒超时
|
||||
- 记录实际使用的数据源
|
||||
|
||||
**预估行数**:~200行
|
||||
|
||||
### P2-4:增量更新 `updater.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 每日增量更新,Parquet+vnpy DB双写 |
|
||||
| 产出 | `~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/updater.py` |
|
||||
| 当前缺口 | 数据停在2026-03-27,需补约25个交易日 |
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. 扫描NAS Parquet获取每只股票最后日期
|
||||
2. 对比今天,确定需要更新的日期范围
|
||||
3. 调用 fallback.py 获取增量数据
|
||||
4. 调用 validator.py 校验
|
||||
5. 写Parquet(原子写入:临时文件+rename)
|
||||
6. 写vnpy DB(INSERT OR REPLACE,复用P1的批量导入逻辑)
|
||||
7. 一致性校验(Parquet条数 vs DB条数)
|
||||
8. 输出更新报告
|
||||
```
|
||||
|
||||
**接口设计**:
|
||||
```python
|
||||
class DailyUpdater:
|
||||
def update_all(self) -> UpdateReport
|
||||
def update_symbol(self, symbol: str) -> SymbolUpdateResult
|
||||
|
||||
class UpdateReport:
|
||||
total_symbols: int
|
||||
updated: int
|
||||
skipped: int # 已是最新
|
||||
failed: int
|
||||
new_records: int
|
||||
parquet_size: str
|
||||
db_size: str
|
||||
consistency_ok: bool
|
||||
```
|
||||
|
||||
**关键约束**:
|
||||
- Parquet写入必须是原子的(临时文件+os.rename)
|
||||
- vnpy DB写入失败不影响Parquet
|
||||
- 复用 `import_vnpy_daily_fast.py` 的批量INSERT逻辑
|
||||
- SMB锁库:DB操作先在/tmp完成再复制
|
||||
|
||||
**首次执行**:需补2026-03-28~2026-05-02约25天数据
|
||||
|
||||
**预估行数**:~200行
|
||||
|
||||
### P2-5:cron定时任务
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 每交易日15:30自动执行增量更新 |
|
||||
| 配置 | Mac crontab(Mac永不休眠已确认) |
|
||||
| 验证 | 下一个交易日检查是否自动执行 |
|
||||
|
||||
**crontab配置**:
|
||||
```
|
||||
30 15 * * 1-5 cd ~/.openclaw/sanguo_projects/sanguo_vnpy && python3 data_platform/updater.py >> data_platform/logs/update.log 2>&1
|
||||
```
|
||||
|
||||
**配套**:
|
||||
- 日志目录:`~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/logs/`
|
||||
- 失败通知:更新失败时写日志(后续可接入三国mail通知)
|
||||
|
||||
---
|
||||
|
||||
## 三、交付物清单
|
||||
|
||||
### 代码文件(`~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/`)
|
||||
|
||||
| 文件 | 功能 | 预估行数 |
|
||||
|------|------|---------|
|
||||
| `fallback.py` | 多源降级管理器 | ~150 |
|
||||
| `validator.py` | 数据校验(7条fatal) | ~150 |
|
||||
| `realtime.py` | 实时行情三源降级 | ~200 |
|
||||
| `updater.py` | 增量更新(双写) | ~200 |
|
||||
|
||||
### 配置文件
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| crontab条目 | 每交易日15:30自动更新 |
|
||||
| logs目录 | 更新日志 |
|
||||
|
||||
### 文档
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| 本需求文档 | `~/.openclaw/sanguo_projects/sanguo_vnpy/docs/data-platform/02-p2-requirements.md` |
|
||||
|
||||
---
|
||||
|
||||
## 四、假设与不确定项
|
||||
|
||||
| # | 不确定项 | 影响 | 验证方式 |
|
||||
|---|---------|------|---------|
|
||||
| 1 | akshare `stock_zh_a_hist()` 当前是否可用 | 降级链主源 | 赵云编码时测试 |
|
||||
| 2 | 腾讯K线API的请求格式(备用日线源) | 降级链备源 | 赵云编码时测试 |
|
||||
| 3 | 新浪/东财/腾讯实时接口的当前可用性 | 实时行情 | 赵云编码时测试 |
|
||||
| 4 | 增量更新数据量(25天×5191只)的耗时 | cron窗口 | 首次执行时实测 |
|
||||
| 5 | vnpy DB导入增量数据的SMB性能 | 更新耗时 | 首次执行时实测 |
|
||||
| 6 | crontab执行时NAS是否已挂载 | cron可用性 | 配置时验证 |
|
||||
|
||||
---
|
||||
|
||||
## 五、约束
|
||||
|
||||
1. 所有产出放到 `~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/`
|
||||
2. 不引新依赖(只用akshare + urllib + 已有库)
|
||||
3. Parquet是唯一真相源,vnpy DB是可重建的派生缓存
|
||||
4. 双写顺序:先Parquet(原子写入)→ 再vnpy DB(幂等写入)
|
||||
5. SMB锁库:DB操作先在/tmp完成再复制
|
||||
6. 遇阻塞用最大尝试轮数限制
|
||||
7. 先输出设计方案经评审再编码
|
||||
8. 复用P1已有代码(`import_vnpy_daily_fast.py`的批量INSERT逻辑)
|
||||
|
||||
---
|
||||
|
||||
## 六、成功标准
|
||||
|
||||
| # | 标准 | 验证方法 |
|
||||
|---|------|---------|
|
||||
| 1 | 降级管理器可用:关掉主源自动切备源 | 手动测试 |
|
||||
| 2 | 校验层拦截bad data:构造异常数据返回fatal | 单元测试 |
|
||||
| 3 | 实时行情可获取:输入股票代码返回实时报价 | 手动测试 |
|
||||
| 4 | 增量更新可执行:补齐25天数据 | 执行updater后检查数据日期 |
|
||||
| 5 | Parquet+vnpy DB一致性 | 比对条数 |
|
||||
| 6 | cron可触发 | 配置后下个交易日检查日志 |
|
||||
@@ -0,0 +1,169 @@
|
||||
# P3 需求规格文档:分钟线数据下载与导入
|
||||
|
||||
**任务ID**: data-platform-p3-20260502
|
||||
**节点**: pangtong_requirements
|
||||
**作者**: 庞统(副军师)
|
||||
**日期**: 2026-05-02
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
### 1.1 已完成的前置工作
|
||||
|
||||
| 项 | 状态 | 证据 |
|
||||
|----|------|------|
|
||||
| P1 vnpy数据通路 | ✅ 完成 | 5191只日线,1281万行,回测验证通过 |
|
||||
| P0 腾讯限频验证 | ✅ 通过 | 100只100%成功,0.19秒/请求,无封禁 |
|
||||
| vnpy DB Schema | ✅ 已知 | DbBarData表,interval字段:d=日线,1m=1分钟 |
|
||||
| 已有分钟线数据 | ⚠️ 84只 | `/Volumes/stock/minute_kline/15min/sz{code}_15min.parquet` |
|
||||
|
||||
### 1.2 已有分钟线数据格式
|
||||
|
||||
**文件名**:`sz000001_15min.parquet`
|
||||
**字段**:day, open, high, low, close, volume, amount(7列)
|
||||
**日期范围**:2025-09-17 ~ 2026-03-27(约1970条/只)
|
||||
**字段类型**:day=object, open/high/low/close=float64, volume/amount=object
|
||||
|
||||
### 1.3 vnpy DB分钟线interval值
|
||||
|
||||
根据P1赵云确认:`1m` = 1分钟线。**15分钟线的interval值需在编码阶段确认**(可能是 `15m` 或其他值)。
|
||||
|
||||
### 1.4 腾讯mkline API
|
||||
|
||||
唯一可用的分钟线数据源(akshare `stock_zh_a_minute()` 已失效)。
|
||||
- 限频:100只连续请求无限制,全市场预估17分钟
|
||||
- 需确认API的请求格式、返回格式、单次返回的历史数据长度
|
||||
|
||||
---
|
||||
|
||||
## 二、功能需求
|
||||
|
||||
### P3-1:下载脚本 `download_minute.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 从腾讯mkline API下载15分钟线数据 |
|
||||
| 数据源 | 腾讯财经mkline API(唯一可用源) |
|
||||
| 存储格式 | Parquet,与已有84只保持一致(day,open,high,low,close,volume,amount) |
|
||||
| 存储路径 | `/Volumes/stock/minute_kline/15min/{code}_15min.parquet` |
|
||||
| 文件名格式 | `sz000001_15min.parquet` 或 `sh600000_15min.parquet` |
|
||||
|
||||
**功能要求**:
|
||||
1. 支持指定股票列表(HS300 / 全市场)
|
||||
2. 支持增量下载(已有数据只追加新部分)
|
||||
3. 断点续传(记录已下载到哪只)
|
||||
4. 限频保护(如遇封禁自动等待重试,最大重试次数限制)
|
||||
5. 下载日志(成功/失败/跳过/耗时)
|
||||
6. 对已有84只文件做增量更新而非覆盖
|
||||
|
||||
**输出**:
|
||||
- 下载报告(成功数、失败数、总耗时、总数据量)
|
||||
|
||||
### P3-2:HS300 15分钟线全量下载
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 下载HS300成分股的15分钟线 |
|
||||
| 股票数 | ~300只 |
|
||||
| 预估耗时 | ~1分钟(基于P0验证:0.19秒/只) |
|
||||
| 预估存储 | ~1.2GB(300只 × ~4MB/只) |
|
||||
|
||||
### P3-3:全市场15分钟线下载
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 下载全市场A股15分钟线 |
|
||||
| 股票数 | ~5500只 |
|
||||
| 预估耗时 | ~17分钟 |
|
||||
| 预估存储 | ~22GB |
|
||||
| 前置 | P3-2验证无问题 |
|
||||
|
||||
### P3-4:分钟线导入vnpy DB `import_vnpy_minute.py`
|
||||
|
||||
| 项 | 说明 |
|
||||
|-----|------|
|
||||
| 需求 | 将15分钟线Parquet导入vnpy SQLite DB |
|
||||
| 输入 | `/Volumes/stock/minute_kline/15min/{code}_15min.parquet` |
|
||||
| 输出 | quant_trading.db 新增分钟线数据(interval ≠ 'd') |
|
||||
| 约束 | 复用P1的导入逻辑(pandas向量化+批量INSERT OR REPLACE) |
|
||||
|
||||
**关键映射**:
|
||||
|
||||
| Parquet字段 | DB字段 | 转换规则 |
|
||||
|------------|--------|---------|
|
||||
| day | datetime | 直接使用(已是 "YYYY-MM-DD HH:MM:SS" 格式) |
|
||||
| open | open_price | 直接映射 |
|
||||
| high | high_price | 直接映射 |
|
||||
| low | low_price | 直接映射 |
|
||||
| close | close_price | 直接映射 |
|
||||
| volume | volume | float转换 |
|
||||
| amount | turnover | float转换 |
|
||||
| 文件名前缀 | symbol+exchange | sz→SZSE, sh→SSE |
|
||||
| 固定值 | interval | **待确认**(可能为 "15m") |
|
||||
| 固定值 | open_interest | 0.0 |
|
||||
|
||||
**SMB锁库问题**:同P1,先写 `/tmp/` 再复制到NAS。或在本地操作DB后整体替换。
|
||||
|
||||
---
|
||||
|
||||
## 三、交付物清单
|
||||
|
||||
### 代码文件(`~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/`)
|
||||
|
||||
| 文件 | 功能 | 预估行数 |
|
||||
|------|------|---------|
|
||||
| `download_minute.py` | 腾讯mkline下载+增量+断点续传 | ~200 |
|
||||
| `import_vnpy_minute.py` | Parquet→vnpy DB导入 | ~150(复用P1逻辑) |
|
||||
|
||||
### 数据文件
|
||||
|
||||
| 产物 | 位置 | 预估大小 |
|
||||
|------|------|---------|
|
||||
| HS300 15分钟线Parquet | `/Volumes/stock/minute_kline/15min/` | ~1.2GB |
|
||||
| 全市场15分钟线Parquet | `/Volumes/stock/minute_kline/15min/` | ~22GB |
|
||||
| vnpy DB(增量) | `/Volumes/stock/sanguo_vnpy/data/quant_trading.db` | 增加~2GB |
|
||||
|
||||
### 报告
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| 下载报告 | 成功/失败/耗时统计 |
|
||||
| 导入报告 | 记录数/字段校验结果 |
|
||||
|
||||
---
|
||||
|
||||
## 四、假设与不确定项
|
||||
|
||||
| # | 不确定项 | 影响 | 验证方式 |
|
||||
|---|---------|------|---------|
|
||||
| 1 | 腾讯mkline API的具体请求/返回格式 | 下载脚本实现 | 赵云编码时实测 |
|
||||
| 2 | vnpy 15分钟线的interval值 | 导入脚本实现 | 查vnpy源码或实测 |
|
||||
| 3 | 腾讯API单次返回的历史数据长度(是否支持获取全量历史) | 全量下载策略 | P3-1实测 |
|
||||
| 4 | SMB写入大量小文件的性能 | 下载耗时 | 实测 |
|
||||
| 5 | DB导入分钟线后的总大小和对查询性能影响 | 回测性能 | P3-4后验证 |
|
||||
| 6 | 已有84只Parquet的字段格式与新下载是否一致 | 数据一致性 | 编码时对比 |
|
||||
|
||||
---
|
||||
|
||||
## 五、约束
|
||||
|
||||
1. 产出放到 `~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/`
|
||||
2. 不引新依赖
|
||||
3. 15分钟线优先,1分钟线暂缓
|
||||
4. 腾讯API是唯一数据源
|
||||
5. 遇阻塞用最大尝试轮数限制
|
||||
6. 先输出设计方案经评审再编码
|
||||
7. 与已有84只Parquet格式保持一致
|
||||
|
||||
---
|
||||
|
||||
## 六、成功标准
|
||||
|
||||
| # | 标准 | 验证方法 |
|
||||
|---|------|---------|
|
||||
| 1 | HS300 300只15分钟线下载完成 | 检查文件数和数据完整性 |
|
||||
| 2 | 全市场5500只下载完成 | 检查文件数和总大小 |
|
||||
| 3 | 分钟线成功导入vnpy DB | DB中有interval≠'d'的记录 |
|
||||
| 4 | 已有84只数据增量更新无覆盖 | 对比更新前后首条记录 |
|
||||
| 5 | 断点续传有效 | 中断后重启继续 |
|
||||
@@ -0,0 +1,195 @@
|
||||
# 15min 数据层设计文档
|
||||
|
||||
**项目**: sanguo_vnpy_v2 数据层
|
||||
**日期**: 2026-07-12(2026-07-08~10 审计+回填后落地)
|
||||
**范围**: A 股 15 分钟 K 线数据层(数据源、覆盖现状、脚本、刷新机制、硬约束、已知问题)
|
||||
**相关文档**: [`docs/data-platform/daily-update-design.md`](../data-platform/daily-update-design.md)(日线+15min+vNpy DB 早期多源架构 v1~v3,本文件聚焦 15min 最终落地的实现)
|
||||
|
||||
---
|
||||
|
||||
## 一、数据源
|
||||
|
||||
| 源 | 用途 | 协议 | 特点 |
|
||||
|----|------|------|------|
|
||||
| 新浪财经 15min API | **主源·增量刷新** | HTTP | `datalen=800`(≈2.5 个月)/ 次,有真实 `amount`,不复权,无法回填历史 |
|
||||
| 腾讯 minute/query + 聚合 | 备源·仅当天 | HTTP | sina 失败时用,拉 1min 聚合成 15min |
|
||||
| baostock | **历史回填** | TCP | `adjustflag=3` 不复权,2024-01-01 起,0.4s/票,单连接,**不支持 BSE** |
|
||||
|
||||
### 1.1 新浪 15min API(主源)
|
||||
|
||||
- URL: `https://quotes.sina.cn/cn/api/jsonp_v2.php/.../CN_MarketDataService.getKLineData?symbol={symbol}&scale=15&ma=no&datalen=800`
|
||||
- `datalen` 最大有效值 **800**(超过返回 null),即 15min ≈ 2.5 个月
|
||||
- 字段: `day, open, high, low, close, volume, amount`,`amount` 为真实成交额
|
||||
- 时间戳为 end-of-bar 格式(09:45, 10:00 ...)
|
||||
- 返回 JSONP,需正则提取 JSON 数组
|
||||
- 不复权,无法指定起始日期 → **只能增量刷新最近 2.5 月,不能回填更早历史**
|
||||
|
||||
### 1.2 腾讯 minute/query(备源)
|
||||
|
||||
- URL: `http://web.ifzq.gtimg.cn/appstock/app/minute/query?code={symbol}`
|
||||
- 仅返回**当天** 1min 数据,聚合为 15min(`_aggregate_1m_to_15m`)
|
||||
- 仅在 sina 主源失败时兜底
|
||||
|
||||
### 1.3 baostock(历史回填)
|
||||
|
||||
- `query_history_k_data_plus`,`adjustflag="3"`(不复权,与 sina 主源一致)
|
||||
- 起点 2024-01-01,可按日期范围全量拉取
|
||||
- 0.4s/票,单连接(并发会崩),每 400 票 relogin 防断会话
|
||||
- **不支持 BSE(北交所 920xxx)**
|
||||
|
||||
### 1.4 源降级链(15min)
|
||||
|
||||
```
|
||||
增量刷新(每日 15:30 cron):
|
||||
sina 15min(主,800 条/次)→ 腾讯 minute/query(备,仅当天)
|
||||
|
||||
历史回填(一次性):
|
||||
baostock adjustflag=3(全量历史,2024-01-01 起,不含 BSE)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、覆盖现状(2026-07-12 审计+回填后)
|
||||
|
||||
| 维度 | 数值 |
|
||||
|------|------|
|
||||
| 全市场 universe | **5493** |
|
||||
| 主板 SH/SZ 覆盖 | **5193** = 5023 老股(≥2.5 年,sina 长期累积)+ 170 新股(baostock 回填至上市日)|
|
||||
| BSE 北交所缺口 | **300**(baostock + sina 均不支持,待 akshare/腾讯另接)|
|
||||
| 15min 主目录文件数 | 5403 |
|
||||
| 数据新鲜度 | 2026-07-08 ~ 2026-07-10 |
|
||||
|
||||
### 2.1 数据深度
|
||||
|
||||
| 股票类型 | 深度 | 起点 |
|
||||
|----------|------|------|
|
||||
| 成熟股(5023 只) | ≈ 2.5 年 | 2024-01-01(sina 长期累积 + baostock 回填)|
|
||||
| 新股(170 只) | = 上市日 | baostock 回填至各自上市日 |
|
||||
| 5 年深度扩展(未来) | 2.5yr → 5yr | baostock 从 2020 起回填(大工程,未来阶段)|
|
||||
|
||||
### 2.2 BSE 缺口说明
|
||||
|
||||
- 300 只北交所股票(920xxx)baostock 和 sina 均不支持
|
||||
- 多为小盘新股,多数策略可剔除
|
||||
- 待后续用 akshare / 腾讯另接(东财接口有封 IP 风险,建议按需)
|
||||
|
||||
---
|
||||
|
||||
## 三、脚本清单(`scripts/data_platform/`)
|
||||
|
||||
| 脚本 | 作用 | 关键点 |
|
||||
|------|------|--------|
|
||||
| `download_minute.py` | sina 增量刷新 | `STOCK_ROOT` 环境变量参数化(默认 `/Volumes/stock`,NAS 用 `/volume1/stock`);0.3s/票单线程;断点续传 `download_progress.json`;`--scope all/hs300`、`--codes`、`--resume` |
|
||||
| `backfill_15min_baostock.py` | baostock 历史回填 | 全量重建 + 备份 `backup_sina/`;`adjustflag=3`;0.4s/票;marker 防重;每 400 票 relogin |
|
||||
| `refresh_15min_daily.py`(新) | cron 入口 | pop 代理直连;交易日判断(周末短路 + baostock `query_trade_dates`);调 `download_minute --scope all --resume`;日志 `$STOCK_ROOT/logs/daily_update/` |
|
||||
|
||||
### 3.1 `download_minute.py` 关键参数
|
||||
|
||||
| 参数 | 值 / 说明 |
|
||||
|------|----------|
|
||||
| `STOCK_ROOT` | `os.environ.get("STOCK_ROOT", "/Volumes/stock")`(line 47)|
|
||||
| `OUTPUT_DIR` | `$STOCK_ROOT/minute_kline/15min` |
|
||||
| `REQUEST_INTERVAL` | 0.3s |
|
||||
| `MAX_RETRIES` | 3 |
|
||||
| 连续失败暂停 | 5 次连续失败 → 暂停 60s |
|
||||
| 写入策略 | 增量合并:`concat` + `drop_duplicates(subset=["day"], keep="last")` + 原子写 `.tmp` → `rename` |
|
||||
|
||||
### 3.2 `backfill_15min_baostock.py` 关键参数
|
||||
|
||||
| 参数 | 值 / 说明 |
|
||||
|------|----------|
|
||||
| `NAS_ROOT` | 硬编码 `/Volumes/stock`(line 43,**待参数化**)|
|
||||
| `adjustflag` | `"3"`(不复权,line 131)|
|
||||
| `RELOGIN_EVERY` | 400 祒(line 268)|
|
||||
| 防重 marker | `.{stem}.baostock` 空文件(line 98/208)|
|
||||
| 旧数据备份 | `$MINUTE_15_DIR/backup_sina/` |
|
||||
|
||||
### 3.3 `refresh_15min_daily.py` 职责
|
||||
|
||||
1. pop 全部代理环境变量(`http_proxy/https_proxy/...`)保证直连
|
||||
2. 交易日判断:周末短路;工作日用 baostock `query_trade_dates`,失败降级为"默认交易日"
|
||||
3. 交易日 → `subprocess` 调 `download_minute.py --scope all --resume`,继承 `STOCK_ROOT`
|
||||
4. 日志写 `$STOCK_ROOT/logs/daily_update/refresh_15min_YYYYMMDD.log`
|
||||
|
||||
---
|
||||
|
||||
## 四、刷新机制(本次新落地)
|
||||
|
||||
### 4.1 调度
|
||||
|
||||
- **NAS Synology 任务计划**,每交易日 **15:30**(A 股 15:00 收盘后半小时)
|
||||
- 之前无任何自动刷新(crontab / Synology / 容器 cron 全空),7-08~10 的数据新鲜是手动跑的;本次补 cron
|
||||
|
||||
### 4.2 执行命令
|
||||
|
||||
容器 `sanguo_vnpy_v2` bind-mount `/volume1/stock`,路径在容器内不变:
|
||||
|
||||
```bash
|
||||
docker exec sanguo_vnpy_v2 bash -c "cd /app && python3 scripts/data_platform/refresh_15min_daily.py"
|
||||
```
|
||||
|
||||
### 4.3 交易日判断
|
||||
|
||||
- 周末(weekday ≥ 5)→ 直接跳过
|
||||
- 工作日 → baostock `query_trade_dates` 查节假日
|
||||
- baostock 不可用 → 降级为"工作日默认交易日"(非交易日跑也只是全部 skip,幂等)
|
||||
|
||||
---
|
||||
|
||||
## 五、硬约束
|
||||
|
||||
> 来源:CLAUDE.md 全局约定 + 数据下载经验(见 MEMORY.md)
|
||||
|
||||
| 约束 | 说明 | 实现 |
|
||||
|------|------|------|
|
||||
| **直连不走代理** | 避免被识别为异常流量 / akshare 代理污染 | 脚本入口 pop `http_proxy/https_proxy/...`;`download_minute._make_opener()` 用 `ProxyHandler({})` |
|
||||
| **单线程限速,0 并发** | baostock 单连接并发会崩;新浪猛打封 IP | sina 0.3s/票,baostock 0.4s/票,无并发 |
|
||||
| **间隔别太大** | baostock 长空闲断会话 | sina 0.3s / baostock 0.4s |
|
||||
| **NAS 内存紧** | swap 近满,分块+断点续传,别全市场并发 | 历史踩过 macOS Jetsam 崩溃(见 MEMORY 数据下载崩溃教训)|
|
||||
| **见空就停** | 连续 5 空 = 会话掉了 | `MAX_CONSECUTIVE_FAILS=5` → 暂停 60s |
|
||||
|
||||
---
|
||||
|
||||
## 六、路径映射表(Mac / NAS / 容器 三端)
|
||||
|
||||
| 端 | `STOCK_ROOT` | 15min 目录 |
|
||||
|----|--------------|-----------|
|
||||
| Mac 开发 | `/Volumes/stock`(NAS 挂载) | `/Volumes/stock/minute_kline/15min` |
|
||||
| NAS host | `/volume1/stock` | `/volume1/stock/minute_kline/15min` |
|
||||
| 容器 `sanguo_vnpy_v2` | `/volume1/stock`(bind-mount) | 同 NAS |
|
||||
|
||||
> 对应 `config/data_platform.yaml` 路径键:`minute_15_dir: /volume1/stock/minute_kline/15min`(容器/NAS 视角)。脚本通过 `STOCK_ROOT` 环境变量切换,不写死。
|
||||
|
||||
---
|
||||
|
||||
## 七、已知问题 / 后续
|
||||
|
||||
| 优先级 | 问题 | 说明 | 处理 |
|
||||
|--------|------|------|------|
|
||||
| **HIGH bug** | `download_minute.py` `_aggregate_1m_to_15m` 的 `amount=("amount","last")` 应为 `"sum"` | 腾讯备源路径 amount 聚合错误(sina 主源不受影响) | 待修(line 147)|
|
||||
| MEDIUM | BSE 920 缺口 300 只 | baostock + sina 均不支持 | 待 akshare/腾讯另接(东财有封 IP 风险,建议按需)|
|
||||
| LOW | 5 年深度扩展(5023 老股 2.5yr → 5yr) | baostock 从 2020 起回填,大工程 | 未来阶段 |
|
||||
| LOW | `backfill_15min_baostock.py` 的 `NAS_ROOT` 仍硬编码 `/Volumes/stock` | Mac 视角写死,NAS 跑需手改 | 建议后续也参数化为 `STOCK_ROOT` |
|
||||
|
||||
---
|
||||
|
||||
## 八、相关文件索引
|
||||
|
||||
| 文件 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 15min 主目录 | `$STOCK_ROOT/minute_kline/15min/` | 5403 个 `_15min.parquet` 文件 |
|
||||
| 断点续传进度 | `$STOCK_ROOT/minute_kline/15min/download_progress.json` | `download_minute.py --resume` 用 |
|
||||
| 审计清单 | `/volume1/stock/minute_kline/15min/backfill_target.json` | 470 个回填目标 |
|
||||
| 回填进度 | `/volume1/stock/minute_kline/15min/backfill_470_progress.json` | `done=170`, `bse_unsupported=300` |
|
||||
| 旧文件备份 | `/volume1/stock/minute_kline/15min/backup_sina/` | baostock 全量重建前的 sina 旧数据 |
|
||||
| 日刷新日志 | `$STOCK_ROOT/logs/daily_update/refresh_15min_YYYYMMDD.log` | cron 每日产出 |
|
||||
| 配置 | `config/data_platform.yaml` | `minute_15_dir` 路径键 |
|
||||
| 配置加载 | `sanguo_data/config.py` | `DataConfig.data_paths["minute_15_dir"]` |
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 变更 | 作者 |
|
||||
|------|------|------|
|
||||
| 2026-07-12 | 初始版本:15min 数据层落地后真实数据(5493 universe / 5193 覆盖 / 300 BSE 缺口 / cron 15:30 / sina主源+baostock回填) | 文档 Sub Agent |
|
||||
@@ -0,0 +1,77 @@
|
||||
# Phase 1 数据层 — 完成报告
|
||||
|
||||
**日期**:2026-07-05
|
||||
**状态**:✅ DONE(本地 16 passed + 端到端冒烟通过)
|
||||
**分支**:已 merge to master(`20e2437`)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把 v1(sanguo_vnpy)散落的数据资产(BaoStock parquet + vnpy SQLite db)收拢成统一的、可被 vnpy 原生调用的数据读取层;打通 NAS Docker 端到端链路。
|
||||
|
||||
**范围约束**:只做数据"读/写/调度/校验",不做回测/策略/Web(留 Phase 2+)。
|
||||
|
||||
## 2. 交付清单
|
||||
|
||||
### 代码模块(`sanguo_data/`)
|
||||
|
||||
| 模块 | 职责 | 关键设计 |
|
||||
|------|------|---------|
|
||||
| `config.py` | 加载 `data_platform.yaml` | 统一配置入口 |
|
||||
| `datareader.py` | 读日 K → `BarData` | `read_parquet_daily` + `read_db_daily`(配 vnpy SETTINGS)+ `guess_exchange`(代码前缀判 SSE/SZSE) |
|
||||
| `datafeed.py` | 在线数据源抓取 | BaoStock + 东财/腾讯 fallback;`_fetch_baostock_with_timeout` 子进程隔离(修 v1 卡死坑) |
|
||||
| `datafeed/` | 数据源实现子包 | 可扩展 |
|
||||
| `datawriter.py` | 数据落盘 | parquet 年分区 + vnpy sqlite 原子写 |
|
||||
| `database/` | vnpy 数据库适配子包 | sqlite 路径配置 |
|
||||
| `scheduler.py` | `UpdateScheduler` | 增量更新 + 断点续传 + 熔断 |
|
||||
| `validator.py` | 数据完整性校验 | 缺口/异常值检测 |
|
||||
|
||||
### 测试(`tests/data/`,16 passed)
|
||||
|
||||
`test_config` / `test_datareader` / `test_datafeed` / `test_datawriter` / `test_scheduler` / `test_validator` / `test_spike_vnpy44`
|
||||
|
||||
## 3. 关键 Fix(收尾阶段)
|
||||
|
||||
| Gap | commit | 说明 |
|
||||
|-----|--------|------|
|
||||
| read_db_daily 没配 vnpy database 路径 | `339d85a` | 默认空 database.db 读不到 NAS 真实数据 → 加 `SETTINGS["database.database"]` 配置 |
|
||||
| spike 测试被全局 SETTINGS 污染 | `8829501` | read_db_daily 改全局 SETTINGS → spike 用 monkeypatch mock load_bar_data 隔离 |
|
||||
|
||||
## 4. 部署产出(NAS Docker)
|
||||
|
||||
- 容器 `sanguo_vnpy_v2` 重建加挂载 `/volume1/stock`(RW)
|
||||
- 镜像 `sanguo_vnpy_v2:with-sqlite`(docker commit 保 vnpy_sqlite,避免 NAS 弱 CPU pip 卡死)
|
||||
- 外网链路 `vnpy.mysanguo.top` 恢复正常(未改动原代理/转发设计)
|
||||
|
||||
## 5. 端到端验证(真实数据)
|
||||
|
||||
`scripts/smoke_e2e.py` 在临时容器内读 NAS `quant_trading.db`:
|
||||
|
||||
```
|
||||
600000.SSE: 541 条
|
||||
000001.SZSE: 563 条
|
||||
300750.SZSE: 564 条
|
||||
区间: 2024-01-01 ~ 2026-06-30
|
||||
```
|
||||
|
||||
## 6. 风险与兜底
|
||||
|
||||
- **v1 db 被容器改**:当前 `quant_trading.db` 1.66→2.13GB(容器 RW 下 peewee create_tables 副作用)。
|
||||
- **兜底**:`/volume1/stock/sanguo_vnpy/data/` 含多个历史备份 `quant_trading_*.db.bak`(1.5G 各,20260519~20260522 + pre_interval_migration),原始可恢复。数据完整性已验证。
|
||||
|
||||
## 7. 已知 Tech Debt(留后续 phase)
|
||||
|
||||
| 项 | 影响 |
|
||||
|----|------|
|
||||
| `_load_stock_list` NotImplementedError | 部署前 copy v1,后续需补实现 |
|
||||
| datawriter 一致性回滚 | final triage 待定 |
|
||||
| validator row-by-row 全量校验性能 | 增量场景 OK,海量回测时需优化 |
|
||||
| peewee 版本冲突告警 | empyrical-reloaded 要 `peewee<3.17.4`,vnpy_sqlite 装 4.1.1 — **可能影响 Phase 2 因子层** |
|
||||
|
||||
## 8. Phase 2 衔接点
|
||||
|
||||
Phase 1 提供的下游可用接口:
|
||||
- `read_db_daily(symbol, start, end, cfg) → list[BarData]` — vnpy 原生 BarData,可直接喂 vnpy.alpha / ctabacktester
|
||||
- `read_parquet_daily(...)` — parquet 快速读取路径
|
||||
- `UpdateScheduler` — 增量数据更新
|
||||
|
||||
Phase 2(因子/回测层)应基于这些接口构建,遵循 PRD ADR:vnpy.alpha 集成、因子层可插拔、multiprocessing+Ray 编排、不引 Qlib。
|
||||
@@ -0,0 +1,854 @@
|
||||
# Plan 1: 数据层实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 移植 v1 数据层到 v2,适配 vnpy 4.4.0,提供统一 DataReader 输出 vnpy BarData,支撑后续回测/因子层。
|
||||
|
||||
**Architecture:** 继承 v1 的 validator/fallback/增量更新(已验证资产),重组为 4 个单一职责组件 + YAML 集中配置;修复 v1 的 BaoStock 超时坑;NAS 容器本地读 parquet/SQLite,不走 SMB。
|
||||
|
||||
**Tech Stack:** Python 3.10、pandas、pyarrow(parquet)、vnpy 4.4.0 数据库模块、BaoStock、requests、PyYAML、pytest
|
||||
|
||||
## Global Constraints
|
||||
- 不改造 vnpy 核心(只用其数据库模块读 DbBarData)
|
||||
- 数据读 NAS `/volume1/stock/`(容器本地挂载,不走 SMB)
|
||||
- 继承 v1 资产:`~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/`
|
||||
- 配置集中 YAML(v1 散落代码 → v2 `config/data_platform.yaml`)
|
||||
- TDD:每个 task 先写测试 → 失败 → 实现 → 通过 → commit
|
||||
- v2 工作目录:`~/.openclaw/sanguo_projects/sanguo_vnpy_v2/`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 职责 | 来源 |
|
||||
|------|------|------|
|
||||
| `sanguo_data/__init__.py` | 包入口,导出公共接口 | 新建 |
|
||||
| `sanguo_data/config.py` | YAML 配置加载 | 新建 |
|
||||
| `sanguo_data/validator.py` | 7 条 fatal 校验 | copy v1 `data_platform/validator.py` |
|
||||
| `sanguo_data/datafeed.py` | 多源接入 + fallback + BaoStock 超时 | 基于 v1 `data_platform/fallback.py` 改造 |
|
||||
| `sanguo_data/datareader.py` | 统一读 parquet/SQLite → BarData | 新建 |
|
||||
| `sanguo_data/datawriter.py` | 写 parquet + SQLite DbBarData | 基于 v1 `data_platform/import_vnpy_daily_fast.py` 改造 |
|
||||
| `sanguo_data/scheduler.py` | 增量更新 + 断点续传 | 基于 v1 `data_platform/daily_all_update.py` 改造 |
|
||||
| `config/data_platform.yaml` | 数据源/路径/限流配置 | 新建 |
|
||||
| `tests/data/__init__.py` | 测试包 | 新建 |
|
||||
| `tests/data/test_validator.py` | validator 测试 | 新建 |
|
||||
| `tests/data/test_datareader.py` | DataReader 测试 | 新建 |
|
||||
| `tests/data/test_datafeed.py` | DataFeed 测试(含 BaoStock 超时) | 新建 |
|
||||
| `tests/data/test_config.py` | 配置加载测试 | 新建 |
|
||||
| `tests/data/conftest.py` | 测试夹具(合成 BarData/parquet) | 新建 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 项目脚手架 + YAML 配置
|
||||
|
||||
**Files:**
|
||||
- Create: `sanguo_data/__init__.py`, `sanguo_data/config.py`, `config/data_platform.yaml`
|
||||
- Create: `tests/data/__init__.py`, `tests/data/test_config.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `load_config(path: str) -> DataConfig`,`DataConfig` 是 dataclass,含 `data_paths`、`data_sources`、`validation`、`performance` 字段
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
```python
|
||||
# tests/data/test_config.py
|
||||
from sanguo_data.config import load_config, DataConfig
|
||||
|
||||
def test_load_config_returns_dataconfig(tmp_path):
|
||||
yaml_content = """
|
||||
data_paths:
|
||||
daily_dir: /tmp/daily
|
||||
minute_15_dir: /tmp/15min
|
||||
vnpy_db: /tmp/quant.db
|
||||
stock_list: /tmp/stock.csv
|
||||
data_sources:
|
||||
daily:
|
||||
- name: eastmoney
|
||||
enabled: true
|
||||
interval: 4.0
|
||||
validation:
|
||||
price_positive: true
|
||||
performance:
|
||||
max_retries: 3
|
||||
"""
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(yaml_content)
|
||||
cfg = load_config(str(p))
|
||||
assert isinstance(cfg, DataConfig)
|
||||
assert cfg.data_paths["daily_dir"] == "/tmp/daily"
|
||||
assert cfg.data_sources["daily"][0]["name"] == "eastmoney"
|
||||
assert cfg.performance["max_retries"] == 3
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `pytest tests/data/test_config.py -v`
|
||||
Expected: FAIL with "ModuleNotFoundError: sanguo_data.config"
|
||||
|
||||
- [ ] **Step 3: 实现 config.py**
|
||||
|
||||
```python
|
||||
# sanguo_data/config.py
|
||||
from dataclasses import dataclass
|
||||
import yaml
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DataConfig:
|
||||
data_paths: dict
|
||||
data_sources: dict
|
||||
validation: dict
|
||||
performance: dict
|
||||
|
||||
def load_config(path: str) -> DataConfig:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
return DataConfig(
|
||||
data_paths=raw.get("data_paths", {}),
|
||||
data_sources=raw.get("data_sources", {}),
|
||||
validation=raw.get("validation", {}),
|
||||
performance=raw.get("performance", {}),
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# sanguo_data/__init__.py
|
||||
from .config import DataConfig, load_config
|
||||
__all__ = ["DataConfig", "load_config"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 创建 config/data_platform.yaml**
|
||||
|
||||
```yaml
|
||||
# config/data_platform.yaml
|
||||
data_paths:
|
||||
daily_dir: /volume1/stock/A股数据/日线数据/daily
|
||||
minute_15_dir: /volume1/stock/minute_kline/15min
|
||||
vnpy_db: /volume1/stock/sanguo_vnpy/data/quant_trading.db
|
||||
stock_list: /volume1/stock/A股数据/stock_info/stock_basic_info_raw_20260326_113530.csv
|
||||
|
||||
data_sources:
|
||||
daily:
|
||||
- name: eastmoney
|
||||
enabled: true
|
||||
interval: 4.0
|
||||
- name: baostock
|
||||
enabled: true
|
||||
interval: 0.0
|
||||
timeout: 30
|
||||
- name: tencent
|
||||
enabled: true
|
||||
interval: 0.0
|
||||
minute_15:
|
||||
- name: eastmoney
|
||||
enabled: true
|
||||
interval: 4.0
|
||||
|
||||
validation:
|
||||
price_positive: true
|
||||
ohlc_consistency: true
|
||||
no_future_dates: true
|
||||
|
||||
performance:
|
||||
request_interval: 0.3
|
||||
max_retries: 3
|
||||
fail_window: 100
|
||||
fail_threshold: 0.8
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试确认通过**
|
||||
|
||||
Run: `pytest tests/data/test_config.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add sanguo_data/ config/data_platform.yaml tests/data/
|
||||
git commit -m "feat(data): 脚手架 + YAML 配置加载"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Validator(copy v1 + 测试)
|
||||
|
||||
**Files:**
|
||||
- Create: `sanguo_data/validator.py`(copy v1)
|
||||
- Create: `tests/data/test_validator.py`, `tests/data/conftest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `validate_daily(df: pd.DataFrame) -> pd.DataFrame`(过滤非法行)
|
||||
|
||||
- [ ] **Step 1: copy v1 validator.py**
|
||||
|
||||
```bash
|
||||
cp ~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/validator.py \
|
||||
~/.openclaw/sanguo_projects/sanguo_vnpy_v2/sanguo_data/validator.py
|
||||
```
|
||||
|
||||
读 copy 后的文件,确认 v1 原有校验函数名。如签名与下方测试不符,**以 v1 实际签名为准**调整。
|
||||
|
||||
- [ ] **Step 2: 写失败测试(合成数据)**
|
||||
|
||||
```python
|
||||
# tests/data/conftest.py
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def good_daily_df():
|
||||
return pd.DataFrame({
|
||||
"date": ["2026-01-01", "2026-01-02"],
|
||||
"open": [10.0, 11.0], "high": [10.5, 11.5],
|
||||
"low": [9.8, 10.8], "close": [10.2, 11.2],
|
||||
"volume": [10000, 12000],
|
||||
})
|
||||
|
||||
@pytest.fixture
|
||||
def bad_daily_df():
|
||||
return pd.DataFrame({
|
||||
"date": ["2026-01-01"],
|
||||
"open": [0.0], "high": [0.0], "low": [0.0], "close": [0.0],
|
||||
"volume": [100],
|
||||
})
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/data/test_validator.py
|
||||
from sanguo_data.validator import validate_daily
|
||||
|
||||
def test_validate_daily_keeps_good_rows(good_daily_df):
|
||||
assert len(validate_daily(good_daily_df)) == 2
|
||||
|
||||
def test_validate_daily_drops_zero_price(bad_daily_df):
|
||||
assert len(validate_daily(bad_daily_df)) == 0
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试**
|
||||
|
||||
Run: `pytest tests/data/test_validator.py -v`
|
||||
Expected: FAIL(函数名不匹配)或 PASS(v1 直接可用)
|
||||
|
||||
- [ ] **Step 4: 适配导出接口**
|
||||
|
||||
如 v1 函数名/签名与测试不符,在 `validator.py` 末尾加薄适配(**不改 v1 校验逻辑**):
|
||||
|
||||
```python
|
||||
# 适配层,不改 v1 校验逻辑
|
||||
def validate_daily(df):
|
||||
"""对外统一接口,委托 v1 校验规则"""
|
||||
return _v1_validate(df) # 替换为 v1 实际函数名
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行确认通过**
|
||||
|
||||
Run: `pytest tests/data/test_validator.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add sanguo_data/validator.py tests/data/test_validator.py tests/data/conftest.py
|
||||
git commit -m "feat(data): 移植 v1 validator + 适配接口 + 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: DataReader — parquet 读取
|
||||
|
||||
**Files:**
|
||||
- Create: `sanguo_data/datareader.py`
|
||||
- Create: `tests/data/test_datareader.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DataConfig.data_paths["daily_dir"]`(Task 1)
|
||||
- Produces: `read_parquet_daily(symbol: str, start: str, end: str, cfg: DataConfig) -> list[BarData]`,`_row_to_bar(symbol, row, interval) -> BarData`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(合成 parquet)**
|
||||
|
||||
```python
|
||||
# tests/data/test_datareader.py
|
||||
import pandas as pd
|
||||
from sanguo_data.config import DataConfig
|
||||
from sanguo_data.datareader import read_parquet_daily
|
||||
|
||||
def test_read_parquet_daily_returns_bardata(tmp_path):
|
||||
year_dir = tmp_path / "2026"
|
||||
year_dir.mkdir()
|
||||
df = pd.DataFrame({
|
||||
"date": ["2026-01-05", "2026-01-06"],
|
||||
"open": [10.0, 11.0], "high": [10.5, 11.5],
|
||||
"low": [9.8, 10.8], "close": [10.2, 11.2],
|
||||
"volume": [10000, 12000],
|
||||
})
|
||||
df.to_parquet(year_dir / "600000.parquet")
|
||||
|
||||
cfg = DataConfig(
|
||||
data_paths={"daily_dir": str(tmp_path)},
|
||||
data_sources={}, validation={}, performance={},
|
||||
)
|
||||
bars = read_parquet_daily("600000", "2026-01-01", "2026-12-31", cfg)
|
||||
assert len(bars) == 2
|
||||
assert bars[0].symbol == "600000"
|
||||
assert bars[0].open_price == 10.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `pytest tests/data/test_datareader.py::test_read_parquet_daily_returns_bardata -v`
|
||||
Expected: FAIL "ModuleNotFoundError"
|
||||
|
||||
- [ ] **Step 3: 实现 datareader.py(parquet 部分)**
|
||||
|
||||
```python
|
||||
# sanguo_data/datareader.py
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from vnpy.trader.object import BarData
|
||||
from vnpy.trader.constant import Exchange, Interval
|
||||
|
||||
def read_parquet_daily(symbol: str, start: str, end: str, cfg) -> list[BarData]:
|
||||
daily_dir = Path(cfg.data_paths["daily_dir"])
|
||||
start_dt = datetime.strptime(start, "%Y-%m-%d")
|
||||
end_dt = datetime.strptime(end, "%Y-%m-%d")
|
||||
bars: list[BarData] = []
|
||||
for year in range(start_dt.year, end_dt.year + 1):
|
||||
f = daily_dir / str(year) / f"{symbol}.parquet"
|
||||
if not f.exists():
|
||||
continue
|
||||
df = pd.read_parquet(f)
|
||||
for _, row in df.iterrows():
|
||||
d = pd.to_datetime(row["date"])
|
||||
if start_dt <= d <= end_dt:
|
||||
bars.append(_row_to_bar(symbol, row, Interval.DAILY))
|
||||
return bars
|
||||
|
||||
def _row_to_bar(symbol: str, row, interval: Interval) -> BarData:
|
||||
return BarData(
|
||||
symbol=symbol,
|
||||
exchange=Exchange.SSE, # Task 4 改为 guess_exchange
|
||||
datetime=pd.to_datetime(row["date"]).to_pydatetime(),
|
||||
interval=interval,
|
||||
open_price=float(row["open"]),
|
||||
high_price=float(row["high"]),
|
||||
low_price=float(row["low"]),
|
||||
close_price=float(row["close"]),
|
||||
volume=float(row["volume"]),
|
||||
gateway_name="DATA",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `pytest tests/data/test_datareader.py::test_read_parquet_daily_returns_bardata -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add sanguo_data/datareader.py tests/data/test_datareader.py
|
||||
git commit -m "feat(data): DataReader parquet 读取 → BarData"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: DataReader — SQLite DbBarData + 交易所判断
|
||||
|
||||
**Files:**
|
||||
- Modify: `sanguo_data/datareader.py`(加 `read_db_daily` + `guess_exchange`,`_row_to_bar` 改用 `guess_exchange`)
|
||||
- Modify: `tests/data/test_datareader.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `read_db_daily(symbol, start, end, cfg) -> list[BarData]`(用 vnpy 4.4.0 数据库模块),`guess_exchange(symbol) -> Exchange`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
```python
|
||||
# 追加到 tests/data/test_datareader.py
|
||||
from sanguo_data.datareader import guess_exchange
|
||||
|
||||
def test_guess_exchange_sh():
|
||||
assert guess_exchange("600000").value == "SSE"
|
||||
|
||||
def test_guess_exchange_sz():
|
||||
assert guess_exchange("000001").value == "SZSE"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `pytest tests/data/test_datareader.py::test_guess_exchange_sh -v`
|
||||
Expected: FAIL "ImportError"
|
||||
|
||||
- [ ] **Step 3: 实现 guess_exchange + read_db_daily,并让 `_row_to_bar` 用 guess_exchange**
|
||||
|
||||
```python
|
||||
# 追加到 sanguo_data/datareader.py;并把 _row_to_bar 的 exchange 改为 guess_exchange(symbol)
|
||||
from vnpy.trader.database import get_database
|
||||
|
||||
def guess_exchange(symbol: str) -> Exchange:
|
||||
"""按代码前缀判断交易所:6/68/5x→SSE,0/3/15x→SZSE"""
|
||||
if symbol.startswith(("60", "68", "51", "56", "58")):
|
||||
return Exchange.SSE
|
||||
if symbol.startswith(("00", "30", "15")):
|
||||
return Exchange.SZSE
|
||||
return Exchange.SSE
|
||||
|
||||
def read_db_daily(symbol: str, start: str, end: str, cfg) -> list[BarData]:
|
||||
db = get_database()
|
||||
start_dt = datetime.strptime(start, "%Y-%m-%d")
|
||||
end_dt = datetime.strptime(end, "%Y-%m-%d")
|
||||
return db.load_bar_data(
|
||||
symbol=symbol,
|
||||
exchange=guess_exchange(symbol),
|
||||
interval=Interval.DAILY,
|
||||
start=start_dt,
|
||||
end=end_dt,
|
||||
)
|
||||
```
|
||||
|
||||
把 `_row_to_bar` 内的 `exchange=Exchange.SSE` 改为 `exchange=guess_exchange(symbol)`。
|
||||
|
||||
> **Spike 检查点**:`get_database()` 与 `load_bar_data` 签名需对照 vnpy 4.4.0 `vnpy/trader/database.py`。Task 8 spike 验证,若变更回头修正。
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `pytest tests/data/test_datareader.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 真实 NAS 数据冒烟(手工)**
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
from sanguo_data.config import load_config
|
||||
from sanguo_data.datareader import read_db_daily
|
||||
cfg = load_config('config/data_platform.yaml')
|
||||
bars = read_db_daily('600000', '2026-01-01', '2026-06-30', cfg)
|
||||
print(f'读取 {len(bars)} 条')
|
||||
"
|
||||
```
|
||||
Expected: N > 0。若 0,检查 vnpy_db 路径与 4.4.0 接口。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add sanguo_data/datareader.py tests/data/test_datareader.py
|
||||
git commit -m "feat(data): DataReader SQLite + 交易所判断 + vnpy 4.4.0 spike 点"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: DataFeed — 多源 fallback + BaoStock 超时(v1 卡死坑修复)
|
||||
|
||||
**Files:**
|
||||
- Create: `sanguo_data/datafeed.py`(基于 v1 `fallback.py`)
|
||||
- Create: `tests/data/test_datafeed.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `fetch_daily(symbol, start, end, cfg) -> pd.DataFrame`,`fetch_with_fallback(symbol, start, end, sources) -> pd.DataFrame`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(mock + 超时)**
|
||||
|
||||
```python
|
||||
# tests/data/test_datafeed.py
|
||||
import time
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from sanguo_data.datafeed import fetch_with_fallback, _fetch_baostock_with_timeout
|
||||
|
||||
def test_fetch_with_fallback_uses_second_when_first_fails():
|
||||
df_good = pd.DataFrame({"date": ["2026-01-01"], "open": [10.0]})
|
||||
with patch("sanguo_data.datafeed._fetch_eastmoney", side_effect=Exception("limit")), \
|
||||
patch("sanguo_data.datafeed._fetch_baostock", return_value=df_good):
|
||||
out = fetch_with_fallback("600000", "2026-01-01", "2026-01-02", ["eastmoney", "baostock"])
|
||||
assert len(out) == 1
|
||||
|
||||
def test_baostock_timeout_does_not_hang():
|
||||
"""v1 卡死坑修复验证:超时必须返回,不能无限挂起"""
|
||||
start = time.time()
|
||||
with patch("sanguo_data.datafeed._fetch_baostock_raw", side_effect=lambda *a: time.sleep(60)):
|
||||
with pytest.raises(TimeoutError):
|
||||
_fetch_baostock_with_timeout("600000", "2026-01-01", "2026-01-02", timeout=2)
|
||||
assert time.time() - start < 5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `pytest tests/data/test_datafeed.py -v`
|
||||
Expected: FAIL "ModuleNotFoundError"
|
||||
|
||||
- [ ] **Step 3: 实现 datafeed.py(v1 fallback 模式 + BaoStock 超时包装)**
|
||||
|
||||
```python
|
||||
# sanguo_data/datafeed.py
|
||||
import pandas as pd
|
||||
from multiprocessing import Process, Queue
|
||||
from sanguo_data.config import DataConfig
|
||||
|
||||
def fetch_with_fallback(symbol, start, end, sources: list[str]) -> pd.DataFrame:
|
||||
fetchers = {
|
||||
"eastmoney": _fetch_eastmoney,
|
||||
"baostock": lambda s, a, b: _fetch_baostock_with_timeout(s, a, b, timeout=30),
|
||||
"tencent": _fetch_tencent,
|
||||
}
|
||||
last_err = None
|
||||
for name in sources:
|
||||
try:
|
||||
df = fetchers[name](symbol, start, end)
|
||||
if df is not None and len(df) > 0:
|
||||
return df
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
raise RuntimeError(f"all sources failed: {last_err}")
|
||||
|
||||
def fetch_daily(symbol, start, end, cfg: DataConfig) -> pd.DataFrame:
|
||||
sources = [s["name"] for s in cfg.data_sources.get("daily", []) if s.get("enabled", True)]
|
||||
return fetch_with_fallback(symbol, start, end, sources)
|
||||
|
||||
def _fetch_baostock_with_timeout(symbol, start, end, timeout):
|
||||
"""子进程隔离 BaoStock(修复 v1 无超时卡死坑)"""
|
||||
q = Queue()
|
||||
def worker():
|
||||
try:
|
||||
q.put(_fetch_baostock_raw(symbol, start, end))
|
||||
except Exception as e:
|
||||
q.put(e)
|
||||
p = Process(target=worker)
|
||||
p.start()
|
||||
p.join(timeout)
|
||||
if p.is_alive():
|
||||
p.terminate(); p.join()
|
||||
raise TimeoutError(f"baostock timeout after {timeout}s")
|
||||
res = q.get()
|
||||
if isinstance(res, Exception):
|
||||
raise res
|
||||
return res
|
||||
|
||||
def _fetch_baostock_raw(symbol, start, end):
|
||||
"""从 v1 data_platform/fallback.py copy BaoStock 接入(baostock.query_history_k_data_plus)"""
|
||||
raise NotImplementedError("copy from v1 data_platform/fallback.py")
|
||||
|
||||
def _fetch_eastmoney(symbol, start, end):
|
||||
raise NotImplementedError("copy from v1 data_platform/fallback.py")
|
||||
|
||||
def _fetch_tencent(symbol, start, end):
|
||||
raise NotImplementedError("copy from v1 data_platform/fallback.py")
|
||||
```
|
||||
|
||||
> **执行注意**:`_fetch_baostock_raw` / `_fetch_eastmoney` / `_fetch_tencent` 从 v1 `data_platform/fallback.py` copy 接入逻辑。**超时包装是新增修复,不 copy。**
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `pytest tests/data/test_datafeed.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add sanguo_data/datafeed.py tests/data/test_datafeed.py
|
||||
git commit -m "feat(data): DataFeed 多源 fallback + BaoStock 超时修复"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: DataWriter — 原子写 parquet + vnpy SQLite
|
||||
|
||||
**Files:**
|
||||
- Create: `sanguo_data/datawriter.py`
|
||||
- Create: `tests/data/test_datawriter.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `write_daily(symbol, df, cfg) -> None`,`atomic_write_parquet(path, df) -> None`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
```python
|
||||
# tests/data/test_datawriter.py
|
||||
import pandas as pd
|
||||
from sanguo_data.config import DataConfig
|
||||
from sanguo_data.datawriter import write_daily, atomic_write_parquet
|
||||
|
||||
def test_atomic_write_parquet(tmp_path):
|
||||
f = tmp_path / "2026" / "600000.parquet"
|
||||
df = pd.DataFrame({"date": ["2026-01-01"], "open": [10.0]})
|
||||
atomic_write_parquet(str(f), df)
|
||||
assert f.exists()
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
def test_write_daily_writes_parquet_and_db(tmp_path, monkeypatch):
|
||||
cfg = DataConfig(
|
||||
data_paths={"daily_dir": str(tmp_path / "daily"), "vnpy_db": str(tmp_path / "q.db")},
|
||||
data_sources={}, validation={}, performance={},
|
||||
)
|
||||
df = pd.DataFrame({"date": ["2026-01-01"], "open": [10.0], "high": [10.0],
|
||||
"low": [10.0], "close": [10.0], "volume": [100]})
|
||||
called = {}
|
||||
monkeypatch.setattr("sanguo_data.datawriter._save_to_vnpy_db", lambda bars, cfg: called.setdefault("bars", bars))
|
||||
write_daily("600000", df, cfg)
|
||||
assert (tmp_path / "daily" / "2026" / "600000.parquet").exists()
|
||||
assert len(called["bars"]) == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `pytest tests/data/test_datawriter.py -v`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: 实现 datawriter.py**
|
||||
|
||||
```python
|
||||
# sanguo_data/datawriter.py
|
||||
import os
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from vnpy.trader.object import BarData
|
||||
from vnpy.trader.constant import Interval
|
||||
from sanguo_data.datareader import _row_to_bar
|
||||
from sanguo_data.config import DataConfig
|
||||
|
||||
def atomic_write_parquet(path: str, df: pd.DataFrame) -> None:
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = str(p) + ".tmp"
|
||||
df.to_parquet(tmp)
|
||||
os.replace(tmp, str(p)) # 原子替换
|
||||
|
||||
def write_daily(symbol: str, df: pd.DataFrame, cfg: DataConfig) -> None:
|
||||
# 1) parquet 增量合并(按年分区,去重保留最新)
|
||||
for year, group in df.groupby(df["date"].str[:4]):
|
||||
f = Path(cfg.data_paths["daily_dir"]) / year / f"{symbol}.parquet"
|
||||
if f.exists():
|
||||
old = pd.read_parquet(f)
|
||||
combined = pd.concat([old, group]).drop_duplicates("date", keep="last")
|
||||
else:
|
||||
combined = group
|
||||
atomic_write_parquet(str(f), combined)
|
||||
# 2) vnpy SQLite
|
||||
bars = [_row_to_bar(symbol, row, Interval.DAILY) for _, row in df.iterrows()]
|
||||
_save_to_vnpy_db(bars, cfg)
|
||||
|
||||
def _save_to_vnpy_db(bars: list[BarData], cfg: DataConfig) -> None:
|
||||
from vnpy.trader.database import get_database
|
||||
db = get_database()
|
||||
db.save_bar_data(bars) # spike 验证签名
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `pytest tests/data/test_datawriter.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add sanguo_data/datawriter.py tests/data/test_datawriter.py
|
||||
git commit -m "feat(data): DataWriter 原子写 parquet + vnpy SQLite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: UpdateScheduler — 增量更新 + 断点续传 + 熔断
|
||||
|
||||
**Files:**
|
||||
- Create: `sanguo_data/scheduler.py`(基于 v1 `daily_all_update.py`)
|
||||
- Create: `tests/data/test_scheduler.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: fetch_daily(Task 5)+ validate_daily(Task 2)+ write_daily(Task 6)
|
||||
- Produces: `run_daily_update(cfg, symbols=None) -> UpdateReport`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(断点续传)**
|
||||
|
||||
```python
|
||||
# tests/data/test_scheduler.py
|
||||
import json
|
||||
import pandas as pd
|
||||
from unittest.mock import patch
|
||||
from sanguo_data.config import DataConfig
|
||||
from sanguo_data.scheduler import run_daily_update, UpdateReport
|
||||
|
||||
def test_run_daily_update_skips_completed_on_resume(tmp_path):
|
||||
progress_file = tmp_path / "progress.json"
|
||||
progress_file.write_text('{"600000": "done"}')
|
||||
cfg = DataConfig(
|
||||
data_paths={"daily_dir": str(tmp_path), "vnpy_db": str(tmp_path / "q.db"),
|
||||
"progress_file": str(progress_file)},
|
||||
data_sources={"daily": [{"name": "eastmoney", "enabled": True}]},
|
||||
validation={}, performance={},
|
||||
)
|
||||
with patch("sanguo_data.scheduler.fetch_daily", return_value=pd.DataFrame({
|
||||
"date": ["2026-01-01"], "open": [10.0], "high": [10.0],
|
||||
"low": [10.0], "close": [10.0], "volume": [100]})) as m_fetch, \
|
||||
patch("sanguo_data.scheduler.write_daily") as m_write:
|
||||
report = run_daily_update(cfg, symbols=["600000"])
|
||||
assert m_fetch.call_count == 0 # 已 done,跳过
|
||||
assert isinstance(report, UpdateReport)
|
||||
assert report.skipped == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `pytest tests/data/test_scheduler.py -v`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: 实现 scheduler.py**
|
||||
|
||||
```python
|
||||
# sanguo_data/scheduler.py
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from sanguo_data.config import DataConfig
|
||||
from sanguo_data.datafeed import fetch_daily
|
||||
from sanguo_data.validator import validate_daily
|
||||
from sanguo_data.datawriter import write_daily
|
||||
|
||||
@dataclass
|
||||
class UpdateReport:
|
||||
total: int = 0
|
||||
success: int = 0
|
||||
failed: int = 0
|
||||
skipped: int = 0
|
||||
failures: list = field(default_factory=list)
|
||||
|
||||
def run_daily_update(cfg: DataConfig, symbols: list[str] | None = None) -> UpdateReport:
|
||||
progress_path = Path(cfg.data_paths.get("progress_file", "progress.json"))
|
||||
progress = json.loads(progress_path.read_text()) if progress_path.exists() else {}
|
||||
symbols = symbols or _load_stock_list(cfg)
|
||||
report = UpdateReport(total=len(symbols))
|
||||
fail_window = cfg.performance.get("fail_window", 100)
|
||||
fail_threshold = cfg.performance.get("fail_threshold", 0.8)
|
||||
|
||||
for sym in symbols:
|
||||
if progress.get(sym) == "done":
|
||||
report.skipped += 1
|
||||
continue
|
||||
try:
|
||||
df = fetch_daily(sym, _last_date(sym, cfg), _today(), cfg)
|
||||
df = validate_daily(df)
|
||||
if len(df) > 0:
|
||||
write_daily(sym, df, cfg)
|
||||
progress[sym] = "done"
|
||||
progress_path.write_text(json.dumps(progress, ensure_ascii=False))
|
||||
report.success += 1
|
||||
except Exception as e:
|
||||
report.failed += 1
|
||||
report.failures.append({"symbol": sym, "error": str(e)})
|
||||
checked = report.success + report.failed
|
||||
if checked >= fail_window and report.failed / max(checked, 1) > fail_threshold:
|
||||
report.failures.append({"error": "FAIL_THRESHOLD_REACHED, abort"})
|
||||
break
|
||||
time.sleep(cfg.performance.get("request_interval", 0.3))
|
||||
return report
|
||||
|
||||
def _load_stock_list(cfg: DataConfig) -> list[str]:
|
||||
"""从 v1 data_platform/daily_all_update.py copy 全市场股票列表读取"""
|
||||
raise NotImplementedError("copy from v1")
|
||||
|
||||
def _last_date(symbol: str, cfg: DataConfig) -> str:
|
||||
return "2020-01-01" # 简化,实际读 parquet 最后日期
|
||||
|
||||
def _today() -> str:
|
||||
return "2026-07-05"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `pytest tests/data/test_scheduler.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add sanguo_data/scheduler.py tests/data/test_scheduler.py
|
||||
git commit -m "feat(data): UpdateScheduler 增量 + 断点续传 + 熔断"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: vnpy 4.4.0 接口 spike + 端到端冒烟
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/data/test_spike_vnpy44.py`
|
||||
- Modify: 按 spike 结果修正 Task 4/6 的 `get_database()` 调用
|
||||
|
||||
- [ ] **Step 1: 写 spike 测试(探查 vnpy 4.4.0 接口)**
|
||||
|
||||
```python
|
||||
# tests/data/test_spike_vnpy44.py
|
||||
"""Spike: 验证 vnpy 4.4.0 数据库接口。无 NAS 数据时可 skip。"""
|
||||
import datetime
|
||||
import pytest
|
||||
from vnpy.trader.database import get_database
|
||||
from vnpy.trader.constant import Interval, Exchange
|
||||
|
||||
def test_vnpy44_database_interface():
|
||||
db = get_database()
|
||||
assert hasattr(db, "load_bar_data")
|
||||
assert hasattr(db, "save_bar_data")
|
||||
bars = db.load_bar_data(
|
||||
symbol="600000", exchange=Exchange.SSE, interval=Interval.DAILY,
|
||||
start=datetime.datetime(2026, 1, 1), end=datetime.datetime(2026, 6, 30),
|
||||
)
|
||||
assert isinstance(bars, list)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 容器内运行 spike**
|
||||
|
||||
```bash
|
||||
docker exec sanguo_vnpy_v2 pytest tests/data/test_spike_vnpy44.py -v
|
||||
```
|
||||
Expected: PASS(接口符合假设)或 FAIL(4.4.0 接口变更 → 记录差异,修正 Task 4/6 调用)。
|
||||
|
||||
- [ ] **Step 3: 端到端冒烟**
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
from sanguo_data.config import load_config
|
||||
from sanguo_data.datareader import read_db_daily
|
||||
from sanguo_data.scheduler import run_daily_update
|
||||
cfg = load_config('config/data_platform.yaml')
|
||||
bars = read_db_daily('600000', '2026-01-01', '2026-06-30', cfg)
|
||||
print(f'读取 {len(bars)} 条')
|
||||
report = run_daily_update(cfg, symbols=['600000'])
|
||||
print(f'更新: {report.success} 成功, {report.failed} 失败')
|
||||
"
|
||||
```
|
||||
Expected: 读取 N 条 + 更新成功。**这是 Plan 1 最终验收。**
|
||||
|
||||
- [ ] **Step 4: spike 发现差异则修正 Task 4/6,重测**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/data/test_spike_vnpy44.py
|
||||
git commit -m "test(data): vnpy 4.4.0 接口 spike + 端到端冒烟"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review(写完内联检查)
|
||||
|
||||
**1. Spec coverage(对照 design §2 数据层):**
|
||||
- DataFeed(多源 fallback + BaoStock 超时)→ Task 5 ✅
|
||||
- Validator(7 条 fatal)→ Task 2 ✅
|
||||
- DataWriter/Reader(双存储)→ Task 3/4/6 ✅
|
||||
- UpdateScheduler(增量 + 断点续传 + 熔断)→ Task 7 ✅
|
||||
- 配置集中 YAML → Task 1 ✅
|
||||
- 早期 spike(vnpy 4.4.0 接口)→ Task 4 + Task 8 ✅
|
||||
- BaoStock 超时坑修复 → Task 5 ✅
|
||||
|
||||
**2. Placeholder 扫描:**
|
||||
- `_fetch_baostock_raw` / `_fetch_eastmoney` / `_fetch_tencent` / `_load_stock_list` 标 `NotImplementedError("copy from v1 ...")`——这是**有意的执行指引**(指明从 v1 哪个文件 copy),不是 plan 占位。执行 subagent 按 v1 `fallback.py`/`daily_all_update.py` copy。
|
||||
- 其余步骤均有完整代码/命令。
|
||||
|
||||
**3. 类型一致性:**
|
||||
- `DataConfig`(Task 1)在 Task 2-7 一致使用 ✅
|
||||
- `BarData` 与 `_row_to_bar`(Task 3)在 Task 4/6 复用 ✅
|
||||
- `guess_exchange`(Task 4)在 Task 6 间接复用 ✅
|
||||
- `fetch_daily` / `validate_daily` / `write_daily` 跨 Task 一致 ✅
|
||||
|
||||
**4. 风险:**
|
||||
- vnpy 4.4.0 `get_database()` / `load_bar_data` / `save_bar_data` 签名需 Task 8 spike 验证,若变更修正 Task 4/6。已在 plan 显式标注 spike 检查点。
|
||||
@@ -0,0 +1,221 @@
|
||||
# P0 数据补全实现计划(历史成份股 + ETF 全市场 + 退市 K 线)
|
||||
|
||||
> **For agentic workers:** 用 superpowers:subagent-driven-development 或 executing-plans 执行。Steps 用 `[ ]` 跟踪。
|
||||
|
||||
**Goal:** 补齐治幸存者偏差 + 策略核心缺口三类数据,落到 VPS 本地。
|
||||
|
||||
**Architecture:** 各源采集脚本 → staging parquet → 验证探针 → 合并主库;baostock 单登录守 48000/天;dbbardata 不动。
|
||||
|
||||
**Tech Stack:** python3.10 / akshare / baostock / xtquant(xtdata)/ pandas / pyarrow / sqlite3
|
||||
|
||||
---
|
||||
|
||||
## Global Constraints(所有 task 隐含)
|
||||
|
||||
- **baostock 单进程单登录**,不并发(防黑名单,日 ≤48000 query)
|
||||
- **直连不走代理**:`$env:http_proxy=''; $env:https_proxy=''; $env:all_proxy=''`
|
||||
- **dbbardata 不破坏**:只 INSERT OR REPLACE `daily_baostock_full` / 新表,不动 dbbardata 既有行
|
||||
- **优先 baostock + miniQMT(xtdata)**
|
||||
- **staging → 验证探针 → 合并主库**(用户铁律,不直接写主库)
|
||||
- Windows VPS 49.232.102.198,`C:\Python310\python.exe -X utf8`,schtasks `/ru SYSTEM`
|
||||
- 输出根:`C:\sanguo_vnpy_v2\data\`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 责任 |
|
||||
|---|---|
|
||||
| `scripts/data_platform/index_const_hist_download.py`(新) | 历史成份股采集(akshare 国证 + 新浪 + baostock 补时点) |
|
||||
| `scripts/data_platform/build_daily_from_xtdata.py`(改 :40) | ETF universe 扩展(一次性全量) |
|
||||
| `scripts/data_platform/daily_update_xtdata.py`(改 :114) | ETF 每日增量 universe |
|
||||
| `scripts/data_platform/baostock_delisted_download.py`(新) | 退市股列表 + K 线采集 |
|
||||
| `scripts/data_platform/import_delisted_to_db.py`(新) | 退市 K 线灌 `daily_baostock_full` |
|
||||
| 各 `*_wrapper.ps1` + schtask | 部署 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 历史成份股采集(治幸存者偏差)
|
||||
|
||||
**Files:** Create `scripts/data_platform/index_const_hist_download.py`;Output `data/index_const_hist/{code}.parquet`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: akshare `index_detail_hist_cni(symbol)` + `index_detail_hist_adjust_cni(symbol)`(国证源);新浪 `vII_HistoryComponent`(pandas.read_html, gb2312);baostock `query_hs300/zz500/sz50_stocks(date)`
|
||||
- Produces: `data/index_const_hist/{code}.parquet`(列:`updateDate/index_code/code/code_name/adjust_type`);并集 = 曾经入选集
|
||||
|
||||
**指数清单:**
|
||||
- 深证/国证(akshare 国证源):399001 / 399006 / 399101 / 399005 / 399330
|
||||
- 中证(新浪):000852(中证1000)/ 932000(中证2000)/ 000300(交叉校验)/ 000016(上证50)
|
||||
- baostock 已有(300/500/50 在 `bs_index_constituent`):Task1 补时点序列到同 schema
|
||||
|
||||
- [ ] **1.1 探针:akshare 国证源 hist 版**
|
||||
```python
|
||||
import akshare as ak
|
||||
df = ak.index_detail_hist_cni(symbol="399101") # 历史样本(日期/样本代码/权重)
|
||||
print(df.columns.tolist(), len(df), df.head(3))
|
||||
adj = ak.index_detail_hist_adjust_cni(symbol="399101") # 调样记录(调整类型 OLD/+/-)
|
||||
print(adj.columns.tolist(), len(adj))
|
||||
```
|
||||
预期:hist 有日期+样本+权重;adjust 有调整类型。**陷阱:必须 hist 版**(`index_detail_cni` 非 hist 版 2025-11-25 起只近期);`ak.index_stock_hist` 已下线别用。
|
||||
|
||||
- [ ] **1.2 探针:新浪中证历史成份**
|
||||
```python
|
||||
import pandas as pd
|
||||
url = "http://vip.stock.finance.sina.com.cn/corp/go.php/vII_HistoryComponent/indexid/000852.phtml"
|
||||
df = pd.read_html(url, encoding="gb2312")[0]
|
||||
print(df.columns.tolist(), len(df), df.head(3))
|
||||
```
|
||||
预期:品种代码/品种名称/纳入日期/剔除日期(空=至今在列),含 *ST/退市股。
|
||||
|
||||
- [ ] **1.3 实现 `index_const_hist_download.py`**:三路采集 → 统一 schema(`updateDate/index_code/code/code_name/adjust_type`)→ 写 `data/index_const_hist/{code}.parquet`。串行 `time.sleep(0.8)`(akshare/新浪防封),单进程。环境变量 `BS_INDEX_HIST_OUT_DIR` 覆盖默认 Mac 路径(同 Day1 wrapper 模式)。
|
||||
|
||||
- [ ] **1.4 验证探针**:每指数 parquet 行数 + 抽样 3 行;**幸存者偏差校验** = 并集 `distinct code` 数 > 当前成份股数(证明含被踢股,例如 399101 并集 > 958 当前)。
|
||||
|
||||
- [ ] **1.5 wrapper + schtask**:`index_const_hist_wrapper.ps1`(设 OUT_DIR + utf8 + unset proxy + log);schtask `sanguo-index-hist` `/sc monthly /mo 2`(半年度调样后,6/12 月)`/ru SYSTEM`。
|
||||
|
||||
- [ ] **1.6 commit**:`git add scripts/data_platform/index_const_hist_download.py scripts/data_platform/index_const_hist_wrapper.ps1 && git commit -m "feat(data): 历史成份股采集(治幸存者偏差,国证+新浪+baostock)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ETF 全市场日线
|
||||
|
||||
**Files:** Modify `scripts/data_platform/build_daily_from_xtdata.py:40` + `daily_update_xtdata.py:114`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: xtdata `get_stock_list_in_sector('沪深A股'/'沪深ETF'/'沪深基金')` + `get_market_data_ex(dividend_type='front')`
|
||||
- Produces: 全市场 ETF(~1000 只)日线**前复权**,落 parquet/dbbardata(复用现有 xtdata 管线)
|
||||
|
||||
- [ ] **2.1 探针:ETF universe + 1 只 K 线**
|
||||
```python
|
||||
from xtquant import xtdata as xd
|
||||
etf = xd.get_stock_list_in_sector('沪深ETF') or []
|
||||
fund = xd.get_stock_list_in_sector('沪深基金') or []
|
||||
a = xd.get_stock_list_in_sector('沪深A股') or []
|
||||
u = list(set(a + etf + fund))
|
||||
print(f"A={len(a)} ETF={len(etf)} fund={len(fund)} union={len(u)}")
|
||||
r = xd.get_market_data_ex([], ['510300.SH'], period='1d',
|
||||
start_time='20240101', end_time='20260721', dividend_type='front')
|
||||
df = r.get('510300.SH')
|
||||
print('510300 bars:', 0 if df is None else len(df), '| tail close:', None if df is None else df['close'].iloc[-1])
|
||||
```
|
||||
预期:ETF ~1000,union > A 股数;510300 前复权日线有值,close 非 NaN。
|
||||
|
||||
- [ ] **2.2 改 universe**:`build_daily_from_xtdata.py:40` 和 `daily_update_xtdata.py:114` 把
|
||||
```python
|
||||
u = xd.get_stock_list_in_sector("沪深A股") or []
|
||||
```
|
||||
改为
|
||||
```python
|
||||
u = list(set(
|
||||
(xd.get_stock_list_in_sector("沪深A股") or []) +
|
||||
(xd.get_stock_list_in_sector("沪深ETF") or []) +
|
||||
(xd.get_stock_list_in_sector("沪深基金") or [])
|
||||
))
|
||||
```
|
||||
保留 `dividend_type='front'`(前复权,§13 默认)。
|
||||
|
||||
- [ ] **2.3 全量下载 ETF**:跑改后的 `build_daily_from_xtdata.py`(走现有 xtdata 管线,**无限流**)→ parquet。
|
||||
|
||||
- [ ] **2.4 验证**:ETF 数 + 抽样(510300/513050/159919)+ 前复权 close 非 NaN + 日期范围。
|
||||
|
||||
- [ ] **2.5 schtask**:复用 `sanguo-daily-update`(universe 扩展后自动含 ETF,无需新 schtask)。
|
||||
|
||||
- [ ] **2.6 commit**:`git commit -m "feat(data): ETF 全市场日线(xtdata universe 扩展+前复权)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 退市股 K 线(反幸存者偏差核心)
|
||||
|
||||
**Files:** Create `scripts/data_platform/baostock_delisted_download.py` + `import_delisted_to_db.py`;Output → `daily_baostock_full`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: baostock `query_all_stock(day)` + `query_stock_basic(code)`(status + 退市日期)+ `query_history_k_data_plus(code, fields, adjustflag=3)`
|
||||
- Produces: 退市股 K 线 INSERT OR REPLACE `daily_baostock_full`(18 列,复用 `parse_baostock_code`)
|
||||
|
||||
**范围:** 近 5 年退市(退市日期 ≥ 2021;守 48000/天;退市股分天跑)
|
||||
|
||||
- [ ] **3.1 探针:退市股列表字段**
|
||||
```python
|
||||
import baostock as bs, pandas as pd
|
||||
bs.login()
|
||||
rs = bs.query_all_stock(day="2026-07-18")
|
||||
rows = []
|
||||
while (rs.error_code == '0') & rs.next():
|
||||
rows.append(rs.get_row_data())
|
||||
df = pd.DataFrame(rows, columns=rs.fields)
|
||||
print('query_all_stock fields:', rs.fields, '| rows:', len(df))
|
||||
rs2 = bs.query_stock_basic(code="sh.600000")
|
||||
b = []
|
||||
while (rs2.error_code == '0') & rs2.next():
|
||||
b.append(rs2.get_row_data())
|
||||
print('query_stock_basic fields:', rs2.fields, '| sample:', b[0] if b else None)
|
||||
bs.logout()
|
||||
```
|
||||
预期:`query_stock_basic` 含 `type`(1股)/`status`(1上市 0退市)/`outDate`(退市日期)。筛 `status=0 & outDate>='2021-01-01'`。
|
||||
|
||||
- [ ] **3.2 实现 `baostock_delisted_download.py`**:
|
||||
- 遍历全 code(或 `query_all_stock` 多日并集)→ `query_stock_basic` 筛 `status=0 & outDate>='2021-01-01'` → 退市股列表
|
||||
- 逐只 `query_history_k_data_plus(code, start_date='1990-01-01', end_date=outDate, fields=18字段, adjustflag=3)` → staging `data/delisted_kline/{code}.parquet`
|
||||
- 单进程单登录,`time.sleep` 守预算,marker 断点续传(复用 Day1 模板),DAILY_LIMIT 计数器
|
||||
|
||||
- [ ] **3.3 `import_delisted_to_db.py`**:staging → INSERT OR REPLACE `daily_baostock_full`(复用 `parse_baostock_code` sh.600000→600000+SH + `executemany`,WAL + busy_timeout=60000,同 `import_baostock_to_db.py`)。**dbbardata 不碰**。
|
||||
|
||||
- [ ] **3.4 验证探针**:退市股数 + 抽样(某退市股 K 线行数 + max(date) ≤ 退市日)+ `daily_baostock_full` 行数增量 + distinct symbol 增量。
|
||||
|
||||
- [ ] **3.5 wrapper + schtask**:`baostock_delisted_wrapper.ps1`;schtask `sanguo-delisted` `/sc monthly /ru SYSTEM`(月度,守 48000,错开 day2b 02:00 + bs-daily-increment 17:00)。
|
||||
|
||||
- [ ] **3.6 commit**:`git commit -m "feat(data): 退市股 K 线采集(baostock,反幸存者偏差)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: baostock 日增量 → daily_baostock_full(#7 daily_update_static)
|
||||
|
||||
> **串行约束**:本 task 与 Task3 都用 baostock 长会话,**必须串行**(Task3 probe → Task3 执行 → Task4),不可并发(防黑名单)。
|
||||
|
||||
**Files:** Create `scripts/data_platform/daily_update_static.py` + `daily_update_static_wrapper.ps1`
|
||||
|
||||
**背景:** 现有 `daily_update_xtdata.py` 只产 parquet 不灌 `daily_baostock_full`(已知 gap,memory `db-primary-parquet-fallback` 记录)。本 task 补 baostock 日线的**每日增量灌库**。
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: baostock `query_stock_basic`(全 A,type=1 含退市,复用 `baostock_daily_fullmarket_download.py:fetch_all_stocks`)+ `query_history_k_data_plus`(LOOKBACK 窗口,adjustflag=3 raw,18 字段同 `BS_FIELDS`)
|
||||
- Produces: staging `data/daily_baostock_increment/{YYYYMMDD}/{code}.{exc}_daily.parquet`(审计)→ 同进程 INSERT OR REPLACE `daily_baostock_full`(复用 `parse_baostock_code`+executemany+WAL+busy_timeout,同 `import_baostock_to_db.py`)
|
||||
|
||||
**设计(LOOKBACK 窗口 + 幂等,不同于全量 marker 模式):**
|
||||
- **不用 marker 断点续传**(全量才需要;增量每日全量重拉最近 N 天)
|
||||
- `LOOKBACK_DAYS=7`(覆盖周末/节假日;baostock 日终更新,17:00 跑时当日 bar 已就绪)
|
||||
- 每只 1 query → 5537 query/run ≪ 48000/天 ✅(留足余量给 day2b/Task3)
|
||||
- `sleep 0.4s × 5537 ≈ 37min`(17:00 schtask 可接受)
|
||||
- `QUERY_COUNT` 计数器 + `DAILY_LIMIT=40000` 防御(复用全量脚本模式)
|
||||
- **一脚本贯通**:download LOOKBACK → staging parquet(审计)→ in-memory df → executemany INSERT OR REPLACE(幂等,重复跑同一天安全,`drop_duplicates keep last` 不需要因 PK+OR REPLACE 天然去重)
|
||||
|
||||
**Steps:**
|
||||
- [ ] **4.1 探针(可选,Day1 已实证 query_history_k_data_plus 可用)**:ssh VPS 跑 1 只近 7 天确认接口 + 当日 bar 就绪
|
||||
- [ ] **4.2 写 `daily_update_static.py`**:自包含,结构
|
||||
- `unset proxy` + `socket.setdefaulttimeout(30)`(同全量脚本,baostock 坑)
|
||||
- `_login_once`/`_relogin`/`fetch_all_stocks`/`fetch_one_daily`/`parse_baostock_code` 复用(可 import 或复制;优先 from `baostock_daily_fullmarket_download import ...`,注意 `QUERY_COUNT` global 需在同进程)
|
||||
- `LOOKBACK` 窗口:`start=today-7, end=today`
|
||||
- 主循环:逐只 `fetch_one_daily` → staging parquet → 累积 df → 每 100 只 `executemany INSERT OR REPLACE`(WAL+busy_timeout=60000)
|
||||
- `QUERY_COUNT`/`DAILY_LIMIT`/断路器/定期重登 复用
|
||||
- 结束 verify:抽样 3 只 `max(date) ≈ today`、当日新增行数
|
||||
- 环境变量 `BS_INCREMENT_OUT_DIR`/`DB_PATH` 覆盖默认(同 Day1 wrapper 模式适配 Win)
|
||||
- [ ] **4.3 小样本**:`--limit 10` 跑 10 只,确认 staging 有行 + DB 抽样 max(date)≈today
|
||||
- [ ] **4.4 全量跑**:5537 只,守预算
|
||||
- [ ] **4.5 wrapper + schtask**:`daily_update_static_wrapper.ps1`(unset proxy+utf8+OUT_DIR+log);schtask `sanguo-bs-daily-increment` `/sc daily /st 17:00 /ru SYSTEM`(错开 daily-update 16:30 + day2b 02:00 + Task3 月度)
|
||||
- [ ] **4.6 commit**:`git commit -m "feat(data): baostock 日增量灌库 daily_update_static(#7 gap 补)"`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec 覆盖**:Task1→spec §4 成份股行 + §8 P0.1;Task2→§4 ETF 行 + §8 P0.2;Task3→§4 退市行 + §8 P0.3 ✅
|
||||
- **Placeholder 扫描**:无 TBD/TODO;采集脚本给接口+探针+schema,实现者按骨架写完整(采集脚本完整代码由执行 agent 基于 接口/schema/陷阱 产出)✅
|
||||
- **类型一致**:`index_const_hist` schema 各源统一;`daily_baostock_full` 18 列复用 `import_baostock_to_db.py` 的 `parse_baostock_code`+executemany ✅
|
||||
- **陷阱纳入**:`ak.index_stock_hist` 下线(1.1 标注)/ csindex SPA 无历史(用国证+新浪)/ 新浪 gb2312(1.2)/ hist 版必须(1.1)✅
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
计划存 `docs/superpowers/plans/2026-07-21-data-fusion-p0.md`。执行方式:
|
||||
1. **Subagent-Driven**(推荐):每 Task 派 fresh agent + task 间 review
|
||||
2. **Inline**:本 session 批量执行 + checkpoint
|
||||
@@ -0,0 +1,106 @@
|
||||
# 数据架构方案A迁移 + schtask 改造 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans。Steps use checkbox。
|
||||
|
||||
**Goal:** 落地 spec §14 方案A定稿 — DB 唯一表、每类数据唯一权威源、4 个新 schtask、迁移 5 单元,全程备份+staging+可回滚+审计。
|
||||
|
||||
**Architecture:** 以本地 DB 迁移为主(`daily_baostock_full`→dbbardata/parquet,无网络),schtask 改造(废弃旧 4 个新建 4 个)。每单元独立可回滚,按风险升序。
|
||||
|
||||
**Tech Stack:** Python3.10 / sqlite3(WAL) / pandas parquet / Windows schtasks / baostock+xtata+akshare
|
||||
|
||||
## Global Constraints
|
||||
- baostock:单进程单登录,`DAILY_LIMIT=48000`,sleep 限速,login 探针 graceful skip,直连不走代理(unset proxy)
|
||||
- xtata:单进程 download 不并发,无限流
|
||||
- akshare:interval 4s 单线程,防东财封 IP
|
||||
- 每迁移单元前:`sqlite3 .backup` 全库 + rsync 到 NAS `/volume1/stock/backup/` + WAL checkpoint
|
||||
- 每单元:staging 隔离 → 验证探针 → 用户确认合并 → 旧 rename `_old` 保留 7 天
|
||||
- 全程 nohup + 审计日志 `data/migration_logs/<unit>_<ts>.log`
|
||||
- 不破坏 vnpy 回测:dbbardata schema 不动(只灌数据),`dbbardata` 12 列保持
|
||||
|
||||
## 文件结构
|
||||
- 迁移脚本:`scripts/data_platform/migrate_*.py`(每单元一个)
|
||||
- 验证脚本:`scripts/data_platform/verify_*.py`
|
||||
- schtask wrapper:`scripts/data_platform/*_wrapper.ps1`
|
||||
- 审计日志:`data/migration_logs/`
|
||||
|
||||
---
|
||||
|
||||
## 前置 Task 0:全库备份(所有单元前必做)
|
||||
**Files:** `scripts/data_platform/backup_db.py`(新建,可复用)
|
||||
- [ ] 写脚本:`sqlite3 .backup` → `quant_trading.db.bak_<YYYYMMDD>`(在线一致);WAL checkpoint;rsync 到 NAS
|
||||
- [ ] 执行
|
||||
- [ ] **verify**:`.bak` 存在 + 大小≈28GB + `PRAGMA integrity_check` ok
|
||||
|
||||
---
|
||||
|
||||
## 单元 1:存量垃圾清理(零风险)
|
||||
**Files:** `scripts/data_platform/cleanup_staging.py`(新建)
|
||||
- [ ] 写脚本:`--dry-run` 先列清单 → 删 `_staging_xtdata/`(14万)、`_xtdata.tar`(1.4G);移 `cta_*/dbg_*/smoke_*/trace_*` → `backtest_files/`
|
||||
- [ ] dry-run 输出清单给用户确认
|
||||
- [ ] 执行删除/移动
|
||||
- [ ] **verify**:`data/` 根目录无散落 json/log;`backtest_files/` 收纳;`du -sh data/` 体积下降
|
||||
- [ ] **回滚**:staging 可由 `build_daily_from_xtdata` 重建(已合并到 qfq/raw)
|
||||
|
||||
---
|
||||
|
||||
## 单元 2:config 统一 VPS 路径
|
||||
**Files:** `config/data_platform.yaml`(VPS 实例)
|
||||
- [ ] 核实 VPS 实际 config 路径(当前仓库版指 NAS /volume1,是容器版遗留)
|
||||
- [ ] `daily_dir/raw_dir/qfq_dir/minute_15_dir` → `C:\sanguo_vnpy_v2\data\...`
|
||||
- [ ] `daily_dir` 统一指 qfq(消除 `daily/` vs `qfq/` 分叉,`daily/`68文件归档)
|
||||
- [ ] NAS config 保留 + 注释"备份用"
|
||||
- [ ] **verify**:`datareader.read_parquet_daily` 抽样能读 + LocalParquetProvider 抽样
|
||||
- [ ] **回滚**:yaml 改回
|
||||
|
||||
---
|
||||
|
||||
## 单元 3:成份股合并 → `constituent_unified`
|
||||
**Files:** `scripts/data_platform/migrate_constituent.py` + `verify_constituent.py`
|
||||
**Interfaces:** 读 `bs_index_constituent`(baostock 300/500/50)+ `data/index_const_hist/*_union.parquet`(akshare cni 深证);写 `constituent_unified(date,index_code,code,code_name,source)`
|
||||
- [ ] 写迁移脚本:按指数代码去重(300/500/50=baostock;深证 399xxx=akshare cni union;新浪 300/50 作校验丢弃);schema 映射 INSERT
|
||||
- [ ] staging:先写 `constituent_unified_staging`
|
||||
- [ ] **verify**:行数 / 指数覆盖 / 抽样某指数某日成份集 vs 源一致 / 无同指数同日重复
|
||||
- [ ] 合并:rename staging → `constituent_unified`;`bs_index_constituent` → `_old`
|
||||
- [ ] 7 天后删 `_old`
|
||||
- [ ] **回滚**:rename `bs_index_constituent_old` 回来
|
||||
|
||||
---
|
||||
|
||||
## 单元 4:`daily_baostock_full` 拆分(本地 DB 迁移,无网络)
|
||||
**Files:** `scripts/data_platform/migrate_daily_baostock.py` + `verify_daily_migration.py`
|
||||
**Interfaces:** 读 `daily_baostock_full`(含退市);写 `dbbardata('d')`(OHLCV 12 列)+ `data/valuation_baostock/<year>.parquet`
|
||||
- [ ] 写迁移脚本:
|
||||
- OHLCV:`daily_baostock_full` → dbbardata INSERT OR REPLACE(interval='d',exchange SH/SZ→SSE/SZSE,datetime=date)。含退市(治偏差)。ETF 不碰(已在 dbbardata)
|
||||
- pe/pb:按年 group → `valuation_baostock/<year>.parquet` 宽表
|
||||
- [ ] staging:先写 `dbbardata_staging_daily` 表 + parquet staging 目录,不动 dbbardata
|
||||
- [ ] **verify**:
|
||||
- 退市股(000005 等)在 dbbardata('d') 有了(治偏差验证)
|
||||
- 在市股(600519)日线行数 / 抽样价格 vs daily_baostock_full 一致
|
||||
- pe/pb parquet 按年覆盖 + 抽样值合理
|
||||
- dbbardata 总行数变化合理(+退市日线)
|
||||
- [ ] 合并:staging → dbbardata;`daily_baostock_full` → `_old`;valuation parquet → 正式目录
|
||||
- [ ] 7 天后删 `_old`
|
||||
- [ ] **回滚**:`daily_baostock_full_old` 还原 + dbbardata 从 `.bak` 恢复
|
||||
|
||||
---
|
||||
|
||||
## 单元 5:schtask 改造(废弃旧 4 个,新建 4 个)
|
||||
**Files:** `scripts/data_platform/bs_eod.py`(日线+15min+pe/pb 拆)+ `xt_eod.py`(ETF+实时)+ `*_wrapper.ps1`
|
||||
- [ ] 写 `bs_eod.py`:基于 `daily_update_static.py` 扩展,+15min 增量,+pe/pb 拆 parquet;落 dbbardata('d'/'15m');`DAILY_LIMIT=48000`
|
||||
- [ ] 写 `xt_eod.py`:基于 `daily_update_xtdata.py`,universe 收窄 ETF/基金 + 个股当天实时;落 dbbardata('d')
|
||||
- [ ] **verify**:`--limit 10` 小样本跑通 + 数据到当天
|
||||
- [ ] 部署 schtask:废弃 `sanguo-daily-update`/`sanguo-bs-daily-increment`/`sanguo-index-hist`;新建 `sanguo-bs-eod`(18:05)/`sanguo-xt-eod`(18:40);`sanguo-bs-akshare` 调到 19:00;`sanguo-index`(月度 19:50)
|
||||
- [ ] **verify**:`schtasks /query` + 首日运行结果码 + 数据抽查到当天
|
||||
- [ ] **回滚**:重新注册旧 schtask
|
||||
|
||||
---
|
||||
|
||||
## 收尾:E2E 验证
|
||||
- [ ] 回测 all_weather 一轮(读 dbbardata 日线含退市 + valuation parquet + constituent_unified)无回归
|
||||
- [ ] LocalParquetProvider 接 constituent_unified + valuation_baostock 单测
|
||||
- [ ] 更新 memory:`data-fusion-design-finalized`(标方案A落地)+ 新建 `data-arch-migration-done`
|
||||
|
||||
## 执行节奏
|
||||
- 每单元独立提交 + 用户 review staging 再合并(单元 4/5 关键)
|
||||
- 全程 VPS nohup 跑(Mac Mini 防休眠 caffeinate,长迁移)
|
||||
- 顺序:0 → 1 → 2 → 3 → 4 → 5 → 收尾(严格风险升序)
|
||||
@@ -0,0 +1,168 @@
|
||||
# akshare 低频任务 schtask 部署 Plan (spec §14.5)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans。本 plan 自包含(实测现状+脚本能力+VPS访问),fresh agent 可直接执行。
|
||||
|
||||
**Goal:** 部署 spec §14.5 akshare/index 低频 schtask — A 三表/估值增量 + B 成份股月度 + C 事件类 7 种。方案A 数据层(`dbbardata`/`constituent_unified`/`valuation_baostock`)的使用层配套,补全 `LocalUnifiedProvider` 依赖的静态数据源。
|
||||
|
||||
**Architecture:** 复用 `akshare_static_download.py`(16类/marker断点/4模式)+ `baostock_constituent_download.py`;改 `merge_constituent.py`/`migrate_constituent.py` 可重跑;按 akshare per-stock 全量慢 + 东财限流,拆多 schtask(日频/季频/月频)。
|
||||
|
||||
**Tech Stack:** Python 3.10, akshare, baostock, sqlite3, Windows schtasks
|
||||
|
||||
## Global Constraints(铁律)
|
||||
|
||||
- **akshare**: 单线程 0.8s sleep / 30s 超时 / 断路器(连30 failed exit) / marker 断点 / **unset proxy** — `akshare_static_download.py` 已内置;东财限流严,**per-stock 全量慢,夜间跑**
|
||||
- **baostock**: 单进程单登录 48000/天,不并发(防黑名单)
|
||||
- **staging→验证→合并**(成份股 B,用户铁律:下载质量不可控,不直接写主库)
|
||||
- **VPS**: ssh alias = `49.232.102.198`(IP 即 alias,User Administrator,key id_ed25519);`C:\Python310\python.exe -X utf8`;schtasks `/create /ru SYSTEM /rl HIGHEST /sc daily|monthly`;Windows ssh 引号地狱 → 脚本 scp + ssh python 跑最稳
|
||||
- **dbbardata UNIQUE 不破坏**;constituent_unified 治偏差
|
||||
- 直连不走代理(schtask wrapper 开头 `unset http_proxy https_proxy all_proxy`)
|
||||
|
||||
## 实测现状(2026-07-23 probe_akshare_status.py)
|
||||
|
||||
- **static/**(`C:\sanguo_vnpy_v2\data\static\`): balance/income/cashflow/valuation/financial_abstract 各 **5530 parquet,停 2026-07-22 18:25**(sanguo-bs-akshare disabled 前最后一次);provider fundamentals 依赖,要续更
|
||||
- **events/**: **全 MISSING**(龙虎榜/北向/两融/解禁/大宗/可转债/研报从未采集)
|
||||
- **constituent_unified**: 7110 行 = baostock 2938(000016:195/000300:940/000905:1803) + akshare_cni 1172(深证 399001:702/399005:145/399006:175/399330:150) + akshare_csindex 3000(000852:1000/932000:2000);**静态,月度更新 schtask 无**
|
||||
- **schtask 状态**: sanguo-bs-akshare / sanguo-index / sanguo-index-hist **全无**(方案A `stop_all_data_schtasks.ps1` 清了)
|
||||
- **akshare_static_download.py 16 类分 5 组**:
|
||||
- PER_STOCK(5500股循环,unit=`{symbol}_{type}`): valuation/northbound/share_capital/balance/income/cashflow/financial_abstract
|
||||
- TOP_HOLDERS(per-stock×period): top_holders
|
||||
- PER_DATE(每交易日,unit=`{date}_{type}`): dragon_tiger/block_trade/margin_sse/restricted
|
||||
- PER_PERIOD(报告期,unit=`{period}_{type}`): forecast/express
|
||||
- ONE_SHOT: index_const/industry
|
||||
- marker 是 **symbol 级**(非 period 级)→ 三表/估值要更新新数据必须 `--force`(否则 marker 跳过永不更新)
|
||||
- **merge_constituent.py 不可重跑**: `ALTER TABLE bs_index_constituent RENAME TO bs_index_constituent_old` 只能一次(_old 已存在);无 DROP/REPLACE constituent_unified
|
||||
- **existing wrappers**(参考模式): `bs_eod_wrapper.ps1` / `xt_eod_wrapper.ps1`(unset proxy + log + 调 python);`register_schtasks.ps1`(schtasks /create 模板)
|
||||
|
||||
## schtask 清单(方案A §14.5 适配,按频率拆)
|
||||
|
||||
| schtask | 频率 | 时间 | 脚本 | 内容 |
|
||||
|---|---|---|---|---|
|
||||
| `sanguo-ak-eod` | daily | 19:00 | ak_eod_wrapper.ps1 | valuation + financial_abstract `--force`(日频,5500×2×0.8s≈2.2h 夜间) |
|
||||
| `sanguo-ak-quarter` | monthly(财报季 5/9/11 月+年报4月) | 周末 02:00 | ak_quarter_wrapper.ps1 | balance + income + cashflow + forecast + express `--force`(季频,5500×3×0.8≈2.2h) |
|
||||
| `sanguo-ak-events` | daily | 19:30 | ak_events_wrapper.ps1 | dragon_tiger + block_trade + margin_sse + restricted `--start today --end today`(per-date 日频,4 unit 快) |
|
||||
| `sanguo-ak-stock` | weekly | 周六 03:00 | ak_stock_wrapper.ps1 | northbound + share_capital + top_holders(per-stock 慢,周频) |
|
||||
| `sanguo-index` | monthly | 19:50 | index_monthly_wrapper.ps1 | 成份股 3 源 + merge(B) |
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify:** `scripts/data_platform/merge_constituent.py`(可重跑: DROP/REPLACE 替代 RENAME)
|
||||
- **Modify:** `scripts/data_platform/migrate_constituent.py`(可重跑: staging 隔离 + DROP staging 重建)
|
||||
- **Create:** `scripts/data_platform/ak_eod_wrapper.ps1` / `ak_quarter_wrapper.ps1` / `ak_events_wrapper.ps1` / `ak_stock_wrapper.ps1`(4 个 akshare wrapper)
|
||||
- **Create:** `scripts/data_platform/index_monthly_wrapper.ps1`(B 成份股 3 源编排)
|
||||
- **Create:** `scripts/data_platform/register_akshare_schtasks.ps1`(注册 5 schtask)
|
||||
- **Create:** `scripts/data_platform/verify_akshare_e2e.py`(验证全部)
|
||||
- **Test:** `tests/portfolio/test_merge_constituent_rerun.py`(B 改造 TDD)
|
||||
|
||||
---
|
||||
|
||||
## Task A: 三表/估值增量(2 schtask)
|
||||
|
||||
**Files:** Create 4 akshare wrapper + register; 复用 `akshare_static_download.py`(不改)。
|
||||
|
||||
- [ ] **A1: ak_eod_wrapper.ps1**(日频估值/财务摘要)
|
||||
```powershell
|
||||
# unset proxy + 调 akshare_static_download.py --types valuation,financial_abstract --force
|
||||
$env:http_proxy=""; $env:https_proxy=""; $env:all_proxy=""
|
||||
cd C:\sanguo_vnpy_v2
|
||||
C:\Python310\python.exe -X utf8 scripts\data_platform\akshare_static_download.py `
|
||||
--types valuation,financial_abstract --force `
|
||||
*>> C:\sanguo_vnpy_v2\data\ak_eod.log
|
||||
```
|
||||
- [ ] **A2: ak_quarter_wrapper.ps1**(季频三表+预告/快报,财报季)— 同上 `--types balance,income,cashflow,forecast,express --force`
|
||||
- [ ] **A3: 验证 A** — scp wrapper + 手动跑 `--limit 5` 确认 valuation parquet 更新今日:
|
||||
```bash
|
||||
scp scripts/data_platform/ak_eod_wrapper.ps1 49.232.102.198:'C:/sanguo_vnpy_v2/scripts/data_platform/'
|
||||
ssh 49.232.102.198 'cd /d C:\sanguo_vnpy_v2 && C:\Python310\python.exe -X utf8 scripts\data_platform\akshare_static_download.py --types valuation --force --limit 3'
|
||||
# 验 static/valuation/<code>_valuation.parquet mtime = 今日
|
||||
```
|
||||
- [ ] **A4: 注册 schtask**(register_akshare_schtasks.ps1 含 sanguo-ak-eod daily 19:00 + sanguo-ak-quarter monthly)
|
||||
|
||||
---
|
||||
|
||||
## Task B: 成份股月度(sanguo-index)— 代码改造 TDD
|
||||
|
||||
**Files:** Modify `merge_constituent.py` + `migrate_constituent.py`; Create `index_monthly_wrapper.ps1`; Test `test_merge_constituent_rerun.py`。
|
||||
|
||||
- [ ] **B1: 写失败测试 — merge_constituent 可重跑**
|
||||
```python
|
||||
# tests/portfolio/test_merge_constituent_rerun.py
|
||||
def test_merge_constituent_rerun_twice(tmp_path):
|
||||
"""merge_constituent 跑两次不崩(第二次 DROP 重建,不 RENAME 已 _old 的表)。"""
|
||||
db = tmp_path / "t.db"; c = sqlite3.connect(str(db))
|
||||
# 造 constituent_unified + bs_index_constituent_old(已存在,_old 状态)
|
||||
c.execute("CREATE TABLE constituent_unified(index_code,code,source,in_current,was_removed)")
|
||||
c.execute("CREATE TABLE constituent_unified_staging(index_code,code,source,in_current,was_removed)")
|
||||
c.execute("CREATE TABLE bs_index_constituent_old(code,date)") # _old 已存在
|
||||
c.executemany("INSERT INTO constituent_unified_staging VALUES(?,?,?,?,?)",
|
||||
[("000300","600519","baostock",1,0)])
|
||||
c.commit(); c.close()
|
||||
# 跑两次
|
||||
import scripts.data_platform.merge_constituent as m # 或函数级 import
|
||||
m.merge(str(db)) # 第一次: staging→unified(DROP 旧 unified 重建)
|
||||
m.merge(str(db)) # 第二次: 不崩, unified 仍 1 行
|
||||
c = sqlite3.connect(str(db))
|
||||
assert c.execute("SELECT COUNT(*) FROM constituent_unified").fetchone()[0] == 1
|
||||
```
|
||||
|
||||
- [ ] **B2: 改 merge_constituent.py 可重跑** — 把 `ALTER TABLE bs_index_constituent RENAME TO _old`(只能一次)改为:staging→`DROP TABLE IF EXISTS constituent_unified`→`CREATE constituent_unified AS SELECT FROM staging`。bs_index_constituent_old 已存在不碰。幂等。
|
||||
|
||||
- [ ] **B3: 改 migrate_constituent.py 可重跑** — staging 表 `DROP IF EXISTS constituent_unified_staging` 重建(每次重新聚合 baostock 988 时点 + akshare cni union + csindex),不依赖上次状态。
|
||||
|
||||
- [ ] **B4: index_monthly_wrapper.ps1**(3 源编排)
|
||||
```powershell
|
||||
$env:http_proxy=""; $env:https_proxy=""; $env:all_proxy=""
|
||||
cd C:\sanguo_vnpy_v2
|
||||
# 1. baostock 300/500/50 最新快照(单进程)
|
||||
C:\Python310\python.exe -X utf8 scripts\data_platform\baostock_constituent_download.py --start 2026-01-01
|
||||
# 2. akshare cni 深证 + csindex 中证(复用 P0 脚本 index_const_hist_download 或 akshare_static_download --types index_const)
|
||||
C:\Python310\python.exe -X utf8 scripts\data_platform\akshare_constituent_download.py
|
||||
# 3. migrate + merge(可重跑版)
|
||||
C:\Python310\python.exe -X utf8 scripts\data_platform\migrate_constituent.py
|
||||
C:\Python310\python.exe -X utf8 scripts\data_platform\merge_constituent.py
|
||||
```
|
||||
(注:akshare_constituent_download.py 若不存在,从 `akshare_static_download.py --types index_const` 或 P0 的 `index_const_hist_download.py` 复用;执行 agent 确认现有脚本)
|
||||
|
||||
- [ ] **B5: 验证 B** — 手动跑 wrapper,确认 `constituent_unified` 行数 ≥ 7110,source 含 baostock/akshare_cni/akshare_csindex,跑两次不崩。
|
||||
- [ ] **B6: 注册 sanguo-index monthly 19:50**
|
||||
|
||||
---
|
||||
|
||||
## Task C: 事件类(per-date 日频 + per-stock 周频)
|
||||
|
||||
**Files:** Create `ak_events_wrapper.ps1` + `ak_stock_wrapper.ps1`(已在 A 的 register 注册)。
|
||||
|
||||
- [ ] **C1: ak_events_wrapper.ps1**(per-date 日频)— `--types dragon_tiger,block_trade,margin_sse,restricted --start {today} --end {today}`。每日 4 类×1 unit,快。落 `events/{type}/{date}_{type}.parquet`。
|
||||
- [ ] **C2: ak_stock_wrapper.ps1**(per-stock 周频慢)— `--types northbound,share_capital,top_holders --force`。5500×3 慢,周六 03:00。
|
||||
- [ ] **C3: 可转债/研报**(spec §14.2 列但 akshare_static_download.py 16 类无对应 fetcher) — **评估**:若 akshare 有 `bond_zh_hs_cov_min`/`stock_research_info_em` 接口,加 fetcher;否则 N/A 标注使用说明。执行 agent 确认 akshare 接口可用性,不可用则跳过并在 verify 标注。
|
||||
- [ ] **C4: 验证 C** — 手动跑 events wrapper `--start today --end today`,确认 `events/dragon_tiger/{today}_dragon_tiger.parquet` 生成。
|
||||
|
||||
---
|
||||
|
||||
## Task D: 注册 + E2E
|
||||
|
||||
- [ ] **D1: register_akshare_schtasks.ps1** — 注册 5 schtask(sanguo-ak-eod/ak-quarter/ak-events/ak-stock/index),`/create /ru SYSTEM /rl HIGHEST`,verify 段 `schtasks /query` 确认 Status=Ready。
|
||||
- [ ] **D2: verify_akshare_e2e.py** — 验证全部:
|
||||
- static/valuation 最新 mtime = 近日
|
||||
- events/dragon_tiger 有 parquet
|
||||
- constituent_unified ≥ 7110 + 3 source
|
||||
- [ ] **D3: 更新 memory + 使用说明** — `data-fusion-design-finalized` 的"剩余待办 akshare schtask"标完成;`docs/portfolio_local_unified_provider.md` 事件类从 N/A 更新。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **spec §14.5 覆盖**: sanguo-bs-akshare(三表+事件)→ 拆 ak-eod/ak-quarter/ak-events/ak-stock(频率适配);sanguo-index 月度 → B。✓
|
||||
2. **provider 依赖**: valuation/balance/income/cashflow/financial_abstract(LocalUnifiedProvider.get_fundamentals_df)→ A 覆盖。✓
|
||||
3. **限流现实**: per-stock --force 全量慢,日频 valuation 2.2h / 季频三表 2.2h,夜间 + 周末 schtask。events per-date 日频快。✓
|
||||
4. **B 可重跑**: merge DROP 重建幂等,migrate staging 隔离。TDD 跑两次不崩。✓
|
||||
5. **风险**: akshare 东财封 IP(per-stock 全量)→ wrapper 内置断路器 + sleep;财报季触发 ak-quarter(`/sc monthly` 指定月或手动);可转债/研报接口待确认(C3)。
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan saved to `docs/superpowers/plans/2026-07-23-akshare-low-freq-schtask.md`。compact 后新 session 派 Sub Agent 执行(参考 LocalUnifiedProvider 模式:Task A→B→C→D,每 task 验证 + commit)。
|
||||
|
||||
## 关联文档/memory
|
||||
|
||||
- spec: `docs/superpowers/specs/2026-07-21-data-source-fusion-design.md` §14.5
|
||||
- memory: `data-fusion-design-finalized`(方案A)/ `local-unified-provider-complete`(使用层)/ `vps-local-data-layout` / `baostock-concurrent-blacklist` / `schtasks-system-bat-gotchas`
|
||||
- VPS 访问: ssh `49.232.102.198`,见 memory `windows-vps-access`
|
||||
@@ -0,0 +1,191 @@
|
||||
# 中证1000/2000 历史成份股补全 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans。Steps 用 checkbox `- [ ]` 跟踪。
|
||||
|
||||
**Goal:** 把中证1000(000852)/中证2000(932000)从"纯当前快照"补成"治幸存者偏差的全集"(含被踢出的股票),接入 `constituent_unified`,并部署定期更新 schtask。
|
||||
|
||||
**Architecture:** csindex 官方公告 JSON 接口(`queryAnnouncementByVo` + `queryAnnouncementById`)抓调整公告 → 解析附件 PDF/xlsx 的调入/调出名单 → 聚合成"曾经入选集"(全集型,非时点型)→ 入 `constituent_unified`,`in_current`=当前快照、`was_removed`=曾经入选−当前。
|
||||
|
||||
**Tech Stack:** Python3 + pandas + openpyxl + pdfplumber + sqlite3 + PowerShell schtask
|
||||
|
||||
## 诊断(已实证,2026-07-23)
|
||||
|
||||
现状 `constituent_unified`(VPS quant_trading.db):
|
||||
- `000852`: total=1000, in_current=1000, **was_removed=0**(纯快照,未治偏差)
|
||||
- `932000`: total=2000, in_current=2000, **was_removed=0**(纯快照)
|
||||
- 对比 `000300`: total=940, in_current=300, was_removed=640(已治偏差)
|
||||
|
||||
**三处断点:**
|
||||
1. **932000 launch xlsx 解析 bug**:`parse_csindex_announce.py:529` 取 `row[0]`(=指数代码 932000),应为 `row[3]`(证券代码)。→ 产出 distinct=1(2000 行全是 932000)。xlsx 实证 6 列:`指数代码/指数简称/指数英文简称/证券代码/证券中文简称/证券英文名称`。
|
||||
2. **000852 公告覆盖不全**:`filter_csi1000_notices`(162 行)用 `theme='指数调样'+title 含'中证1000'` 过滤,只拿 28 份(2018-07 起)。调查实证:列表 API payload 加 `indexCode:'000852'` 能拿 **96 条**(45 调样),可回溯到 **2014 发布期**(早期 HTML 表格,2018+ PDF/xlsx)。
|
||||
3. **migrate 没接 announce_union**:`migrate_constituent.py:118-131` 只读 `_snapshot.parquet`,没读 `_announce_union.parquet`。→ 1220 个治偏差集白产了。路径也对不上(parse 在 Mac 产 announce_union,migrate 读 VPS HIST,没同步)。
|
||||
|
||||
## 关键简化
|
||||
|
||||
`constituent_unified` 是**全集型**(300/500/50 = baostock 988 时点聚合成 in_current/was_removed),**不是时点型**。所以:
|
||||
- **不需要**反向回溯引擎(生效日边界、逐时点 asof join)
|
||||
- 只要"曾经入选集"= 所有公告 add 记录 ∪ initial ∪ current 的 distinct code
|
||||
- `in_current` = akshare 当前快照(权威),`was_removed` = 曾经入选 − 当前
|
||||
|
||||
调查 agent 提的"生效日≠公告日"等坑是**时点型**需求才需要,本计划(全集型)不涉及。
|
||||
|
||||
## Global Constraints(spec 铁律)
|
||||
- baostock 单进程单登录不并发(本计划不碰 baostock,无冲突)
|
||||
- 直连不走代理:`unset http_proxy https_proxy all_proxy`(脚本已内置)
|
||||
- 单线程限速:csindex 接口 sleep 1.0~1.5s
|
||||
- staging→验证→合并,不直接写主库(migrate 走 staging→merge 两步,已幂等)
|
||||
- provider 读 VPS 本地,不调 online(本计划是采集层,可调 csindex)
|
||||
- commit message 无 Co-Authored-By
|
||||
|
||||
---
|
||||
|
||||
### Task 1(#30):修 parse_csindex_announce.py 两处
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/data_platform/parse_csindex_announce.py:526-538`(932000 launch xlsx 列索引)
|
||||
- Modify: `scripts/data_platform/parse_csindex_announce.py:120-182`(000852 列表搜索用 indexCode)
|
||||
|
||||
**改动 1a — 932000 launch xlsx 列索引(:526-538):**
|
||||
现:`code = _norm_code(row[0])`, `name = str(row[1])`。改为按 header 定位列(稳健),或直接 `code=row[3]`, `name=row[4]`。推荐 header 定位:
|
||||
```python
|
||||
header = rows[0]
|
||||
# 找"证券代码"和"证券中文简称"列(中英文混合 header)
|
||||
code_idx = next((i for i,h in enumerate(header) if h and "证券代码" in str(h)), 3)
|
||||
name_idx = next((i for i,h in enumerate(header) if h and "证券中文简称" in str(h)), 4)
|
||||
for row in rows[1:]:
|
||||
code = _norm_code(row[code_idx] if len(row)>code_idx else None)
|
||||
name = str(row[name_idx]).strip() if len(row)>name_idx and row[name_idx] else ""
|
||||
```
|
||||
|
||||
**改动 1b — 000852 列表搜索用 indexCode(:120-182):**
|
||||
现 `fetch_all_notices` 拉全量再 `filter_csi1000_notices` title 过滤。改为:对 000852 用 `indexCode` payload 直接搜:
|
||||
```python
|
||||
payload = {"lang":"cn","classlist":[],"indexlist":[],
|
||||
"indexCode":"000852", # ← 新增,直接按指数搜
|
||||
"page":{"desc":"","key":"","page":page,"rows":100},
|
||||
"related_topics":[],"typelist":[]}
|
||||
```
|
||||
保留旧 filter 作兜底(标题含中证1000+调整)。合并 indexCode 命中 ∪ 已知 REGULAR/TEMP_IDS 去重。932000 走全局 `related_topics:["index_rebalance"]` + PDF grep "中证2000" section(parse_pdf_adjustments 已支持 target_section)。
|
||||
|
||||
**验证探针:**
|
||||
```bash
|
||||
python3 scripts/data_platform/parse_csindex_announce.py --only 1000
|
||||
# 期望:filtered CSI 1000 公告 ≥ 40 条(原 28),date 范围早于 2018-07
|
||||
python3 scripts/data_platform/parse_csindex_announce.py --only 2000
|
||||
# 期望:932000_announce_union.parquet distinct codes ≈ 2000(原 bug=1)
|
||||
```
|
||||
|
||||
- [ ] Step 1: 改 932000 launch xlsx 列索引(header 定位)
|
||||
- [ ] Step 2: 改 000852 列表搜索(indexCode payload + 932000 related_topics)
|
||||
- [ ] Step 3: Mac 重跑 `--only 1000` + `--only 2000`,验证探针
|
||||
- [ ] Step 4: commit
|
||||
|
||||
---
|
||||
|
||||
### Task 2(#31):改 migrate_constituent.py 接 announce_union 聚合全集
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/data_platform/migrate_constituent.py:118-131`(加读 announce_union)
|
||||
- Test: `tests/portfolio/test_migrate_announce_union.py`(新建,TDD)
|
||||
|
||||
**聚合逻辑(全集型):**
|
||||
```python
|
||||
# 读 000852_announce_union.parquet + 932000_announce_union.parquet
|
||||
# announce_union schema: updateDate/index_code/code/code_name/adjust_type(add|remove|current|initial|current)/notice_id/source
|
||||
# 全集聚合:
|
||||
for idx in ['000852','932000']:
|
||||
ann = read(f"{idx}_announce_union.parquet")
|
||||
snap = read(f"{idx}_snapshot.parquet") # akshare 当前快照,权威 in_current
|
||||
current_codes = set(snap['code']) # 当前在册
|
||||
ever_codes = set(ann['code']) | current_codes # 曾经入选(所有 add/initial + current)
|
||||
# 产出:ever_codes 每只一行
|
||||
# in_current = code in current_codes
|
||||
# was_removed = code not in current_codes(曾入选已踢)
|
||||
# source = 'csindex_announce'
|
||||
```
|
||||
schema 对齐:`index_code/code/code_name/source/in_current/was_removed`。`code_name` 取 announce_union 或 snapshot 的(优先 snapshot 当前名)。
|
||||
|
||||
**合并进 staging:** 现有 `all_df = pd.concat([pool, df_deep, df_snap])`(:134)→ 把 000852/932000 的 announce_union 全集**替换** df_snap 里的 000852/932000 快照行(快照并入 announce 全集的 in_current),其他指数不动。
|
||||
|
||||
**TDD 测试(tests/portfolio/test_migrate_announce_union.py):**
|
||||
- test announce_union 聚合:given announce(add A,B + remove C) + snapshot(current A,B,D),assert ever={A,B,C,D}, in_current={A,B,D}, was_removed={C}
|
||||
- test 000852 distinct > 1000(治偏差证据)
|
||||
- test 932000 distinct ≈ 2000(launch 修复)
|
||||
- test 幂等(跑两次结果一致)
|
||||
|
||||
- [ ] Step 1: 写聚合测试(RED)
|
||||
- [ ] Step 2: 改 migrate 加 announce_union 聚合(GREEN)
|
||||
- [ ] Step 3: 测试通过
|
||||
- [ ] Step 4: commit
|
||||
|
||||
---
|
||||
|
||||
### Task 3(#32):重跑→同步VPS→migrate→merge→验证
|
||||
|
||||
**Files:** 无新文件(运行现有 pipeline)
|
||||
|
||||
- [ ] Step 1: Mac 重跑 parse_csindex_announce.py --only both → 新 announce_union
|
||||
- [ ] Step 2: scp 000852_announce_union.parquet + 932000_announce_union.parquet 到 VPS `C:\sanguo_vnpy_v2\data\index_const_hist\`
|
||||
- [ ] Step 3: rsync 改后的 migrate_constituent.py 到 VPS
|
||||
- [ ] Step 4: VPS 跑 migrate_constituent.py(SANGUO_DB 指向 quant_trading.db)→ merge_constituent.py
|
||||
- [ ] Step 5: 验证(见下)
|
||||
|
||||
**验证标准(VPS 查 constituent_unified):**
|
||||
```sql
|
||||
SELECT index_code, COUNT(*), SUM(in_current), SUM(was_removed)
|
||||
FROM constituent_unified WHERE index_code IN ('000852','932000') GROUP BY index_code;
|
||||
```
|
||||
- 000852: total > 1000(曾经入选 ~1200+), in_current=1000, **was_removed > 0**(治偏差)
|
||||
- 932000: total ≈ 2000+, in_current=当前快照数, was_removed ≥ 0(launch ∪ current,中间调整无记录则 was_removed=0 可接受)
|
||||
- 抽样:挑一只 known 被踢股(如 announce_union 里 remove 类型)→ constituent_unified 该 code was_removed=1
|
||||
- 回归:300/500/50/深证 行数不变(没误伤)
|
||||
|
||||
---
|
||||
|
||||
### Task 4(#33):定期 schtask 方案+部署
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/data_platform/csindex_constituent_wrapper.ps1`
|
||||
- Create: `scripts/data_platform/register_csindex_schtasks.ps1`
|
||||
|
||||
**schtask 设计:**
|
||||
- 名:`sanguo-csindex-constituent`
|
||||
- 频率:**每月 16 号 + 6月/12月定调后额外**(中证1000 定期调整 6月/12月,临时调整不定期 → 月度抓足够,缓存增量)
|
||||
- 时间:**20:30**(避开 baostock 18:05/xt 18:40/akshare 19:00-19:50 窗口)
|
||||
- 流程:parse_csindex_announce.py --refresh-list(抓新公告)→ 同步 announce_union 已在本机 → migrate → merge
|
||||
- 幂等:migrate/merge 已 DROP+CREATE 可重跑;parse 有 notice cache 增量
|
||||
|
||||
**wrapper ps1(仿 bs_eod_wrapper.ps1 风格):** unset proxy → Set-Location → timestamped log → python parse + migrate + merge → exit code
|
||||
|
||||
- [ ] Step 1: 写 wrapper ps1 + register ps1
|
||||
- [ ] Step 2: VPS 部署 + schtasks /create /ru SYSTEM /rl HIGHEST
|
||||
- [ ] Step 3: 手动触发一次验证(schtasks /run)
|
||||
- [ ] Step 4: commit + 同步安装目录
|
||||
|
||||
---
|
||||
|
||||
### Task 5(#34):更新 memory
|
||||
|
||||
**Files:**
|
||||
- Update: memory `data-fusion-design-finalized.md`(推翻 000852/932000 "永久 gap")
|
||||
- Update: memory `static_data_gaps_design.md`(中证1000/2000 gap 关闭)
|
||||
- Update: `MEMORY.md` 索引
|
||||
|
||||
**记:** csindex 公告 JSON 接口路推翻"永久 gap";000852 全集入库(曾经入选 1200+);932000 launch xlsx 列 bug 修复;全集型简化洞察(不需回溯引擎);定期 schtask;调查 agent 实证的 96 公告/45 调样/回溯到 2014。
|
||||
|
||||
- [ ] Step 1: 更新 3 个 memory 文件
|
||||
- [ ] Step 2: MEMORY.md 索引行
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- spec 覆盖:① 调整补全→Task1-3 ② 定期抓取方案→Task4 ✓
|
||||
- 全集型简化避免过度设计(调查 agent 的回溯引擎是 future 时点型需求,现不做)✓
|
||||
- TDD:migrate 聚合逻辑先写测试 ✓
|
||||
- 不破坏:300/500/50/深证 migrate 路径不动,只加 000852/932000 announce 段 ✓
|
||||
- 约束:不走代理/单线程/staging→merge 幂等/不碰 baostock ✓
|
||||
|
||||
## 已知残留 gap(接受,不阻塞)
|
||||
- 932000 中间调整(2023-08 launch 到 current 之间)csindex 无公告 → launch ∪ current 全集,中间被踢的不可补(2023 新指数,影响小)
|
||||
- 000852 2014-2017 早期 HTML 表格解析格式松散,可能不全(扩 indexCode 搜索尽力补,实证 id=5/id=1585 等仍有表格)
|
||||
@@ -0,0 +1,601 @@
|
||||
# LocalUnifiedProvider Implementation Plan (spec §6 使用层)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现 spec §6 使用层 `LocalUnifiedProvider`——读方案A 权威数据层(dbbardata/constituent_unified/valuation_baostock),零 online,治幸存者偏差,喂 all_weather 策略。
|
||||
|
||||
**Architecture:** 新建 `LocalUnifiedProvider(bullet_trade.DataProvider)`,内部按数据类路由方案A 权威表:日线读 `dbbardata('d')` raw + `bs_adjust_factor` 算前复权;成份股读 `constituent_unified` 并集(治偏差);估值读 `valuation_baostock` parquet + 市值读 static/valuation akshare parquet。Mac 测试用 `sqlite :memory:` + tmp parquet fixture,零 VPS 依赖。
|
||||
|
||||
**Tech Stack:** Python 3.10, pandas 2.3, sqlite3, pyarrow, pytest
|
||||
|
||||
## Global Constraints(spec + 用户铁律)
|
||||
|
||||
- **零 online**: provider 不 import baostock 调 online,纯读本地 DB/parquet(memory provider-local-data-only)。baostock 48000/天限频不波及使用层。
|
||||
- **surgical**: 不改 `LocalParquetProvider`/`BaostockProvider`(旧链路保留,向后兼容)。
|
||||
- **dbbardata 不破坏**: `UNIQUE(symbol,exchange,interval,datetime)`,只读不写。
|
||||
- **复权**: dbbardata 存 raw,消费端按 `bs_adjust_factor.foreAdjustFactor` 算前复权(§14.7 最终目标,用户定不降级)。
|
||||
- **constituent_unified 并集模型**: 表无 date 列,`get_index_stocks(date)` 返回 in_current∪was_removed 并集,date 参数无法精确时点过滤——治"纯当前幸存者"偏差,有轻微前视(使用说明标注)。
|
||||
- **代码归一**: jq_code `600519.XSHG` ↔ dbbardata `symbol=600519, exchange=SSE`;`SSE→SH, SZSE→SZ`。
|
||||
|
||||
## 实测 schema(VPS 2026-07-23 probe,执行 agent 必读)
|
||||
|
||||
DB = `C:\sanguo_vnpy_v2\data\quant_trading.db`(VPS) / Mac 测试用 fixture 路径。
|
||||
|
||||
**dbbardata('d')** — 唯一行情表,raw 真实价:
|
||||
```
|
||||
列: symbol TEXT, exchange TEXT(SSE/SZSE), datetime TEXT(YYYY-MM-DD HH:MM:SS),
|
||||
interval TEXT('d'), volume REAL, turnover REAL, open_interest REAL,
|
||||
open_price REAL, high_price REAL, low_price REAL, close_price REAL
|
||||
样本: 600519 10056行 2001-08-27~2026-07-22; 000005退市 8146行~2024-04-26; 510300 ETF 3439行
|
||||
```
|
||||
|
||||
**constituent_unified** — 成份股并集(无 date!):
|
||||
```
|
||||
列: index_code TEXT(如 '000300'), code TEXT(纯6位如 '000001'), code_name TEXT,
|
||||
source TEXT('baostock'/'akshare'), in_current INT(0/1), was_removed INT(0/1)
|
||||
分布: 000300=940(300当前+640被踢) 000905=1803 000016=195 000852=1000(全当前,历史不可补)
|
||||
399001=702 399005=145 399006=175 399330=150 932000=2000(全当前)
|
||||
```
|
||||
|
||||
**bs_adjust_factor** — 复权因子:
|
||||
```
|
||||
列: code TEXT('sh.600519'), dividOperateDate TEXT(YYYY-MM-DD),
|
||||
foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL
|
||||
语义: foreAdjustFactor 按除权日分段,最新事件=1.0,递减往历史。qfq[t]=raw[t]*factor[date[t]]。
|
||||
600519 有 12 事件: 2020-06-24=0.856267 ... 2026-06-26=1.0
|
||||
```
|
||||
|
||||
**valuation_baostock/<year>.parquet** — baostock 估值(1990-2026 全年份):
|
||||
```
|
||||
列: symbol(6位), exchange(SH/SZ), date(YYYY-MM-DD), peTTM, psTTM, pcfNcfTTM, pbMRQ, turn, pctChg, isST
|
||||
注: 无 market_cap/total_share 列! 市值从 static/valuation akshare 补。
|
||||
```
|
||||
|
||||
**static/valuation/<code>_valuation.parquet** — akshare 估值(市值/股本来源,5530 文件):
|
||||
```
|
||||
中文列(见 LocalParquetProvider._VAL_COL_MAP): 总市值→total_market_cap, 流通市值→circ_market_cap,
|
||||
总股本→total_share, PE(TTM)→pe_ttm, 市净率→pb ...
|
||||
```
|
||||
|
||||
**static/{balance,income,cashflow}/<code>_<table>.parquet** — akshare 三表(balance 221列/income 170列):
|
||||
```
|
||||
通用列: SECUCODE, REPORT_DATE, REPORT_TYPE; balance 有 TOTAL_ASSETS/TOTAL_LIABILITIES/TOTAL_PARENT_EQUITY;
|
||||
income 有 BASIC_EPS/OPERATE_INCOME/PARENT_NETPROFIT/OPERATE_INCOME_YOY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create:** `sanguo_portfolio/providers/local_unified_provider.py` — LocalUnifiedProvider 类(~400行)
|
||||
- **Modify:** `sanguo_portfolio/providers/__init__.py` — 导出 LocalUnifiedProvider
|
||||
- **Modify:** `sanguo_portfolio/runner_backtest.py` — `build_provider` 加 `unified` 选项(choices + 分支)
|
||||
- **Create:** `tests/portfolio/test_local_unified_provider.py` — DataProvider 契约单测(fixture: sqlite + tmp parquet)
|
||||
- **Create:** `tests/portfolio/conftest.py` 追加 — `local_unified_provider` fixture(若需要,否则在测试文件内建)
|
||||
- **Create:** `docs/portfolio_local_unified_provider.md` — 使用说明(架构/数据源/接口/复权/治偏差/Mac测试/部署)
|
||||
|
||||
---
|
||||
|
||||
## Task 0: 代码转换 + DB 连接辅助 + 复权因子构造
|
||||
|
||||
**Files:**
|
||||
- Create: `sanguo_portfolio/providers/local_unified_provider.py`(本 task 建文件骨架 + 模块级辅助函数)
|
||||
- Test: `tests/portfolio/test_local_unified_provider.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `jq_to_dbbardata(jq_code) -> (symbol, exchange)` / `dbbardata_to_jq(symbol, exchange) -> jq_code`; `_connect(cfg) -> sqlite3.Connection`; `_build_qfq_factor(code, conn, dates) -> pd.Series(factor indexed by date)`
|
||||
|
||||
- [ ] **Step 1: 写失败测试 — 代码转换**
|
||||
|
||||
```python
|
||||
# tests/portfolio/test_local_unified_provider.py
|
||||
from sanguo_portfolio.providers.local_unified_provider import (
|
||||
jq_to_dbbardata, dbbardata_to_jq, LocalUnifiedProvider,
|
||||
)
|
||||
|
||||
def test_jq_to_dbbardata_roundtrip():
|
||||
assert jq_to_dbbardata("600519.XSHG") == ("600519", "SSE")
|
||||
assert jq_to_dbbardata("000001.XSHE") == ("000001", "SZSE")
|
||||
assert jq_to_dbbardata("600519") == ("600519", "SSE") # 纯6位推断
|
||||
assert dbbardata_to_jq("600519", "SSE") == "600519.XSHG"
|
||||
assert dbbardata_to_jq("000001", "SZSE") == "000001.XSHE"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认 FAIL** — `pytest tests/portfolio/test_local_unified_provider.py::test_jq_to_dbbardata_roundtrip -v`(ImportError)
|
||||
|
||||
- [ ] **Step 3: 实现模块骨架 + 代码转换**
|
||||
|
||||
```python
|
||||
# sanguo_portfolio/providers/local_unified_provider.py
|
||||
"""LocalUnifiedProvider: 读方案A 权威数据层, 零 online, 治幸存者偏差(spec §6)。
|
||||
|
||||
数据源(全本地 VPS C:\\sanguo_vnpy_v2\\data\\):
|
||||
- 日线: dbbardata('d') raw + bs_adjust_factor 算前复权(§14.7)
|
||||
- 成份股: constituent_unified 并集(治偏差,无 date 时点)
|
||||
- 估值 pe/pb/ps/pcf: valuation_baostock/<year>.parquet(baostock 权威)
|
||||
- 市值/股本: static/valuation akshare parquet(baostock valuation 无市值列)
|
||||
- 三表: static/{balance,income,cashflow} akshare parquet
|
||||
|
||||
零 online: 不 import baostock 调 online。Mac 测试用 sqlite+parquet fixture。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging, os, sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
from bullet_trade.data.providers.base import DataProvider # type: ignore
|
||||
except ImportError:
|
||||
class DataProvider: # type: ignore[no-redef]
|
||||
name: str = "base"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_DEFAULT_DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
|
||||
_DEFAULT_DATA_DIR = r"C:\sanguo_vnpy_v2\data"
|
||||
|
||||
_JQ_SUFFIX_TO_EXC = {"XSHG": "SSE", "XSHE": "SZSE", "SH": "SSE", "SZ": "SZSE"}
|
||||
_EXC_TO_JQ_SUFFIX = {"SSE": "XSHG", "SZSE": "XSHE"}
|
||||
|
||||
|
||||
def jq_to_dbbardata(jq_code: str) -> tuple[str, str]:
|
||||
"""600519.XSHG → ('600519', 'SSE')。纯6位按6开头=sh/0,3=sz 推断。"""
|
||||
s = (jq_code or "").strip()
|
||||
if "." not in s:
|
||||
if len(s) == 6:
|
||||
return s, ("SSE" if s.startswith("6") else "SZSE")
|
||||
return s, "SSE"
|
||||
code, suffix = s.split(".", 1)
|
||||
return code, _JQ_SUFFIX_TO_EXC.get(suffix.upper(), "SSE")
|
||||
|
||||
|
||||
def dbbardata_to_jq(symbol: str, exchange: str) -> str:
|
||||
"""('600519','SSE') → '600519.XSHG'。"""
|
||||
jq_suffix = _EXC_TO_JQ_SUFFIX.get(str(exchange).upper(), "XSHG")
|
||||
return f"{symbol}.{jq_suffix}"
|
||||
|
||||
|
||||
# 复权因子代码转换: 600519.XSHG → 'sh.600519'(bs_adjust_factor.code 格式)
|
||||
def _jq_to_bs_code(jq_code: str) -> str:
|
||||
sym, exc = jq_to_dbbardata(jq_code)
|
||||
prefix = "sh" if exc == "SSE" else "sz"
|
||||
return f"{prefix}.{sym}"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认 PASS**
|
||||
|
||||
- [ ] **Step 5: 写失败测试 — 复权因子构造**
|
||||
|
||||
```python
|
||||
def test_build_qfq_factor(tmp_path):
|
||||
# fixture: 2 除权事件, 最新=1.0
|
||||
import sqlite3
|
||||
db = tmp_path / "t.db"
|
||||
c = sqlite3.connect(str(db))
|
||||
c.execute("CREATE TABLE bs_adjust_factor(code TEXT, dividOperateDate TEXT, foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL)")
|
||||
c.executemany("INSERT INTO bs_adjust_factor VALUES(?,?,?,?,?)", [
|
||||
("sh.600519", "2024-06-19", 0.90, 0, 0),
|
||||
("sh.600519", "2025-06-19", 1.00, 0, 0),
|
||||
])
|
||||
c.commit(); c.close()
|
||||
from sanguo_portfolio.providers.local_unified_provider import _build_qfq_factor
|
||||
dates = pd.to_datetime(["2023-01-01", "2024-07-01", "2025-07-01"])
|
||||
f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates)
|
||||
# 2023(早于最早事件)=0.90; 2024-07(between)=0.90; 2025-07(最新后)=1.00
|
||||
assert abs(f.iloc[0] - 0.90) < 1e-6
|
||||
assert abs(f.iloc[1] - 0.90) < 1e-6
|
||||
assert abs(f.iloc[2] - 1.00) < 1e-6
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 实现 `_build_qfq_factor`** — asof join 逻辑(每个 date 找 ≤ 的最大 dividOperateDate 的 foreAdjustFactor;早于所有事件用最早;晚于所有用最新):
|
||||
|
||||
```python
|
||||
def _build_qfq_factor(bs_code: str, conn: sqlite3.Connection,
|
||||
dates: pd.Series) -> pd.Series:
|
||||
"""构造每个 date 的前复权因子(asof)。qfq[t]=raw[t]*factor[t]。"""
|
||||
rows = conn.execute(
|
||||
"SELECT dividOperateDate, foreAdjustFactor FROM bs_adjust_factor "
|
||||
"WHERE code=? ORDER BY dividOperateDate", (bs_code,)).fetchall()
|
||||
if not rows:
|
||||
return pd.Series([1.0] * len(dates), index=dates)
|
||||
ev_dates = pd.to_datetime([r[0] for r in rows])
|
||||
factors = [float(r[1]) for r in rows]
|
||||
out = []
|
||||
for d in pd.to_datetime(dates):
|
||||
# 找 <= d 的最大事件; 全部 > d 用最早(第一个); 全部 <= d 用最后一个
|
||||
mask = ev_dates <= d
|
||||
out.append(factors[mask.argmax()] if mask.any() else factors[0])
|
||||
# mask.argmax() 给第一个 True 的索引;但我们要"<= d 的最大事件"= 最后一个 True
|
||||
# 修正:取最后一个 True
|
||||
out = []
|
||||
for d in pd.to_datetime(dates):
|
||||
mask = ev_dates <= d
|
||||
idx = int(np.where(mask)[0][-1]) if mask.any() else 0
|
||||
out.append(factors[idx])
|
||||
return pd.Series(out, index=pd.to_datetime(dates))
|
||||
```
|
||||
(注意:`np` 需 `import numpy as np`。实现时简化为单次循环取最后一个 True 索引。)
|
||||
|
||||
- [ ] **Step 7: 跑测试确认 PASS**
|
||||
- [ ] **Step 8: Commit** — `feat(portfolio): LocalUnifiedProvider 代码转换+复权因子(Task0)`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: get_price(dbbardata raw + 前复权 + panel 长表)
|
||||
|
||||
**Files:** Modify `local_unified_provider.py` 加 `__init__` + `get_price`; Test 同文件。
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task0 辅助函数 + `_connect`
|
||||
- Produces: `LocalUnifiedProvider.get_price(security, start_date, end_date, frequency, fields, skip_paused, fq, count, panel, fill_paused) -> DataFrame`
|
||||
|
||||
策略契约(all_weather 实证):
|
||||
- `get_price(hold_list, end_date, freq=daily, fields=[close,high_limit], count=1, panel=False)` — panel=False 长表需 time/code 列
|
||||
- `get_price(stocks, freq=1d, fields=[close], count=n, panel=False)` — _trend_mean pivot(index=time,columns=code)
|
||||
- `get_price(stock, freq=1m, fq="pre", count=1, panel=False)` — intraday(day 频率回测降级,1m 无数据返空)
|
||||
|
||||
- [ ] **Step 1: 写失败测试 — get_price daily 单股 + 复权**
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def unified_provider(tmp_path):
|
||||
"""造小样本 sqlite + parquet fixture。"""
|
||||
db = tmp_path / "quant_trading.db"
|
||||
c = sqlite3.connect(str(db))
|
||||
c.execute("CREATE TABLE dbbardata(symbol,exchange,datetime,interval,volume,turnover,open_interest,open_price,high_price,low_price,close_price)")
|
||||
rows = [
|
||||
("600519","SSE","2024-06-18 00:00:00","d",1000,1e6,0,1000.0,1010.0,990.0,1000.0), # 除权前
|
||||
("600519","SSE","2024-06-19 00:00:00","d",1000,1e6,0,900.0,910.0,890.0,900.0), # 除权日 raw 跳水
|
||||
("600519","SSE","2024-06-20 00:00:00","d",1000,1e6,0,910.0,920.0,900.0,910.0),
|
||||
]
|
||||
c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
|
||||
c.execute("CREATE TABLE bs_adjust_factor(code,dividOperateDate,foreAdjustFactor,backAdjustFactor,adjustFactor)")
|
||||
c.execute("INSERT INTO bs_adjust_factor VALUES('sh.600519','2024-06-19',0.9,0,0)") # 除权日 factor
|
||||
c.commit(); c.close()
|
||||
return LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
|
||||
|
||||
def test_get_price_raw_vs_qfq(unified_provider):
|
||||
p = unified_provider
|
||||
# raw: 除权日 900 跳水
|
||||
df_raw = p.get_price("600519.XSHG", start_date="2024-06-18", end_date="2024-06-20", fq="raw")
|
||||
assert len(df_raw) == 3
|
||||
assert abs(df_raw.loc["2024-06-19", "close"] - 900.0) < 1e-6
|
||||
# qfq: 06-18 = 1000*0.9 = 900; 06-19/20 = raw(factor=0.9 当 06-19 之后? 用最新段逻辑)
|
||||
df_qfq = p.get_price("600519.XSHG", start_date="2024-06-18", end_date="2024-06-20", fq="qfq")
|
||||
assert abs(df_qfq.loc["2024-06-18", "close"] - 900.0) < 1e-6 # 1000*0.9(早于事件用最早factor)
|
||||
```
|
||||
(复权断言:06-18 早于除权日 06-19 → 用 factor 0.9 → 1000*0.9=900;06-19/20 ≥ 事件日 → factor 取 06-19 的 0.9 → 900*0.9=810, 910*0.9=819。实现时按 `_build_qfq_factor` 语义校准断言。)
|
||||
|
||||
- [ ] **Step 2: 跑测试确认 FAIL**
|
||||
|
||||
- [ ] **Step 3: 实现 `__init__` + `get_price`**
|
||||
|
||||
```python
|
||||
class LocalUnifiedProvider(DataProvider): # type: ignore[misc]
|
||||
name: str = "sanguo_local_unified"
|
||||
requires_live_data: bool = False
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
cfg = config or {}
|
||||
self.db_path: str = cfg.get("db_path", _DEFAULT_DB)
|
||||
self.data_dir: str = cfg.get("data_dir", _DEFAULT_DATA_DIR)
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._val_bs_cache: Dict[int, pd.DataFrame] = {} # year -> valuation_baostock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(self.db_path, timeout=30)
|
||||
self._conn.execute("PRAGMA busy_timeout = 30000")
|
||||
return self._conn
|
||||
|
||||
def get_price(self, security, start_date=None, end_date=None, frequency="daily",
|
||||
fields=None, skip_paused=False, fq="raw", count=None,
|
||||
panel=True, fill_paused=True, **kwargs):
|
||||
freq = str(frequency or "").lower()
|
||||
if freq not in ("daily", "day", "1d", "d"):
|
||||
return pd.DataFrame() # 1m/分钟 day 频率回测降级(数据层无 1m)
|
||||
secs = [security] if isinstance(security, str) else list(security or [])
|
||||
conn = self._connect()
|
||||
start_str = self._to_date_str(start_date)
|
||||
end_str = self._to_date_str(end_date) or datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
frames: Dict[str, pd.DataFrame] = {}
|
||||
for jq_code in secs:
|
||||
sym, exc = jq_to_dbbardata(jq_code)
|
||||
q = "SELECT datetime, open_price, high_price, low_price, close_price, " \
|
||||
"volume, turnover FROM dbbardata WHERE symbol=? AND exchange=? " \
|
||||
"AND interval='d' AND datetime>=? AND datetime<=? ORDER BY datetime"
|
||||
df = pd.read_sql(q, conn, params=(sym, exc, start_str + " 00:00:00", end_str + " 23:59:59"))
|
||||
if df.empty:
|
||||
frames[jq_code] = df; continue
|
||||
df["datetime"] = pd.to_datetime(df["datetime"])
|
||||
df = df.set_index("datetime")
|
||||
df.index.name = None
|
||||
if count:
|
||||
df = df.tail(count)
|
||||
# 复权
|
||||
if fq in ("qfq", "pre", "前复权"):
|
||||
factor = _build_qfq_factor(_jq_to_bs_code(jq_code), conn, df.index)
|
||||
for col in ("open_price", "high_price", "low_price", "close_price"):
|
||||
df[col] = df[col].values * factor.values
|
||||
# 策略要 close/high_limit 字段名(jq 风格)
|
||||
df = df.rename(columns={"open_price": "open", "high_price": "high",
|
||||
"low_price": "low", "close_price": "close"})
|
||||
# high_limit 不在 dbbardata, 留给 get_current_tick 语义;这里策略 prepare_stock_list 要 high_limit 列
|
||||
# → 缺失列返 NaN(策略 hit = close==high_limit 不会命中,降级可接受)
|
||||
if fields:
|
||||
for f in fields:
|
||||
if f not in df.columns:
|
||||
df[f] = float("nan")
|
||||
df = df[fields]
|
||||
frames[jq_code] = df
|
||||
|
||||
if not frames or all(f.empty for f in frames.values()):
|
||||
return pd.DataFrame()
|
||||
if not panel:
|
||||
parts = []
|
||||
for jq_code, df in frames.items():
|
||||
if df.empty:
|
||||
continue
|
||||
d = df.reset_index().rename(columns={"datetime": "time"})
|
||||
d.insert(0, "code", jq_code)
|
||||
parts.append(d)
|
||||
return pd.concat(parts, ignore_index=True) if parts else pd.DataFrame()
|
||||
if len(frames) == 1:
|
||||
return next(iter(frames.values()))
|
||||
return pd.concat(frames, axis=1)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认 PASS**
|
||||
- [ ] **Step 5: 写失败测试 — panel=False 多股长表 + count**
|
||||
|
||||
```python
|
||||
def test_get_price_panel_false_multi(unified_provider):
|
||||
df = unified_provider.get_price("600519.XSHG", end_date="2024-06-20", count=2, panel=False, fields=["close"])
|
||||
assert "code" in df.columns and "time" in df.columns
|
||||
assert len(df) == 2
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 实现(Step 3 已含 panel 分支),跑 PASS**
|
||||
- [ ] **Step 7: Commit** — `feat(portfolio): LocalUnifiedProvider get_price+前复权(Task1)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: get_index_stocks + get_constituent(constituent_unified 并集,治偏差)
|
||||
|
||||
**Files:** Modify `local_unified_provider.py`; Test 同文件。
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `get_index_stocks(index_symbol, date) -> List[str]` + `get_constituent(index, date) -> List[str]`(语义别名)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
```python
|
||||
def test_get_index_stocks_union(tmp_path):
|
||||
db = tmp_path / "t.db"; c = sqlite3.connect(str(db))
|
||||
c.execute("CREATE TABLE constituent_unified(index_code TEXT,code TEXT,code_name TEXT,source TEXT,in_current INT,was_removed INT)")
|
||||
c.executemany("INSERT INTO constituent_unified VALUES(?,?,?,?,?,?)", [
|
||||
("000300", "600519", "贵州茅台", "baostock", 1, 0),
|
||||
("000300", "000001", "平安银行", "baostock", 1, 0),
|
||||
("000300", "600811", "退市股", "baostock", 0, 1), # 被踢
|
||||
])
|
||||
c.commit(); c.close()
|
||||
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
|
||||
stocks = p.get_index_stocks("000300.XSHG", "2020-01-01")
|
||||
assert set(stocks) == {"600519.XSHG", "000001.XSHE", "600811.SH"} # 并集含被踢
|
||||
# date 参数不报错(并集模型忽略)
|
||||
assert p.get_constituent("000300", None) == stocks # 别名
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认 FAIL**
|
||||
|
||||
- [ ] **Step 3: 实现** — 查 constituent_unified,index_code 匹配(去 `.XXXX` 后缀),返回 in_current=1 OR was_removed=1 的并集,code→jq_code:
|
||||
|
||||
```python
|
||||
def get_index_stocks(self, index_symbol, date=None) -> List[str]:
|
||||
idx = index_symbol.split(".")[0] if "." in str(index_symbol) else str(index_symbol)
|
||||
conn = self._connect()
|
||||
rows = conn.execute(
|
||||
"SELECT code FROM constituent_unified WHERE index_code=? "
|
||||
"AND (in_current=1 OR was_removed=1)", (idx,)).fetchall()
|
||||
out = []
|
||||
for (code,) in rows:
|
||||
code = str(code).strip()
|
||||
if len(code) != 6:
|
||||
continue
|
||||
exc = "SSE" if code.startswith("6") else "SZSE"
|
||||
out.append(dbbardata_to_jq(code, exc))
|
||||
return out
|
||||
|
||||
def get_constituent(self, index, date=None) -> List[str]:
|
||||
"""spec §6 语义别名 = get_index_stocks。"""
|
||||
return self.get_index_stocks(index, date)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试 PASS**
|
||||
- [ ] **Step 5: Commit** — `feat(portfolio): LocalUnifiedProvider 成份股并集治偏差(Task2)`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: get_fundamentals_df(valuation_baostock + static akshare + 三表)
|
||||
|
||||
**Files:** Modify `local_unified_provider.py`; Test 同文件 + tmp parquet fixture。
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `get_fundamentals_df(stocks, date) -> DataFrame` 列对齐 `_FUNDAMENTAL_COLUMNS`
|
||||
|
||||
数据源映射:
|
||||
- `pe_ratio/pb_ratio/ps_ratio/pcf_ratio` ← valuation_baostock parquet(peTTM/pbMRQ/psTTM/pcfNcfTTM,baostock 权威)
|
||||
- `market_cap/circulating_market_cap` ← static/valuation akshare parquet(total_market_cap/circ_market_cap,baston 无市值)
|
||||
- 三表字段(eps/net_profit_margin/total_liability 等) ← static/{balance,income} akshare parquet(复用 LocalParquetProvider 读法)
|
||||
|
||||
- [ ] **Step 1: 写失败测试 — 估值字段从 valuation_baostock**
|
||||
|
||||
```python
|
||||
def test_get_fundamentals_valuation(tmp_path):
|
||||
# valuation_baostock/2024.parquet
|
||||
vdir = tmp_path / "valuation_baostock"; vdir.mkdir()
|
||||
pd.DataFrame({"symbol":["600519"],"exchange":["SH"],"date":["2024-09-30"],
|
||||
"peTTM":[25.0],"psTTM":[15.0],"pcfNcfTTM":[20.0],"pbMRQ":[7.5],
|
||||
"turn":[0.1],"pctChg":[1.0],"isST":[0]}).to_parquet(vdir/"2024.parquet")
|
||||
# static/valuation akshare(市值)
|
||||
sdir = tmp_path / "static" / "valuation"; sdir.mkdir(parents=True)
|
||||
pd.DataFrame({"数据日期":["2024-09-30"],"总市值":[2e12],"流通市值":[2e12],"总股本":[1.256e9],
|
||||
"PE(TTM)":[25],"市净率":[7.5]}).to_parquet(sdir/"600519.SH_valuation.parquet")
|
||||
p = LocalUnifiedProvider({"db_path": str(tmp_path/"t.db"), "data_dir": str(tmp_path)})
|
||||
df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||||
assert abs(df.loc["600519.XSHG","pe_ratio"] - 25.0) < 1e-6 # baostock 权威
|
||||
assert abs(df.loc["600519.XSHG","pb_ratio"] - 7.5) < 1e-6
|
||||
assert abs(df.loc["600519.XSHG","market_cap"] - 2e4) < 1 # 2e12元→2e4亿
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认 FAIL**
|
||||
|
||||
- [ ] **Step 3: 实现** — 读 valuation_baostock parquet(year from date)+ static/valuation akshare;合并对齐 `_FUNDAMENTAL_COLUMNS`(复用 LocalParquetProvider 的 `_VAL_COL_MAP` / `to_yi` / 三表读法,import 复用):
|
||||
|
||||
```python
|
||||
from .local_parquet_provider import (_VAL_COL_MAP, jq_to_file_code,
|
||||
_to_float, _or_nan, _pct_to_decimal, _FUNDAMENTAL_COLUMNS)
|
||||
from ..factors.valuation import to_yi
|
||||
|
||||
def get_fundamentals_df(self, stocks, date=None) -> pd.DataFrame:
|
||||
if not stocks:
|
||||
return pd.DataFrame(columns=_FUNDAMENTAL_COLUMNS)
|
||||
date_str = self._to_date_str(date) or datetime.now().strftime("%Y-%m-%d")
|
||||
rows = [self._build_fundamental_row(s, date_str) for s in stocks]
|
||||
df = pd.DataFrame(rows, columns=_FUNDAMENTAL_COLUMNS)
|
||||
if "code" in df.columns:
|
||||
df = df.set_index("code", drop=False)
|
||||
return df
|
||||
|
||||
def _read_valuation_baostock(self, year: int) -> pd.DataFrame:
|
||||
if year in self._val_bs_cache:
|
||||
return self._val_bs_cache[year]
|
||||
p = os.path.join(self.data_dir, "valuation_baostock", f"{year}.parquet")
|
||||
df = pd.read_parquet(p) if os.path.exists(p) else pd.DataFrame()
|
||||
self._val_bs_cache[year] = df
|
||||
return df
|
||||
|
||||
def _build_fundamental_row(self, jq_code, date_str) -> Dict[str, Any]:
|
||||
sym, exc = jq_to_dbbardata(jq_code)
|
||||
fc = jq_to_file_code(jq_code) # 600519.SH(static akshare 文件名)
|
||||
row: Dict[str, Any] = {"code": jq_code}
|
||||
# 1. pe/pb/ps/pcf ← valuation_baostock(baostock 权威)
|
||||
year = int(date_str[:4])
|
||||
vbs = self._read_valuation_baostock(year)
|
||||
if not vbs.empty:
|
||||
sub = vbs[(vbs["symbol"].astype(str) == sym) & (vbs["date"].astype(str) <= date_str)]
|
||||
vrow = sub.iloc[-1] if not sub.empty else None
|
||||
else:
|
||||
vrow = None
|
||||
def gbs(k):
|
||||
return _to_float(vrow.get(k)) if vrow is not None else None
|
||||
row["pe_ratio"] = _or_nan(gbs("peTTM"))
|
||||
row["pb_ratio"] = _or_nan(gbs("pbMRQ"))
|
||||
row["ps_ratio"] = _or_nan(gbs("psTTM"))
|
||||
row["pcf_ratio"] = _or_nan(gbs("pcfNcfTTM"))
|
||||
# 2. 市值/股本 + 三表 ← static akshare(复用 LocalParquetProvider 读法)
|
||||
# 复用:直接实例化 LocalParquetProvider 读 static 部分,或内联读 static/valuation
|
||||
ak_val = self._read_akshare_valuation(fc, date_str) # 返 renamed Series
|
||||
mkt = _to_float(ak_val.get("total_market_cap")) if ak_val is not None else None
|
||||
circ = _to_float(ak_val.get("circ_market_cap")) if ak_val is not None else None
|
||||
row["market_cap"] = to_yi(mkt) if mkt else float("nan")
|
||||
row["circulating_market_cap"] = to_yi(circ) if circ else float("nan")
|
||||
# 3. 三表(income/balance)— 复用 LocalParquetProvider._read_quarter + 字段提取
|
||||
# 简化:委托一个内部 LocalParquetProvider 实例读三表部分(eps/margin/liability)
|
||||
lpp = self._get_lpp_helper()
|
||||
inc = lpp._latest_row_before(lpp._read_quarter("income", fc), "REPORT_DATE", date_str)
|
||||
bal = lpp._latest_row_before(lpp._read_quarter("balance", fc), "REPORT_DATE", date_str)
|
||||
row["eps"] = _or_nan(_to_float(inc.get("BASIC_EPS")) if inc is not None else None)
|
||||
# ... net_profit_margin/total_liability/roe 等(照 LocalParquetProvider._build_fundamental_row 逻辑)
|
||||
return row
|
||||
```
|
||||
(实现时:`_get_lpp_helper()` 返一个复用的 `LocalParquetProvider(config)` 实例读 static 三表;`_read_akshare_valuation` 复用 LocalParquetProvider._read_valuation。DRY:不重写三表/akshare valuation 逻辑,委托 LocalParquetProvider。pe/pb 改 baostock 源覆盖 akshare 的。)
|
||||
|
||||
- [ ] **Step 4: 跑测试 PASS**
|
||||
- [ ] **Step 5: 写测试 — 三表字段(eps/market_cap 全 _FUNDAMENTAL_COLUMNS 有值不 NaN)**
|
||||
- [ ] **Step 6: 实现 + PASS**
|
||||
- [ ] **Step 7: Commit** — `feat(portfolio): LocalUnifiedProvider fundamentals baostock估值+akshare市值(Task3)`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 辅助方法(trade_days/all_securities/security_info/current_tick/split_dividend)
|
||||
|
||||
**Files:** Modify `local_unified_provider.py`; Test 同文件。
|
||||
|
||||
- [ ] **Step 1-2: 写失败测试 + FAIL** — `get_trade_days(count=2)` 返 datetime list;`get_security_info` 返 display_name/start_date;`get_current_tick` 返 close+high_limit;`get_split_dividend` 返 bs_adjust_factor 事件;`get_all_securities` 返 dbbardata distinct symbol。
|
||||
|
||||
- [ ] **Step 3: 实现**:
|
||||
- `get_trade_days`: 读 dbbardata 某 symbol(如 600519)distinct datetime,filter/count。
|
||||
- `get_security_info`: dbbardata min/max datetime → start/end_date;display_name 从 constituent_unified code_name 或 code。
|
||||
- `get_current_tick`: dbbardata 最近 close + valuation_baostock 最近 pctChg → high_limit=close×1.1(ST 0.05)。
|
||||
- `get_split_dividend`: bs_adjust_factor → events(dividOperateDate + adjustFactor)。
|
||||
- `get_all_securities`: dbbardata distinct symbol → DataFrame。
|
||||
|
||||
- [ ] **Step 4: 跑测试 PASS**
|
||||
- [ ] **Step 5: Commit** — `feat(portfolio): LocalUnifiedProvider 辅助方法(Task4)`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 接线(__init__ 导出 + runner build_provider 加 unified)
|
||||
|
||||
**Files:** Modify `sanguo_portfolio/providers/__init__.py`; Modify `sanguo_portfolio/runner_backtest.py`。
|
||||
|
||||
- [ ] **Step 1: __init__.py 加导出**
|
||||
```python
|
||||
from .local_unified_provider import LocalUnifiedProvider
|
||||
__all__ = ["SanguoMiniQmtProvider", "BaostockProvider", "LocalParquetProvider", "LocalUnifiedProvider"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: runner_backtest build_provider 加 unified**
|
||||
```python
|
||||
# parse_args choices 加 "unified"; build_provider 加分支
|
||||
p.add_argument("--provider", default="local", choices=["local", "baostock", "miniqmt", "unified"], ...)
|
||||
# build_provider:
|
||||
from .providers import LocalUnifiedProvider
|
||||
if name == "unified":
|
||||
return LocalUnifiedProvider(cfg)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑 `pytest tests/portfolio/ -v` 全绿(回归)**
|
||||
- [ ] **Step 4: Commit** — `feat(portfolio): 接线 LocalUnifiedProvider 到 runner(Task5)`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 使用说明 + VPS E2E 验证
|
||||
|
||||
**Files:** Create `docs/portfolio_local_unified_provider.md`; VPS 跑 `python -m sanguo_portfolio.runner_backtest --provider unified --start 2024-01-01 --end 2024-03-31 --max-pool 20`。
|
||||
|
||||
- [ ] **Step 1: 写使用说明** `docs/portfolio_local_unified_provider.md`(其他 session 直用)— 含:
|
||||
- 一句话定位(读方案A权威层/零online/治偏差)
|
||||
- 数据源映射表(每接口→哪张表/parquet)
|
||||
- 接口清单(DataProvider 接口 + get_constituent)
|
||||
- 复权说明(raw存储+消费端按bs_adjust_factor算qfq;fq参数 raw/qfq)
|
||||
- **幸存者偏差说明**(constituent_unified 并集模型,治纯当前偏差,有轻微前视,date 参数忽略;中证1000/2000只快照永久gap)
|
||||
- Mac 测试(fixture,零VPS依赖)
|
||||
- 部署/运行(runner --provider unified;VPS 数据依赖 dbbardata/constituent_unified/valuation_baostock/static)
|
||||
- 已知限制(high_limit 列 NaN→prepare_stock_list 涨停识别降级;1m 无数据;三表委托 LocalParquetProvider)
|
||||
- 与旧 provider 关系(LocalParquetProvider/BaostockProvider 保留,unified 是方案A 后推荐)
|
||||
|
||||
- [ ] **Step 2: VPS E2E** — rsync 代码到 VPS,跑 `--provider unified --max-pool 20` 小样本回测,确认:
|
||||
- get_price 读 dbbardata 出 K 线(含退市)
|
||||
- get_index_stocks 出并集成份股
|
||||
- get_fundamentals_df 出市值+pe/pb
|
||||
- 回测不崩,有选股+指标输出
|
||||
|
||||
- [ ] **Step 3: Commit** — `docs(portfolio): LocalUnifiedProvider 使用说明+VPS E2E(Task6)`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review(plan 自检)
|
||||
|
||||
1. **Spec 覆盖**: spec §6 接口(get_daily/get_constituent/get_fundamentals/...)— get_constituent 别名✓;get_price 覆盖 get_daily+get_etf_daily(都读 dbbardata,ETF 也在);get_fundamentals_df ✓;其余 §6 方法(industry/longhubang/instrument)数据层未就绪(P1),使用说明标注 NotImplementedError。✓
|
||||
2. **方案A §14 一致**: dbbardata 唯一行情✓;constituent_unified 治偏差✓;valuation_baostock pe/pb✓;raw+factor 复权✓;零online✓。
|
||||
3. **类型一致**: `_build_qfq_factor(code, conn, dates) -> Series` 在 Task0/Task1 调用签名一致✓。
|
||||
4. **占位扫描**: Task3 的 `_get_lpp_helper/_read_akshare_valuation` 标了"复用 LocalParquetProvider",实现 agent 须内联或委托,不留空✓。
|
||||
5. **风险**: get_price 的 high_limit 列缺失(NaN)→策略 prepare_stock_list 涨停识别降级,使用说明标注(Task6)✓。
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-07-23-local-unified-provider.md`.
|
||||
@@ -0,0 +1,815 @@
|
||||
# 数据平台每日增量更新 — 详细设计文档
|
||||
|
||||
**项目**: sanguo_vnpy 数据平台
|
||||
**作者**: 赵云(数据总管)
|
||||
**日期**: 2026-05-06
|
||||
**版本**: v2.0
|
||||
**状态**: 待评审(重大架构变更)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
经过 P1(日线导入)和 P3(15分钟线下载导入),数据平台已建成:
|
||||
|
||||
| 数据类型 | 存储 | 覆盖范围 | 数据量 |
|
||||
|---------|------|---------|--------|
|
||||
| 日线行情 | NAS Parquet (`/Volumes/stock/A股数据/日线数据/daily/{year}/`) | 2010~2026 全市场 | ~5000只/年 |
|
||||
| 15min分钟线 | NAS Parquet (`/Volumes/stock/minute_kline/15min/`) | 2025-09~2026-04 全市场 | 5193只 |
|
||||
| vnpy主库 | NAS SQLite (`/Volumes/stock/sanguo_vnpy/data/quant_trading.db`) | 同上 | 1.4GB, 1281万行 |
|
||||
| vnpy DB备份 | NAS (`.bak`) | 2026-05-02 | 330MB |
|
||||
|
||||
**问题**:数据是静态快照,没有自动更新机制。每次更新需手动执行脚本。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
1. **每日自动增量更新**:交易日收盘后自动更新日线+15min数据
|
||||
2. **多数据源整合**:保留所有数据源访问方式,取各源最优数据合并
|
||||
3. **数据最大化**:历史数据尽量完整,增量数据每日累积
|
||||
4. **部署集成**:最终整合到 sanguo_vnpy 项目统一部署(待实现)
|
||||
|
||||
---
|
||||
|
||||
## 二、数据源调研
|
||||
|
||||
### 2.1 已验证的数据源
|
||||
|
||||
| 源 | 接口 | 可用性 | 历史深度 | 限频 | 适用场景 |
|
||||
|---|---|---|---|---|---|
|
||||
| **新浪财经** | `quotes.sina.cn/.../getKLineData` | ✅ Mac可用 | 15min: 800条(~3个月), 日线: 800条(~3年), 60min: 800条(~10月) | 0.3s/请求无封禁 | 15min增量、日线增量 |
|
||||
| **腾讯财经** | `web.ifzq.gtimg.cn/.../fqkline` | ⚠️ 偶尔连接重置 | 日线: 按日期范围查询,可获取多年 | 无明显限制 | 日线增量(主源) |
|
||||
| **东方财富** | `push2his.eastmoney.com/.../kline` | ❌ Mac直连被拒 | 理论上可指定任意日期范围 | 未知 | 历史回补(需Windows环境) |
|
||||
| **akshare** | `stock_zh_a_hist_min_em` / `stock_zh_a_hist` | ⚠️ 走东方财富,受代理影响 | 理论完整 | 有代理污染问题 | 备用(需网络正常时) |
|
||||
| **腾讯 minute/query** | `web.ifzq.gtimg.cn/.../minute/query` | ⚠️ 仅当天1min数据 | 仅当天 | 未知 | 当天1min→聚合15min(备源) |
|
||||
|
||||
### 2.2 数据源限制详情
|
||||
|
||||
**新浪财经 K线API**:
|
||||
- URL: `https://quotes.sina.cn/cn/api/jsonp_v2.php/var%20=min15_{symbol}=/CN_MarketDataService.getKLineData?symbol={symbol}&scale={period}&ma=no&datalen={count}`
|
||||
- `datalen` 参数最大有效值: **800**(超过返回null)
|
||||
- `scale` 支持: 5, 15, 30, 60, 240(日线)
|
||||
- 字段: day, open, high, low, close, volume, amount
|
||||
- amount为真实成交额
|
||||
- 时间戳为end-of-bar格式
|
||||
- 返回JSONP,需正则提取JSON数组
|
||||
|
||||
**腾讯财经 fqkline API**:
|
||||
- URL: `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={symbol},{period},{start},,{days},`
|
||||
- 支持按日期范围查询
|
||||
- 返回格式: `[date, open, close, high, low, volume]` 或 7列含amount
|
||||
- amount有时为0(不完整)
|
||||
|
||||
**东方财富 K线API**:
|
||||
- URL: `http://push2his.eastmoney.com/api/qt/stock/kline/get?secid={market}.{code}&klt={period}&fqt=1&beg={start}&end={end}`
|
||||
- Mac环境直连被拒绝(Connection reset / 502)
|
||||
- 可能与IP/地区/UA有关
|
||||
- **Windows Node(192.168.2.33)待验证**:Node当前离线
|
||||
|
||||
### 2.3 多数据源策略
|
||||
|
||||
```
|
||||
数据源选择优先级(按数据质量排序):
|
||||
|
||||
日线增量更新:
|
||||
主源: 腾讯 fqkline(支持日期范围,amount有时为0)
|
||||
备源: 新浪 getKLineData scale=240(固定800条,amount真实)
|
||||
|
||||
15min增量更新:
|
||||
主源: 新浪 getKLineData scale=15(固定800条,amount真实,稳定可靠)
|
||||
备源: 腾讯 minute/query → 聚合15min(仅当天数据)
|
||||
|
||||
历史回补(15min更早的历史):
|
||||
首选: 东方财富(需Windows环境,可指定日期范围)
|
||||
备选: akshare stock_zh_a_hist_min_em(依赖东方财富,需网络正常)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、系统设计
|
||||
|
||||
### 3.1 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 定时调度层 (OpenClaw Cron) │
|
||||
│ 每交易日 15:35 触发 daily_update_all.sh │
|
||||
└───────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ daily_all_update.py (主脚本) │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ 日线增量更新 │ │ 15min增量更新 │ │
|
||||
│ │ 腾讯fqkline(主) │ │ 新浪API(主) │ │
|
||||
│ │ 新浪(备) │ │ 腾讯聚合(备) │ │
|
||||
│ └────────┬────────┘ └──────────┬───────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 数据校验层 │ │
|
||||
│ │ 价格>0 | OHLC一致性 | 去重 | 类型兼容 │ │
|
||||
│ └─────────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────┴────────────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Parquet写入 │ │ vnpy DB写入 │ │
|
||||
│ │ 原子写入(.tmp) │ │ 本地tmp→ATTACH导入 │ │
|
||||
│ │ 增量合并 │ │ NAS SQLite │ │
|
||||
│ └─────────────────┘ └──────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 文件结构
|
||||
|
||||
```
|
||||
~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/
|
||||
├── daily_all_update.py # 主脚本:全市场增量更新(日线+15min)
|
||||
├── daily_update_all.sh # Shell wrapper,由cron调用
|
||||
├── download_minute.py # 15min全量/批量下载脚本(保留)
|
||||
├── import_vnpy_minute.py # 分钟线导入vnpy DB(保留)
|
||||
├── import_vnpy_daily_fast.py # 日线全量导入脚本(保留,首次用)
|
||||
├── updater.py # 旧版日线更新脚本(保留)
|
||||
├── daily_update.sh # 旧版wrapper(保留)
|
||||
├── fallback.py # 降级工具(保留)
|
||||
├── validator.py # 数据验证工具(保留)
|
||||
└── logs/ # 日志目录
|
||||
```
|
||||
|
||||
### 3.3 核心流程
|
||||
|
||||
#### 3.3.1 日线增量更新
|
||||
|
||||
```
|
||||
1. 扫描全市场股票列表(从 stock_basic_info CSV)
|
||||
2. 对每只股票:
|
||||
a. 获取Parquet中最后日期
|
||||
b. 计算需要补充的日期范围(last_date+1 ~ today)
|
||||
c. 如果已是最新,跳过
|
||||
d. 调用腾讯fqkline API获取增量数据
|
||||
e. 数据校验(价格>0, 类型一致)
|
||||
f. 增量合并到年度Parquet文件(原子写入)
|
||||
g. 收集vnpy DB写入数据
|
||||
3. 批量写入vnpy DB(本地tmp → ATTACH导入NAS DB)
|
||||
```
|
||||
|
||||
#### 3.3.2 15分钟线增量更新
|
||||
|
||||
```
|
||||
1. 扫描全市场股票列表
|
||||
2. 对每只股票:
|
||||
a. 调用新浪API获取最近800条15min数据
|
||||
b. 数据校验(价格>0, OHLC一致性)
|
||||
c. 与已有Parquet增量合并(drop_duplicates keep='last')
|
||||
d. 原子写入Parquet
|
||||
e. 计算新增行数,收集vnpy DB写入数据
|
||||
3. 批量写入vnpy DB
|
||||
```
|
||||
|
||||
#### 3.3.3 vnpy DB写入策略(解决SMB性能问题)
|
||||
|
||||
**问题**:NAS通过SMB挂载在Mac上,直接对1.4GB SQLite文件进行频繁读写:
|
||||
- 查询超时(>20秒无响应)
|
||||
- 写入可能触发SIGKILL(进程被系统终止)
|
||||
- SMB文件锁与SQLite锁冲突风险
|
||||
|
||||
**方案:本地临时DB → ATTACH导入**
|
||||
|
||||
```
|
||||
1. 在 /tmp/ 创建本地SQLite DB,写入增量数据
|
||||
2. ATTACH NAS DB
|
||||
3. INSERT OR REPLACE ... SELECT 从本地导入NAS DB
|
||||
4. 更新 dbbaroverview 表
|
||||
5. DETACH,删除本地临时文件
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 增量数据先在本地SSD写完,与NAS交互只有一次批量INSERT
|
||||
- 减少SMB文件操作次数
|
||||
- INSERT OR REPLACE保证幂等性
|
||||
|
||||
**风险与缓解**:
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| ATTACH时NAS DB被锁定 | timeout=120秒等待 |
|
||||
| 中途失败导致DB不一致 | WAL模式 + INSERT OR REPLACE幂等 |
|
||||
| overview表更新慢 | 只在全部数据导入后执行一次 |
|
||||
|
||||
### 3.4 数据校验规则
|
||||
|
||||
| 规则 | 说明 | 实现 |
|
||||
|------|------|------|
|
||||
| 价格>0 | close/open ≤ 0 的行丢弃 | `(df[["close","open"]] <= 0).any(axis=1)` |
|
||||
| OHLC一致性 | high < max(open,close) 或 low > min(open,close) 的行丢弃 | 逐行比较 |
|
||||
| 去重 | 相同day/date保留最新 | `drop_duplicates(subset=["day"], keep="last")` |
|
||||
| 类型兼容 | volume/amount保持object与已有Parquet一致 | `.astype(str)` |
|
||||
| NaN处理 | 价格NaN行丢弃,volume/amount NaN填0 | `fillna(0)` + `dropna` |
|
||||
| 日期格式 | 日线: YYYY-MM-DD(str), 15min: YYYY-MM-DD HH:MM:SS(str) | 统一astype(str)避免混合类型 |
|
||||
|
||||
### 3.5 断点续传
|
||||
|
||||
- 15min更新:进度文件 `/Volumes/stock/logs/daily_update/progress/15min_progress.json`
|
||||
- 记录已完成的股票代码列表
|
||||
- 中断后重启自动跳过已完成的
|
||||
- 日线更新:通过检查Parquet最后日期判断,天然幂等
|
||||
- 日志:`/Volumes/stock/logs/daily_update/update_{timestamp}.log`
|
||||
- 报告:`/Volumes/stock/logs/daily_update/report_{date}.json`
|
||||
|
||||
### 3.6 限频与容错
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 请求间隔 | 0.3秒 | 避免触发源站限频 |
|
||||
| 单股重试 | 3次 | 失败后重试,间隔1秒 |
|
||||
| 连续失败暂停 | 10次连续失败后暂停60秒 | 防止批量封禁 |
|
||||
| 超时 | 15秒/请求 | 单次请求超时 |
|
||||
| DB写入超时 | 120秒 | SMB写入等待 |
|
||||
|
||||
---
|
||||
|
||||
## 四、vnpy DB Schema 参考
|
||||
|
||||
```sql
|
||||
-- 主数据表
|
||||
CREATE TABLE dbbardata (
|
||||
symbol VARCHAR(32),
|
||||
exchange VARCHAR(32),
|
||||
datetime VARCHAR(64),
|
||||
interval VARCHAR(8),
|
||||
volume FLOAT,
|
||||
turnover FLOAT,
|
||||
open_interest FLOAT,
|
||||
open_price FLOAT,
|
||||
high_price FLOAT,
|
||||
low_price FLOAT,
|
||||
close_price FLOAT,
|
||||
PRIMARY KEY (symbol, exchange, interval, datetime)
|
||||
);
|
||||
|
||||
-- 概览表
|
||||
CREATE TABLE dbbaroverview (
|
||||
symbol VARCHAR(32),
|
||||
exchange VARCHAR(32),
|
||||
interval VARCHAR(8),
|
||||
count INT,
|
||||
start VARCHAR(64),
|
||||
end VARCHAR(64),
|
||||
PRIMARY KEY (symbol, exchange, interval)
|
||||
);
|
||||
```
|
||||
|
||||
**interval值说明**:
|
||||
- `d` = 日线
|
||||
- `15m` = 15分钟线(v1.1修正:与司马懿确认,采用方案B)
|
||||
|
||||
**方案B实现**(2026-05-03 司马懿评审确认):
|
||||
1. vnpy Interval枚举加 `MINUTE_15 = "15m"`(monkey patch方式注入,不依赖vnpy版本)
|
||||
2. executor INTERVAL_MAP 改 `"15m" = Interval.MINUTE_15`
|
||||
3. DB迁移:`UPDATE dbbardata SET interval="15m" WHERE interval="1m" AND ...`
|
||||
4. DB中现有"1m"数据需要一次迁移(迁移前备份)
|
||||
|
||||
---
|
||||
|
||||
## 五、多数据源保留策略
|
||||
|
||||
### 5.1 当前实现
|
||||
|
||||
| 数据源 | 代码文件 | 状态 |
|
||||
|--------|---------|------|
|
||||
| 新浪财经 | `daily_all_update.py` 中的 `try_sina_15min()` | ✅ 在用 |
|
||||
| 腾讯fqkline | `daily_all_update.py` 中的 `fetch_tencent_daily()` | ✅ 在用 |
|
||||
| 腾讯minute/query | `download_minute.py` 中的 `try_minute_query_aggregate()` | ✅ 已实现,作为备源 |
|
||||
| 东方财富 | 未实现 | ❌ 待开发(需Windows环境) |
|
||||
| akshare | `daily_all_update.py` 外部依赖 | ⚠️ 受代理影响 |
|
||||
|
||||
### 5.2 设计原则
|
||||
|
||||
1. **所有数据源接口统一保留**,不删除任何已有的数据源访问代码
|
||||
2. **数据合并策略**:同一股票同一周期从多个源获取时,按优先级选择:
|
||||
- amount(成交额):优先有真实值的源(新浪 > 腾讯)
|
||||
- 数据长度:优先历史更长的源
|
||||
- 数据时效:优先更新的源
|
||||
3. **源降级链**:主源失败自动尝试备源,不丢数据
|
||||
4. **源标记**:Parquet文件可选增加 `_source` 列标记数据来源(待讨论)
|
||||
|
||||
### 5.3 未来扩展点
|
||||
|
||||
- 东方财富API集成(需Windows Node)
|
||||
- akshare作为备用日线源(网络恢复后)
|
||||
- 1min/5min/30min/60min等其他周期
|
||||
- 北交所920xxx数据(需新数据源)
|
||||
|
||||
---
|
||||
|
||||
## 六、SMB/NAS 性能问题与方案
|
||||
|
||||
### 6.1 已知问题
|
||||
|
||||
| 问题 | 现象 | 影响 |
|
||||
|------|------|------|
|
||||
| SMB读大文件慢 | 1.4GB SQLite查询超时(>20s) | 无法直接在Mac上操作NAS DB |
|
||||
| SMB写大文件卡死 | 进程被SIGKILL | 全量导入必须用本地中转 |
|
||||
| SMB文件锁冲突 | SQLite WAL模式可能异常 | 并发写入风险 |
|
||||
| Parquet小文件延迟 | 5300个parquet文件,SMB逐个读写 | 全量更新约30分钟 |
|
||||
|
||||
### 6.2 当前方案
|
||||
|
||||
```
|
||||
写入流程(NAS DB):
|
||||
本地/tmp写SQLite → ATTACH NAS DB → INSERT OR REPLACE → DETACH → 删除临时文件
|
||||
|
||||
写入流程(Parquet):
|
||||
内存中合并 → 写本地.tmp → rename到NAS路径
|
||||
```
|
||||
|
||||
### 6.3 待讨论:是否直接在NAS本地执行
|
||||
|
||||
NAS (192.168.2.154) 上运行的是 Linux,如果能SSH执行Python脚本:
|
||||
- SQLite直接本地读写,无SMB延迟
|
||||
- Parquet直接本地写入
|
||||
- 速度提升10倍以上
|
||||
|
||||
**方案A(当前)**:Mac上跑脚本,SMB读写NAS
|
||||
- 优点:无需SSH,利用Mac环境
|
||||
- 缺点:SMB性能瓶颈
|
||||
|
||||
**方案B(建议)**:NAS上直接跑脚本(需姜维配合SSH/容器环境)
|
||||
- 优点:无SMB瓶颈,速度快
|
||||
- 缺点:需要NAS上有Python环境
|
||||
|
||||
**方案C(折中)**:Parquet写NAS(小文件SMB可接受),SQLite写Docker容器内(通过HTTP API)
|
||||
- 优点:各取所长
|
||||
- 缺点:需要开发写入API
|
||||
|
||||
> 📌 **待与司马懿讨论**:NAS性能问题的最终解决方案
|
||||
|
||||
---
|
||||
|
||||
## 七、定时任务配置
|
||||
|
||||
### 7.1 当前方案(OpenClaw Cron)
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| 调度 | 每交易日(周一到周五)15:35 |
|
||||
| 时区 | Asia/Shanghai |
|
||||
| 执行方式 | isolated session(不消耗主session token) |
|
||||
| 超时 | 3600秒(1小时) |
|
||||
| 通知 | 完成后飞书通知 |
|
||||
|
||||
### 7.2 Cron表达式
|
||||
|
||||
```
|
||||
35 15 * * 1-5 # 周一到周五 15:35
|
||||
```
|
||||
|
||||
### 7.3 注意事项
|
||||
|
||||
- 非交易日也会触发,但脚本会检测无新数据后快速退出(所有股票都skipped)
|
||||
- 未来可增加交易日历判断(如使用akshare获取交易日历)
|
||||
|
||||
---
|
||||
|
||||
## 八、部署方案(待实现)
|
||||
|
||||
### 8.1 当前部署状态
|
||||
|
||||
- 脚本路径:`~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/`
|
||||
- 运行环境:Mac mini(楚锋的Mac mini),Python 3.9
|
||||
- 调度:OpenClaw Cron
|
||||
- 数据存储:NAS SMB挂载 `/Volumes/stock/`
|
||||
|
||||
### 8.2 目标部署(整合到sanguo_vnpy项目)
|
||||
|
||||
**待实现,设计如下**:
|
||||
|
||||
```
|
||||
sanguo_vnpy/
|
||||
├── deploy/
|
||||
│ ├── docker-compose.yml # 包含数据更新服务
|
||||
│ └── data-updater/
|
||||
│ ├── Dockerfile # 数据更新容器
|
||||
│ ├── crontab # 容器内crontab
|
||||
│ └── entrypoint.sh
|
||||
├── src/
|
||||
│ └── data_platform/ # 数据平台代码(从data_platform/迁移)
|
||||
│ ├── daily_all_update.py
|
||||
│ ├── download_minute.py
|
||||
│ ├── import_vnpy_daily_fast.py
|
||||
│ ├── import_vnpy_minute.py
|
||||
│ └── ...
|
||||
├── docs/
|
||||
│ └── data-platform/
|
||||
│ └── daily-update-design.md # 本文档
|
||||
└── config/
|
||||
└── data_platform.yaml # 配置文件(路径、限频参数等)
|
||||
```
|
||||
|
||||
### 8.3 部署步骤(草案)
|
||||
|
||||
1. 代码从 `~/.openclaw/sanguo_projects/` 迁移到 `sanguo_vnpy/src/data_platform/`
|
||||
2. 配置外置为YAML文件
|
||||
3. Docker容器内置crontab + Python脚本
|
||||
4. 容器挂载NAS数据目录
|
||||
5. 与现有vnpy回测服务docker-compose整合
|
||||
|
||||
---
|
||||
|
||||
## 九、测试
|
||||
|
||||
### 9.1 已完成的测试
|
||||
|
||||
| 测试项 | 结果 | 日期 |
|
||||
|--------|------|------|
|
||||
| 日线增量更新3只(000001/600519/300750) | ✅ 3只skipped(已是最新) | 2026-05-03 |
|
||||
| 15min增量更新3只 | ✅ 3只ok,0 failed | 2026-05-03 |
|
||||
| 全市场15min下载(5193只) | ✅ 完成,107只北交所失败(源不支持) | 2026-05-02 |
|
||||
| vnpy DB日线全量导入(1281万行) | ✅ 回测验证通过 | 2026-05-02 |
|
||||
| vnpy DB 15min导入(单只验证) | ✅ 1970行,16个时间点正确 | 2026-05-02 |
|
||||
|
||||
### 9.2 待测试项
|
||||
|
||||
| 测试项 | 方法 | 优先级 |
|
||||
|--------|------|------|
|
||||
| 全市场增量更新完整流程 | cron触发后检查report | P0 |
|
||||
| NAS离线时脚本行为 | umount后运行,验证优雅退出 | P0 |
|
||||
| DB写入并发安全 | 两个脚本同时写DB | P1 |
|
||||
| 东方财富API(Windows) | Windows Node上线后测试 | P2 |
|
||||
| 非交易日执行 | 周末运行,验证全部skipped | P1 |
|
||||
| 30天连续运行稳定性 | 观察一个月的report | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 十、Q&A — 讨论过的问题汇总
|
||||
|
||||
### Q1: Parquet双写是什么意思?还需要吗?
|
||||
|
||||
**讨论**:原TODO #4提到Parquet作为真相源(source of truth)与vnpy DB双写。
|
||||
**结论**:当前架构中 Parquet 是下载的**原始产出**,vnpy DB 是**导入产物**。Parquet本身就是备份。不需要额外的双写机制。真正需要的是**vnpy DB的定时备份**(当前.bak只备份一次)。
|
||||
|
||||
### Q2: 新浪API只能拿800条,怎么获取更长的历史?
|
||||
|
||||
**讨论**:新浪 `datalen=800` 是硬限制,超过800返回null。实测15min=3个月,日线=3年。
|
||||
**结论**:
|
||||
- 增量更新场景:每日800条足够覆盖最新数据,历史在Parquet中累积
|
||||
- 历史回补:需要东方财富API(可指定日期范围),但Mac被拒,需Windows环境
|
||||
- 另一条路:如果之前有更长的CSV数据(如84只深市老数据有1970行),合并进Parquet
|
||||
|
||||
### Q3: vnpy DB的interval为什么是"1m"而不是"15m"?
|
||||
|
||||
**讨论**:vnpy 4.x的Interval枚举只有 `MINUTE="1m"`,没有 `MINUTE_15`。Docker用的是原始vnpy。
|
||||
**结论**:DB中15分钟线用 `interval="1m"` 存储,与BacktestingEngine `load_data(interval="1m")` 匹配。如果未来引入真正的1分钟线,需要重新设计interval值。
|
||||
|
||||
### Q4: 北交所107只股票怎么办?
|
||||
|
||||
**讨论**:新浪行情源不支持920xxx代码。
|
||||
**结论**:当前不影响(HS300无北交所),后续如需支持需引入新数据源(如东方财富)。
|
||||
|
||||
### Q5: 为什么不直接在NAS上跑脚本?
|
||||
|
||||
**讨论**:Mac通过SMB访问NAS,大文件操作慢且不稳定(SIGKILL)。
|
||||
**结论**:当前用本地tmp中转方案缓解。长期建议在NAS本地执行(需SSH/容器环境),或通过Docker容器HTTP API写入。
|
||||
|
||||
### Q6: amount(成交额)数据准确性?
|
||||
|
||||
**讨论**:腾讯fqkline的amount有时返回0,新浪API的amount是真实值。
|
||||
**结论**:
|
||||
- 15min:用新浪(amount真实)
|
||||
- 日线:用腾讯(amount可能为0,但支持日期范围查询更重要)
|
||||
- 未来可考虑用新浪的amount覆盖腾讯的0值
|
||||
|
||||
### Q7: 每日增量更新多长时间?
|
||||
|
||||
**预估**:
|
||||
- 日线:5300只 × 0.3s ≈ 26分钟(大部分skipped更快)
|
||||
- 15min:5300只 × 0.3s ≈ 26分钟
|
||||
- DB写入:取决于增量数据量,通常几百条
|
||||
- **总计约30-50分钟**
|
||||
|
||||
### Q8: 如何处理节假日/非交易日?
|
||||
|
||||
**当前方案**:非交易日执行时,所有股票都检测到"已是最新"被skipped,快速退出(<1分钟)。
|
||||
**改进方向**:可增加交易日历判断,非交易日直接不执行(节省一次扫描)。
|
||||
|
||||
### Q9: 数据更新和回测服务会冲突吗?
|
||||
|
||||
**风险**:更新脚本和回测服务同时读写同一个vnpy DB。
|
||||
**缓解**:回测服务在Docker容器内操作自己的DB副本(`/home/vnpy/.vntrader/database.db`),与NAS上的DB是不同文件。NAS DB更新后需要同步到Docker(目前手动wget)。
|
||||
**待改进**:自动化DB同步机制(cron或文件监控)。
|
||||
|
||||
### Q10: 代码部署为什么要和sanguo_vnpy整合?
|
||||
|
||||
**理由**:
|
||||
1. 数据平台是为vnpy回测服务的,放一起管理方便
|
||||
2. Docker统一部署,减少环境依赖
|
||||
3. 配置集中管理(NAS路径、限频参数等)
|
||||
|
||||
---
|
||||
|
||||
## 十一、文件清单
|
||||
|
||||
| 文件 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `daily_all_update.py` | `sanguo_vnpy/data_platform/` | 主脚本:全市场增量更新 |
|
||||
| `daily_update_all.sh` | `sanguo_vnpy/data_platform/` | Shell wrapper |
|
||||
| `download_minute.py` | `sanguo_vnpy/data_platform/` | 15min全量下载(保留) |
|
||||
| `import_vnpy_minute.py` | `sanguo_vnpy/data_platform/` | 分钟线导入DB(保留) |
|
||||
| `import_vnpy_daily_fast.py` | `sanguo_vnpy/data_platform/` | 日线全量导入(保留) |
|
||||
| `updater.py` | `sanguo_vnpy/data_platform/` | 旧版日线更新(保留) |
|
||||
| `daily_update.sh` | `sanguo_vnpy/data_platform/` | 旧版wrapper(保留) |
|
||||
|
||||
---
|
||||
|
||||
## 十二、变更记录
|
||||
|
||||
| 日期 | 版本 | 变更 | 作者 |
|
||||
|------|------|------|------|
|
||||
| 2026-05-03 | v1.0-draft | 初始版本 | 赵云 |
|
||||
| 2026-05-03 | v1.1 | 司马懿评审后修改:interval→15m, 严格增量追加, 日线进度文件, 全局源检测, DB轮转备份, 失败率告警 | 赵云 |
|
||||
| 2026-05-05 | v1.2 | 东方财富集成:日线主源切换为东方财富(amount真实,反爬策略4s/请求+随机抖动), 腾讯降为备源 | 赵云 |
|
||||
| 2026-05-06 | v2.0 | **重大架构变更**:BaoStock替代所有主源(无反爬、全量历史、amount真实);15min interval改为1m;vnpy DB写入改为本地构建+rsync;新浪API已挂移除;多源fallback机制重构 | 赵云 |
|
||||
| 2026-07-10 | v3.0 | **双源架构+脚本重构**:raw/qfq双源(task#79,5yr全市场29600×2); run_daily_update.sh重写(raw+qfq增量,跳daily_all_update新浪坏接口,持久路径,不set-e); baostock_download.py(15min双源+reconnect retry限流鲁棒); raw_redownload断点续传; launchd 15:30 | 楚锋 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、评审结果(2026-05-03 司马懿评审)
|
||||
|
||||
### v1.1 评审结论:有条件通过(已完成)
|
||||
|
||||
**2个阻塞项(已解决)**:
|
||||
1. ✅ interval="1m" → "15m":采用方案B(vnpy加MINUTE_15枚举 + monkey patch)
|
||||
2. ✅ 15min增量合并:改为严格按日期追加,不再用drop_duplicates keep=last
|
||||
|
||||
**4个硬伤(已修复)**:
|
||||
1. ✅ 日线增加进度文件
|
||||
2. ✅ 全局源不可用检测(连续30只首次失败→终止)
|
||||
3. ✅ DB轮转备份(保留7天,`quant_trading_{YYYYMMDD}.db.bak`)
|
||||
4. ✅ 失败率>5%告警标记 + 源终止告警
|
||||
|
||||
---
|
||||
|
||||
## 十四、v2.0 重大架构变更(2026-05-06)
|
||||
|
||||
### 14.1 变更背景
|
||||
|
||||
v1.2运行暴露了5个根本性问题:
|
||||
|
||||
| # | 问题 | 根因 | 影响 |
|
||||
|---|------|------|------|
|
||||
| 1 | vnpy DB写入报`no such table: dbbardata` | SMB文件锁与SQLite ATTACH不兼容 | 日线+15min数据不入DB |
|
||||
| 2 | 新浪15min API已失效 | 返回Error 0,所有请求失败 | 15min无法增量更新 |
|
||||
| 3 | BaoStock 15min回补已完成但未入库 | 原脚本interval=`15m`与vnpy不兼容 | 5193只×8992条数据闲置 |
|
||||
| 4 | 日线跨年写入bug | `year=datetime.now().year`硬编码 | 年初数据会写错目录 |
|
||||
| 5 | overview全表聚合 | 1.4G DB上GROUP BY全表扫描 | NAS上可能超时/锁死 |
|
||||
|
||||
### 14.2 数据源重新调研
|
||||
|
||||
#### 数据源实测对比
|
||||
|
||||
| 数据源 | 15min可获取量 | 日线可获取量 | amount | 反爬 | 频率 | 当前状态 |
|
||||
|--------|-------------|-------------|--------|------|------|----------|
|
||||
| **BaoStock** | 无限制(按日期) | 无限制(按日期) | 真实(5.54亿) | **无** | 0.12s/只, 100只0错误 | ✅ 稳定 |
|
||||
| **东方财富** | ~496条(7周) | 多年(~1046条) | 真实(5.54亿) | 4-5s/请求+UA+Referer | 中 | ✅ 可用 |
|
||||
| **腾讯** | Connection reset | 按日期范围 | 有时为0 | 无 | 快 | ⚠️ 不稳定 |
|
||||
| **新浪** | Error/2条 | Error/2条 | - | - | - | ❌ 已挂 |
|
||||
|
||||
**关键结论**:BaoStock在所有维度都最优(无反爬、全量历史、amount真实、速度快),应作为首选源。
|
||||
|
||||
#### v1.2 BaoStock压力测试
|
||||
|
||||
```
|
||||
15min: 100只连续请求, 总耗时11.9s, 平均0.12s/只, 0错误
|
||||
日线: 10只连续请求, 总耗时1.6s, 平均0.16s/只
|
||||
全历史: sh.600000 2010-2026日线 3963条, 0.68s
|
||||
```
|
||||
|
||||
#### v1.2 SQLite本地写入性能
|
||||
|
||||
```
|
||||
100万条INSERT OR REPLACE: 2.0s
|
||||
预估4600万条(15min全量): ~91s ≈ 1.5分钟
|
||||
```
|
||||
|
||||
### 14.3 v2.0 核心架构变更
|
||||
|
||||
#### 变更1:数据源降级链重构
|
||||
|
||||
**设计原则**:按数据质量排序,质量最好的源排第一。每个源封装独立函数,统一返回DataFrame。主循环挨个尝试,成功即用,失败试下一个。
|
||||
|
||||
```
|
||||
v1.x(旧):
|
||||
日线: 腾讯(主) → 新浪(备)
|
||||
15min: 新浪(主) → 无备源
|
||||
|
||||
v2.0(新):
|
||||
日线: BaoStock(主) → 东方财富(备) → 腾讯(三备)
|
||||
15min: BaoStock(主) → 东方财富(备) → 新浪(三备,当前已挂)
|
||||
```
|
||||
|
||||
**Fallback机制**:
|
||||
```python
|
||||
SOURCES_DAILY = [
|
||||
("baostock", fetch_baostock_daily), # 最优:全量历史+无反爬+amount真实
|
||||
("eastmoney", fetch_eastmoney_daily), # 备用:多年历史+amount真实+4s限频
|
||||
("tencent", fetch_tencent_daily), # 三备:amount有时为0
|
||||
]
|
||||
SOURCES_15MIN = [
|
||||
("baostock", fetch_baostock_15min), # 最优
|
||||
("eastmoney", fetch_eastmoney_15min), # 备用:7周
|
||||
("sina", try_sina_15min), # 三备:当前已挂
|
||||
]
|
||||
|
||||
def fetch_with_fallback(sources, code, start, end):
|
||||
for name, fetch_fn in sources:
|
||||
try:
|
||||
data = fetch_fn(code, start, end)
|
||||
if data is not None and len(data) > 0:
|
||||
return data, name
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
```
|
||||
|
||||
#### 变更2:vnpy DB写入策略改为本地构建+rsync
|
||||
|
||||
**v1.x方案(ATTACH via SMB)**:直接在Mac上ATTACH NAS DB → SMB文件锁导致失败
|
||||
|
||||
**v2.0方案(本地构建+rsync)**:
|
||||
1. 每日更新时,从NAS cp当前DB到本地`/tmp/`
|
||||
2. 所有增量数据写入本地DB
|
||||
3. 验证完整性后,rsync覆盖NAS DB
|
||||
4. 备份旧DB(轮转7天)
|
||||
|
||||
```python
|
||||
def sync_db_to_nas():
|
||||
# 备份
|
||||
backup = f"quant_trading_{today}.db.bak"
|
||||
shutil.copy2(str(VNPY_DB_PATH), str(VNPY_DB_PATH.parent / backup))
|
||||
|
||||
# rsync本地→NAS
|
||||
os.system(f"rsync -av --progress {LOCAL_DB_PATH} {VNPY_DB_PATH}")
|
||||
```
|
||||
|
||||
**性能预估**:
|
||||
- cp NAS DB到本地:~15秒(1.4G)
|
||||
- 增量写入本地DB:<1秒(日线)
|
||||
- rsync覆盖NAS:~15秒
|
||||
- 全量15min导入(首次):~1.5分钟(4600万条)
|
||||
|
||||
#### 变更3:15min interval统一用`1m`
|
||||
|
||||
**v1.x**:interval=`15m`(与vnpy 4.x不兼容)
|
||||
**v2.0**:interval=`1m`(姜维确认vnpy 4.x Interval.MINUTE.value=`1m`)
|
||||
|
||||
**数据格式(姜维确认)**:
|
||||
- symbol: `000001`(纯代码)
|
||||
- exchange: `SSE` / `SZSE`
|
||||
- interval日线: `d`
|
||||
- interval分钟线: `1m`
|
||||
|
||||
#### 变更4:日线跨年写入修复
|
||||
|
||||
**v1.x bug**:`year = datetime.now().year`,年初数据写错目录
|
||||
**v2.0**:按数据日期分目录
|
||||
|
||||
```python
|
||||
def update_daily_parquet(code, new_data):
|
||||
for yr in new_data["date"].str[:4].unique():
|
||||
year_data = new_data[new_data["date"].str[:4] == yr]
|
||||
parquet_path = DAILY_DIR / yr / f"{prefix}{clean}_daily.parquet"
|
||||
# 合并写入...
|
||||
```
|
||||
|
||||
#### 变更5:overview增量更新
|
||||
|
||||
**v1.x**:`SELECT ... FROM dbbardata GROUP BY` 全表扫描(1.4G DB上很慢)
|
||||
**v2.0**:只更新本次涉及的symbol
|
||||
|
||||
```python
|
||||
for sym, exc, ivl in affected_keys:
|
||||
c.execute("""INSERT OR REPLACE INTO dbbaroverview
|
||||
SELECT ?,?,?,COUNT(*),MIN(datetime),MAX(datetime)
|
||||
FROM dbbardata WHERE symbol=? AND exchange=? AND interval=?""",
|
||||
(sym, exc, ivl, sym, exc, ivl))
|
||||
```
|
||||
|
||||
#### 变更6:进度文件加日期
|
||||
|
||||
**v1.x**:进度文件不区分日期,跨天可能跳过
|
||||
**v2.0**:`daily_20260506_progress.json`,每次运行独立进度
|
||||
|
||||
#### 变更7:Cron fallback模型
|
||||
|
||||
**v1.x**:只用默认模型,配额用完则任务失败
|
||||
**v2.0**:设置fallback模型(zhipu/glm-5.1),配额不足时自动降级
|
||||
|
||||
### 14.4 执行计划
|
||||
|
||||
#### 第1步:灌入现有数据到本地vnpy DB
|
||||
|
||||
```
|
||||
1. cp NAS quant_trading.db → /tmp/quant_trading_import.db
|
||||
2. import_vnpy_daily_fast.py --start-year 2026 # 补3/28~今天的日线增量
|
||||
3. import_vnpy_minute.py --scope all # 全量导入5193只15min
|
||||
4. 验证数据完整性
|
||||
5. rsync本地DB → NAS
|
||||
```
|
||||
|
||||
#### 第2步:重构daily_all_update.py
|
||||
|
||||
按14.3的7个变更点重构代码。
|
||||
|
||||
#### 第3步:Cron更新+测试
|
||||
|
||||
- 更新cron任务配置
|
||||
- 手动触发一次全量更新验证
|
||||
- 确认日志无错误
|
||||
|
||||
### 14.5 与v1.x的兼容性
|
||||
|
||||
| 变更 | 向后兼容 | 影响 |
|
||||
|------|---------|------|
|
||||
| interval 15m→1m | ❌ 需要DB迁移 | 现有15m数据需UPDATE为1m |
|
||||
| DB写入策略 | ✅ 无影响 | Parquet不受影响 |
|
||||
| 数据源顺序 | ✅ 无影响 | 只是重试顺序变化 |
|
||||
| 跨年写入 | ✅ 修正bug | 未来数据不再错 |
|
||||
| overview增量 | ✅ 无影响 | 只是优化 |
|
||||
|
||||
> ⚠️ **DB迁移注意**:v1.x如果有`interval='15m'`的记录,需要一次性UPDATE为`'1m'`。当前DB中实际无15min数据(v1.x的写入全部失败),所以无需迁移。
|
||||
|
||||
---
|
||||
|
||||
## 十五、v2.0 评审待确认项
|
||||
|
||||
| # | 问题 | 建议方案 | 待确认 |
|
||||
|---|------|---------|--------|
|
||||
| 1 | BaoStock作为全主源是否合适? | 无反爬+全量+amount真实,建议通过 | 司马懿 |
|
||||
| 2 | 本地构建+rsync替代ATTACH | 姜维确认推荐,比ATTACH稳定 | 司马懿 |
|
||||
| 3 | interval=1m而非15m | 姜维确认vnpy 4.x规范 | 司马懿 |
|
||||
| 4 | 是否需要DB迁移脚本? | 当前无15m数据,无需迁移 | 司马懿 |
|
||||
| 5 | Fallback顺序是否合理? | BaoStock→东方财富→腾讯/新浪 | 司马懿 |
|
||||
| 6 | 日常更新全市场耗时预估? | BaoStock: ~10min(15min)+~8min(日线)+rsync | 司马懿 |
|
||||
| 7 | 是否需要额外反爬措施? | BaoStock无需,备源保留原有措施 | 司马懿 |
|
||||
|
||||
### 15.6 v2.0 评审结论(2026-05-06 司马懿)
|
||||
|
||||
**结论:全部通过,可以部署**
|
||||
|
||||
C1 interval=1m:姜维翻源码确认vnpy硬约束,接受。**附加前提:代码里所有写interval='1m'的地方必须加注释,说明这是vnpy 4.x Interval.MINUTE硬约束,实际存储15分钟线。**
|
||||
|
||||
C2 rsync原子性:改为写新文件+mv原子重命名。
|
||||
|
||||
M1 BaoStock T+1延迟:已验证确认。日常增量改为东方财富(当天实时) → BaoStock(T+1补全) → 腾讯。
|
||||
|
||||
M2 失败暂停:改为失败率检测(最近100只>80%切换源)。
|
||||
|
||||
**最终Fallback顺序(含T+1调整):**
|
||||
```
|
||||
日常增量(当天15:35触发):
|
||||
日线:东方财富(实时) → BaoStock(T+1) → 腾讯
|
||||
15min:东方财富(实时7周) → BaoStock(T+1) → 新浪
|
||||
|
||||
历史回补:
|
||||
日线+15min:BaoStock(全量历史,无反爬)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十六、v3.0 双源架构 + 脚本重构(2026-07-10)
|
||||
|
||||
### 16.1 背景:task#79 mixed-adjust 假跌
|
||||
|
||||
v2.0 `daily_dir` 是 mixed-adjust(hfq bulk + raw tail 拼接),3-30 类单日 -94% 假跌。C-S3 模拟盘撮合/涨跌停需真实价,策略信号需无除权缺口 → **双源**。
|
||||
|
||||
### 16.2 双源设计(raw + qfq)
|
||||
|
||||
| 源 | 用途 | adjustflag | 目录(cfg.data_paths)|
|
||||
|----|------|-----------|------|
|
||||
| **raw** | 撮合/涨跌停/成交价(真实交易价,含除权缺口)| akshare `adjust=""` / baostock `flag=3` | `raw_dir` / `minute_15_raw_dir` |
|
||||
| **qfq** | 策略 on_bar 信号(前复权,无除权缺口 → MA 信号准)| akshare `adjust="qfq"` / baostock `flag=2` | `qfq_dir` / `minute_15_qfq_dir` |
|
||||
| daily(mixed) | 仅 backtest 兼容,**模拟盘不用** | - | `daily_dir` |
|
||||
|
||||
`data_source.py _resolve_dir_key` 路由:日线/15min 均支持 raw/qfq 双源,缺配置明确报错(不 fallback 防混源)。
|
||||
|
||||
### 16.3 脚本更新清单
|
||||
|
||||
| 脚本 | 更新 | 用途 |
|
||||
|------|------|------|
|
||||
| `run_daily_update.sh` | **重写**:raw+qfq 增量(最近5天),跳 `daily_all_update`(新浪源坏 `KeyError:date`),持久路径 `data_cache/daily_update`(不进 /tmp 重启丢),`set -uo pipefail`(不 -e,单只失败不退)| 每日增量(C-S3 实走)|
|
||||
| `baostock_download.py` | **新**:15min 双源(qfq+raw),`reconnect()`+`download_one max_retries=3`(限流/broken pipe 自动 `bs.logout+login` 重连)| 15min 全量/增量 |
|
||||
| `raw_redownload.py` | 断点续传(`exists()`/skip 已存在,扩范围重下覆盖)| raw/qfq 全量/增量 |
|
||||
| `full_deploy_5yr.sh` | **新**:5yr 全量部署 pipeline(qfq→raw→rsync→验证)| 一次性部署 |
|
||||
| `verify_dual_source.py` | **新**:容器内双源验证(fetch_day/iter_bars/除权日/抽检)| 部署验证 |
|
||||
|
||||
### 16.4 部署与验证
|
||||
|
||||
- **launchd** `com.sanguo.data-update`:每日 **15:30** 跑 `run_daily_update.sh`(收盘后增量 raw+qfq 最近 5 天 → NAS;Mac 睡眠错过唤醒补跑)
|
||||
- **数据量**:日线双源 5yr 全市场(qfq+raw 各 ~29600 文件);15min 沪深300 baostock(131/300,限流续下,断点续传)
|
||||
- **准确性验证**:除权日 raw 含缺口 / qfq 平滑(浦发 2022-07-21 raw -5.9% / qfq -0.6%;宁德 2023-04-26 raw -41.8% / qfq +5.4%);跨源一致(日线 raw close = 15min raw 当日末 bar);全市场抽检无 mixed 假跌
|
||||
|
||||
### 16.5 与 v2.0 的关系
|
||||
|
||||
v3.0 在 v2.0(BaoStock 主源 + 本地构建 rsync)基础上,**新增双源(raw/qfq)** 区分撮合价与信号价,解决 mixed-adjust 假跌。`run_daily_update.sh` 从 v1 `daily_all_update` 包装改为 raw+qfq 直接增量(跳过新浪坏接口)。BaoStock 仍是 15min 主源(v2.0 遗产),日线用 akshare 新浪源 `stock_zh_a_daily`(adjust ""/qfq 双源)。
|
||||
@@ -0,0 +1,82 @@
|
||||
# LocalParquetProvider V1 数据缺口记录
|
||||
|
||||
> V1 已通过 VPS 真实数据验证(2026-07-21):
|
||||
> fundamentals 字段值合理(茅台市值 18433亿/PE 23.6/ROE 0.19/净利率 0.51)、
|
||||
> get_price/get_index_stocks/trade_days/all_securities 全通、B_mean 趋势信号正常、
|
||||
> 回测出完整 JSON(117 交易日, 0.4s/月, 无 baostock 卡死)。
|
||||
>
|
||||
> **0 交易根因(非 provider bug)**: `_pick_big_universe` 选股 target=[]
|
||||
> = `big` filter 8 条件 AND 过严 + `roic_big` 用 roic(V1 NaN) + bm market_cap 100-900亿
|
||||
> 不匹配 hs300 大盘 + B_mean<0 时兜底海外 ETF(无 K 线)。补 roic + 调 filter 阈值即出交易。
|
||||
>
|
||||
> 以下缺口不阻塞 MVP 链路验证,但全市场正式回测前需补齐。
|
||||
|
||||
## 缺口 1: 历史成分股(治幸存者偏差,重要 ⚠️)
|
||||
|
||||
**现状**: VPS `static/index_const/index_const.parquet` 仅 **2026-07-17 最新一期**快照。
|
||||
`get_index_stocks(index, date)` 的 `date` 参数当前被忽略(无历史数据可读)。
|
||||
|
||||
**影响**: 回测 2020 年选股池 = "现在还在 hs300/zz500 里的股票" → 幸存者偏差(结果虚高)。
|
||||
`max_pool` 小范围验证影响相对小(只取前 N 只),**全市场轮动回测前必须补**。
|
||||
|
||||
**补齐方案**(任选,不用 baostock online):
|
||||
- akshare `index_stock_cons_csindex(symbol="000300")` 按调仓日拉历史成分(csindex 源)
|
||||
- 中证指数官网 csindex.com.cn 历史成分下载
|
||||
- 用户侧(数据补全 session)补到 `static/index_const_history/` 多期 parquet, provider 加日期过滤
|
||||
|
||||
## 缺口 2: gross_profit_margin(V1 NaN)
|
||||
|
||||
**现状**: akshare income 表无明确"营业成本(COGS)"列(有 OPERATE_INCOME 营收、OPERATE_EXPENSE 营业总成本,但非纯 COGS)。
|
||||
V1 `gross_profit_margin` 置 NaN,策略 filter 该阈值失效(不过滤毛利率)。
|
||||
|
||||
**补齐方案**: 从 `static/financial_abstract/{code}_*.parquet` 读现成"销售毛利率"
|
||||
(宽表 指标×季度,含 1990-2026)。解析:找指标行"销售毛利率",取最新季度列。
|
||||
|
||||
## 缺口 3: roic(V1 NaN)
|
||||
|
||||
**现状**: ROIC = NOPAT / (权益 + 有息负债 - 现金),需有息负债拆分。
|
||||
V1 置 NaN。balance 表有 BORROW_FUND/BOND_PAYABLE 等字段可算。
|
||||
|
||||
**补齐方案**: balance 读 BORROW_FUND(短期借款) + BOND_PAYABLE(应付债券) + SUBBOND_PAYABLE
|
||||
+ 现金(CASH_DEPOSIT_PBC 附近字段),算 roic。NOPAT = 营业利润 ×(1 - 税率)。
|
||||
|
||||
## V1 单位口径备忘(VPS 实测验证合理 ✅)
|
||||
|
||||
| 字段 | VPS 源单位 | 转换 | 验证值(2024-06) |
|
||||
|---|---|---|---|
|
||||
| market_cap | 总市值(元) | /1e8 转亿 | 茅台 18433 亿 ✅ |
|
||||
| circulating_market_cap | 流通市值(元) | /1e8 | ✅ |
|
||||
| pe_ratio | PE(TTM) 数值 | 直接 | 茅台 23.6 ✅ |
|
||||
| pb_ratio | 市净率 数值 | 直接 | 茅台 7.69 ✅ |
|
||||
| ps_ratio/pcf_ratio | 市销率/市现率 | 直接 | ✅ |
|
||||
| eps | BASIC_EPS 元 | 直接 | 茅台 33.19 ✅ |
|
||||
| roe | 归母净利润/归母权益 | 小数(单期非TTM) | 茅台 0.19 ✅ |
|
||||
| roa | 净利润/总资产 | 小数 | ✅ |
|
||||
| net_profit_margin | 归母净利润/营收 | 小数 | 茅台 0.51 ✅ |
|
||||
| inc_revenue_yoy | OPERATE_INCOME_YOY 百分数 | /100 | 茅台 +0.18 ✅ |
|
||||
| total_liability | TOTAL_LIABILITIES 元 | /1e8 | 浦发 85000 亿 ✅ |
|
||||
| total_sheet_owner_equities | TOTAL_PARENT_EQUITY 元 | /1e8 | ✅ |
|
||||
| retained_profit | SURPLUS_RESERVE+UNASSIGN_RPOFIT | /1e8 | ✅ |
|
||||
|
||||
## VPS 两种代码格式(已适配,备忘)
|
||||
|
||||
VPS `data/` 下代码格式**不统一**:
|
||||
- **K 线** `qfq/{年}/` `raw/{年}/`: baostock 风格 `sh600000_daily.parquet`(sh/sz 前缀无点)
|
||||
→ `jq_to_kline_code("600000.XSHG") = "sh600000"`
|
||||
- **三表/估值** `static/{table}/`: jq 后缀 `000001.SZ_balance.parquet`
|
||||
→ `jq_to_file_code("000001.XSHE") = "000001.SZ"`
|
||||
|
||||
`get_price` 用 `jq_to_kline_code`,`get_fundamentals_df` 用 `jq_to_file_code`。
|
||||
|
||||
## V1 已验证可用的接口
|
||||
|
||||
| 方法 | 状态 | 备注 |
|
||||
|---|---|---|
|
||||
| get_price | ✅ | qfq 日线, 单股 index=date / 多股 panel |
|
||||
| get_fundamentals_df | ✅ | 19 列对齐 _FUNDAMENTAL_COLUMNS, 字段值合理 |
|
||||
| get_security_info | ✅ | valuation 最新行 |
|
||||
| get_trade_days | ✅ | sh600000 K 线 date 列 |
|
||||
| get_all_securities | ✅ | 5528 股 |
|
||||
| get_index_stocks | ⚠️ | 仅当前快照(缺口 1) |
|
||||
| get_current_tick | ✅ | valuation 推算 close + 涨跌停(主板±10%) |
|
||||
| get_split_dividend | ✅ | 占位返空(qfq 已复权) |
|
||||
@@ -0,0 +1,152 @@
|
||||
# LocalUnifiedProvider 使用说明
|
||||
|
||||
> spec §6 使用层 provider。读**方案A 权威数据层**,零 online,治幸存者偏差。**方案A 数据层落地后的推荐 provider**。
|
||||
> 实现见 `sanguo_portfolio/providers/local_unified_provider.py`,测试 `tests/portfolio/test_local_unified_provider.py`(36 用例)。
|
||||
|
||||
## 一句话定位
|
||||
|
||||
一个 provider,内部按数据类路由方案A 的权威表(dbbardata / constituent_unified / valuation_baostock / static akshare),**零 online**(不调 baostock HTTP,纯读本地 sqlite/parquet),**治幸存者偏差**(成份股并集含退市/被踢 + dbbardata 日线含退市),喂 `all_weather` 等策略。
|
||||
|
||||
## 快速使用
|
||||
|
||||
```python
|
||||
from sanguo_portfolio.providers import LocalUnifiedProvider
|
||||
|
||||
# VPS(默认路径 C:\sanguo_vnpy_v2\data)
|
||||
p = LocalUnifiedProvider()
|
||||
|
||||
# Mac 测试 / 自定义路径
|
||||
p = LocalUnifiedProvider({
|
||||
"db_path": "/path/to/quant_trading.db",
|
||||
"data_dir": "/path/to/data", # 含 valuation_baostock/ + static/
|
||||
})
|
||||
|
||||
# 回测入口(runner)
|
||||
# python -m sanguo_portfolio.runner_backtest --provider unified --start 2024-01-01 --end 2024-12-31
|
||||
```
|
||||
|
||||
## 数据源映射(每接口 → 方案A 权威表)
|
||||
|
||||
| 方法 | 数据源 | 表 / 文件 | 归一化 |
|
||||
|---|---|---|---|
|
||||
| `get_price` | dbbardata('d') raw + bs_adjust_factor | `quant_trading.db` | jq_code↔symbol+exchange; `SSE→SH`; raw 默认, `fq='qfq'` 按 foreAdjustFactor 算 |
|
||||
| `get_index_stocks` / `get_constituent` | constituent_unified 并集 | `quant_trading.db` | code(纯6位)→jq_code; 返回 `in_current=1 ∪ was_removed=1` |
|
||||
| `get_fundamentals_df` | pe/pb/ps/pcf ← valuation_baostock; 市值+三表 ← static akshare | `<year>.parquet` + `static/{valuation,balance,income}/` | 对齐 `_FUNDAMENTAL_COLUMNS`; 市值元→亿 |
|
||||
| `get_trade_days` | dbbardata('d') 600519 distinct datetime | `quant_trading.db` | — |
|
||||
| `get_all_securities` | dbbardata distinct symbol | `quant_trading.db` | — |
|
||||
| `get_security_info` | dbbardata min/max datetime + constituent_unified code_name | `quant_trading.db` | — |
|
||||
| `get_current_tick` | dbbardata 最近 close × 1.1/0.9 | `quant_trading.db` | ST/创业/科创精确规则 v2 |
|
||||
| `get_split_dividend` | bs_adjust_factor 除权事件 | `quant_trading.db` | dividOperateDate + factor |
|
||||
|
||||
## 接口清单
|
||||
|
||||
```python
|
||||
# K 线(日线 raw 真实价,按需前复权)
|
||||
get_price(security, start_date=None, end_date=None, frequency="daily",
|
||||
fields=None, skip_paused=False, fq="raw", count=None,
|
||||
panel=True, fill_paused=True) -> pd.DataFrame
|
||||
# - frequency 非 daily/day/1d/d → 返空(1m 数据层无,day 频率回测降级)
|
||||
# - panel=False → 长表含 time + code 列(供策略 pivot)
|
||||
# - fq='qfq'/'pre' → 按 bs_adjust_factor 算前复权
|
||||
# - fields 缺失列(如 high_limit)补 NaN(策略涨停识别降级)
|
||||
|
||||
# 成份股(spec §6 治偏差核心)
|
||||
get_index_stocks(index_symbol, date=None) -> List[str] # date 忽略(并集模型)
|
||||
get_constituent(index, date=None) -> List[str] # 语义别名
|
||||
|
||||
# 基本面(列对齐 _FUNDAMENTAL_COLUMNS,策略选股核心)
|
||||
get_fundamentals_df(stocks, date=None) -> pd.DataFrame
|
||||
|
||||
# 辅助
|
||||
get_trade_days(start_date=None, end_date=None, count=None) -> List[datetime]
|
||||
get_all_securities(types=None) -> pd.DataFrame
|
||||
get_security_info(security) -> Dict
|
||||
get_current_tick(security) -> Optional[Dict] # 回测从 K 线推涨跌停
|
||||
get_split_dividend(security, start_date=None, end_date=None) -> List[Dict]
|
||||
```
|
||||
|
||||
## 复权(方案A §14.7 最终目标)
|
||||
|
||||
- **dbbardata 存 raw 真实价**(不复权)。`get_price` 默认 `fq='raw'` 返 raw。
|
||||
- **前复权消费端算**:`get_price(fq='qfq')` 按 `bs_adjust_factor.foreAdjustFactor` 算。
|
||||
- **asof 语义**:每个日期找 `≤ 该日` 的最大除权日的 `foreAdjustFactor`;早于所有除权日用最早因子;晚于所有用最新(=1.0)。
|
||||
- **公式**:`qfq[t] = raw[t] × factor[t]`(open/high/low/close 同乘,volume/turnover 不乘)。
|
||||
- 例:600519 最新除权 2026-06-26 factor=1.0;历史递减(2020-06-24=0.856)。
|
||||
- 策略 `_trend_mean` 算 N 日涨幅是比率,raw/qfq 等价(除权日 raw 跳水除外);要精确除权连续性用 `fq='qfq'`。
|
||||
|
||||
## 幸存者偏差治理(关键!)
|
||||
|
||||
**`constituent_unified` 是"全时期并集"模型**(无 date 列):
|
||||
- 9 指数分布:`000300`=940只(300当前+640被踢) / `000905`=1803(500+1303) / `000016`=195(50+145) / 深证 399001=702,399005=145,399006=175,399330=150
|
||||
- **治"纯当前幸存者"偏差**:含已退市/被踢股票(如 000005 退市、600811 被踢都在 300 并集)
|
||||
- **轻微前视**:`get_index_stocks(date)` 的 `date` 参数**被忽略**(表无时点数据),回测 2020 年选股池 = 历史上所有曾在该指数的股票(含 2024 才纳入的)。比纯当前快照好,但不如 baostock `query_hs300_stocks(date)` 时点精确。
|
||||
- **永久 gap**:中证1000(`000852`)/2000(`932000`)只当前快照(1000/2000 全当前,0 被踢),历史成份股不可补(csindex SPA 封/akshare 只快照)。
|
||||
- **dbbardata 日线也治偏差**:含退市股 K 线(000005 退市到 2024-04-26,600811 等),回测能真实反映"当时买入现已退市"的标的。
|
||||
|
||||
## Mac 测试(零 VPS 依赖)
|
||||
|
||||
`tests/portfolio/test_local_unified_provider.py` 用 `tmp_path` + `sqlite3` + tmp parquet fixture,完全不依赖 VPS 数据:
|
||||
|
||||
```python
|
||||
def test_get_index_stocks_union(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
c = sqlite3.connect(str(db))
|
||||
c.execute("CREATE TABLE constituent_unified(...)")
|
||||
# 造 in_current + was_removed 样本
|
||||
...
|
||||
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
|
||||
assert set(p.get_index_stocks("000300.XSHG")) == {...} # 含被踢
|
||||
```
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/portfolio/test_local_unified_provider.py -v # 36 passed
|
||||
python3 -m pytest tests/portfolio/ -q # 全回归 149 passed
|
||||
```
|
||||
|
||||
## 部署 / 运行
|
||||
|
||||
**VPS 数据依赖**(方案A 已落地,见 memory `data-fusion-design-finalized` / `vps-local-data-layout`):
|
||||
- `C:\sanguo_vnpy_v2\data\quant_trading.db` — 含 dbbardata / constituent_unified / bs_adjust_factor
|
||||
- `C:\sanguo_vnpy_v2\data\valuation_baostock\<year>.parquet` — 1990-2026 全年份
|
||||
- `C:\sanguo_vnpy_v2\data\static\{valuation,balance,income,cashflow}\*.parquet` — akshare 三表+市值
|
||||
- 日增量:`sanguo-bs-eod`(18:05 baostock 日线+15min+pe/pb)+ `sanguo-xt-eod`(18:40 ETF/基金)已部署
|
||||
|
||||
**rsync 同步代码到 VPS**:
|
||||
```bash
|
||||
rsync -avz -e ssh --exclude='.git' --exclude='vnpy_v4.4.0' --exclude='__pycache__' \
|
||||
--exclude='.superpowers' --exclude='docs' --exclude='tests/data' \
|
||||
./ 49.232.102.198:C:/sanguo_vnpy_v2/
|
||||
```
|
||||
⚠️ config 不在排除列表,会覆盖 VPS config(方案A §14.9 已知 TODO:部署前 `--exclude config` 或靠 SANGUO_DATA_ROOT)。
|
||||
|
||||
**回测**:
|
||||
```bash
|
||||
ssh 49.232.102.198 'cd C:\sanguo_vnpy_v2 && C:\Python310\python.exe -X utf8 -m sanguo_portfolio.runner_backtest --provider unified --start 2024-01-01 --end 2024-12-31 --cash 1000000 --max-pool 20'
|
||||
```
|
||||
|
||||
## 已知限制(v1)
|
||||
|
||||
| 限制 | 影响 | 对策 |
|
||||
|---|---|---|
|
||||
| `high_limit` 列 NaN | 策略 `prepare_stock_list` 昨日涨停识别降级(close==high_limit 不命中) | dbbardata 不存涨跌停;`get_current_tick` 另算;v2 可从 valuation pctChg 推 |
|
||||
| 1m 频率返空 | `_intraday_high_low` 降级 | 数据层无 1m;day 频率回测不触发;15m 在 dbbardata('15m') 可扩展支持 |
|
||||
| `gross_profit_margin`/`roic` NaN | fundamentals 两字段空 | 委托 LocalParquetProvider 读 `financial_abstract`,fixture 未造则 NaN(非新缺口) |
|
||||
| 成份股轻微前视 | 回测早期选股池含未来纳入股 | 方案A 既定取舍(并集模型);要精确时点需 baostock online(违反铁律) |
|
||||
| `get_current_tick` 涨跌停 ±10% 简化 | ST/创业板/科创板精确涨跌停未区分 | v2 从 valuation `isST` + 代码段识别 |
|
||||
|
||||
## 与旧 provider 的关系
|
||||
|
||||
| provider | 数据源 | 用途 | 状态 |
|
||||
|---|---|---|---|
|
||||
| **`LocalUnifiedProvider`** | 方案A 权威层(dbbardata/constituent_unified/valuation_baostock) | **方案A 后推荐** | ✅ 新增 |
|
||||
| `LocalParquetProvider`(`--provider local`) | 旧 parquet(qfq 日线/index_const 快照/akshare valuation) | MVP 验证遗留 | 保留(向后兼容,unittest 仍在) |
|
||||
| `BaostockProvider`(`--provider baostock`) | baostock online HTTP | Mac 跨平台调试 | 保留(违反"读本地"铁律,非生产推荐) |
|
||||
| `SanguoMiniQmtProvider`(`--provider miniqmt`) | miniQMT xtquant | VPS 实盘 | 保留(实盘 runner_live 用) |
|
||||
|
||||
**迁移建议**:新回测/策略用 `--provider unified`。`local` 是方案A 前的 MVP 链路(读旧 parquet, index_const 仅当前快照有幸存者偏差),`unified` 读方案A 权威层治偏差。
|
||||
|
||||
## 设计文档
|
||||
|
||||
- spec:`docs/superpowers/specs/2026-07-21-data-source-fusion-design.md` §6(使用层)+ §14(方案A 数据层)
|
||||
- plan:`docs/superpowers/plans/2026-07-23-local-unified-provider.md`(TDD 拆解)
|
||||
- 关联 memory:`data-fusion-design-finalized` / `vps-local-data-layout` / `provider-local-data-only` / `db-primary-parquet-fallback`
|
||||
@@ -0,0 +1,105 @@
|
||||
# A股静态数据全量缓存到 VPS — 设计与采集计划
|
||||
|
||||
> 2026-07-19 立。目标:全市场 A股静态/基本面/参考数据全量缓存到 VPS 本地(parquet),作选股(基本面)与回测数据源。
|
||||
|
||||
## 原则(用户钦定)
|
||||
1. **尽量多缓存**——能下的全下,避免限流/网络依赖。
|
||||
2. **串行可,等待长可接受**——不追求并发速度,稳定性优先。
|
||||
3. **准确性第一**——下错不如不下,每类数据必须验证。
|
||||
4. **建立每日自动更新**——历史一次灌满 + 每日增量。
|
||||
5. 用途:基本面选股 + 回测。盘中实时当日数据(实盘)未来再做。
|
||||
|
||||
## 为什么缓存优于实时取(背景)
|
||||
历史静态数据(含"日频"的历史部分)永不改变。本地缓存:秒级读盘/零网络依赖/可复现快照/不触发限流。实时取历史:慢/不稳/不可复现/反复触发封 IP。**唯一非静态是"今天未收盘/未公布"的部分,日终收盘后即变静态。**
|
||||
|
||||
## 范围(全量 ~2GB)
|
||||
| 组 | 类别 | 频率 | 源 | 估算 |
|
||||
|---|---|---|---|---|
|
||||
| A 基础元数据 | 基础信息(代码/名称/交易所/板块/上市退市/状态) | 静态 | baostock | 1MB |
|
||||
| | 行业分类(申万/中信/概念) | 静态 | akshare | 30MB |
|
||||
| | 指数成分+权重(300/500/1000/国证2000) | 月 | akshare | 50MB |
|
||||
| B 财务 | 三大报表(资产/利润/现金流) | 季 | baostock | 150MB |
|
||||
| | 季频衍生指标(ROE/EPS/毛利率/净利增速/负债率/杜邦) | 季 | baostock | 200MB |
|
||||
| | 业绩预告/快报 | 季事件 | akshare | 30MB |
|
||||
| C 股本/公司行为 | 股本结构变动 | 事件 | akshare | 50MB |
|
||||
| | 十大股东+十大流通股东 | 季 | akshare | 250MB |
|
||||
| | 分红送转配股 | 事件 | baostock | 40MB |
|
||||
| | 限售解禁 | 事件 | akshare | 20MB |
|
||||
| D 估值/复权(日频) | 估值快照(PE/PB/PS/PF/股息率/市值/流通市值) | 日 | akshare | 300MB |
|
||||
| | 复权因子(qfq/hfq) | 日 | baostock | 150MB |
|
||||
| E 市场参考(日频,可选) | 龙虎榜/大宗交易/融资融券/北向/ST停复牌 | 日 | akshare | ~360MB |
|
||||
|
||||
## 存储格式与目录
|
||||
**parquet(每类一个目录)**,VPS `C:\sanguo_vnpy_v2\data\static\<type>\`。匹配现有 15min/daily parquet 模式,回测 pandas 直读,增量 append/overwrite 幂等。
|
||||
```
|
||||
data/static/
|
||||
basic/ 基础信息(全量刷新,1文件 or per-stock)
|
||||
industry/ 行业分类
|
||||
index_const/ 指数成分
|
||||
balance/ 资产负债表(per-stock parquet)
|
||||
income/ 利润表
|
||||
cashflow/ 现金流量表
|
||||
indicator/ 季频财务指标(ROE/EPS/...)
|
||||
forecast/ 业绩预告/快报
|
||||
share_capital/ 股本结构
|
||||
top_holders/ 十大股东
|
||||
dividend/ 分红送转
|
||||
lockup_release/ 限售解禁
|
||||
valuation/ 估值日频(PE/PB/市值)
|
||||
adjust_factor/ 复权因子
|
||||
dragon_tiger/ 龙虎榜
|
||||
block_trade/ 大宗交易
|
||||
margin/ 融资融券
|
||||
northbound/ 北向资金
|
||||
```
|
||||
|
||||
## 数据源映射 + 串行约束(关键)
|
||||
| 源 | 数据 | 并发约束 |
|
||||
|---|---|---|
|
||||
| **baostock** | 基础信息/复权因子/分红/季频指标/三表 | **单登录串行,跟15min共用登录→必须等15min跑完才能开**(并发=IP封6-24h) |
|
||||
| **akshare** | 估值/龙虎榜/大宗/融资融券/北向/指数成分/行业/股本/十大股东/解禁/业绩预告 | 不同源,可与baostock错峰;东财源要限速防反爬 |
|
||||
|
||||
## 准确性协议(每类数据强制)
|
||||
1. **断点续传 marker + 失败/空数据区分**(复用 15min 的 empty-vs-failed 修复)
|
||||
2. **下载后抽样验证(≥10只)**:字段完整 / 日期覆盖(回溯到2020) / 值合理性(PE>0、ROE合理区间、volume≥0、OHLC 自洽)
|
||||
3. **行数 + 覆盖率统计**写入日志
|
||||
4. **幂等写入**(per-stock parquet overwrite;INSERT OR REPLACE 若入 DB),staging 隔离→验证→合并(用户铁律:绝不直写主库/主目录未验)
|
||||
5. (可选)跨源抽检:baostock 季频财务 vs miniQMT PershareIndex 抽几只对一对
|
||||
|
||||
## 每日自动更新机制
|
||||
Windows schtask `sanguo-static-daily`,每日盘后 **16:30** 跑 `daily_update_static.py`:
|
||||
- **日频类(估值/龙虎榜/大宗/融资融券/北向)**:追加当日(或近N日补漏)
|
||||
- **小表全量刷新**:基础信息/行业/指数成分/分红(事件少,全量省得算增量)
|
||||
- **财报季(4/8/10月底后)**:追加新季报(三表/季频/十大股东)
|
||||
- **复权因子**:每日刷新(除权事件会改累计因子)
|
||||
- 失败告警 + 断点续传 + **绝不破坏既有数据**(只 append/replace 单日单股)
|
||||
- 串行 baostock 部分 + 限速 akshare 部分,单进程跑完
|
||||
|
||||
## 执行阶段
|
||||
- **Phase 0(进行中)**:15min baostock,ETA 07-19 ~22:30
|
||||
- **Phase 1**:baostock 静态下载脚本(基础/复权/分红/季频/三表)— **构建 now,运行须等15min完**
|
||||
- **Phase 2**:akshare 静态下载脚本(估值/龙虎榜/大宗/融资融券/北向/指数成分/行业/股本/十大股东/解禁/业绩预告)— **构建+可now起**(不同源)
|
||||
- **Phase 3**:每类抽样验证 → 修问题
|
||||
- **Phase 4**:每日更新 schtask + 验证增量
|
||||
|
||||
## akshare 调查结果(2026-07-19 确认,15类端点实证)
|
||||
**15类中 11 类端点直接可用,4 类有替代。财务类全部"单股一次拉全历史"(完美绕开 baostock per-quarter 百万调用)。全量 ~2.5-3GB,单线程 17-22h(可挂机,不同源可与 baostock 并行)。**
|
||||
|
||||
确认端点(4种模式):
|
||||
- **per-stock(5500股×1调用)**:估值`stock_value_em` / 北向`stock_hsgt_individual_em` / 股本`stock_share_change_cninfo` / 十大流通`stock_gdfx_free_top_10_em`(×20报告期) / **三大报表`stock_balance/profit/cash_flow_sheet_by_report_em`(319/203/254列全历史)** / 财务摘要`stock_financial_abstract`
|
||||
- **per-date(交易日×1调用)**:龙虎榜`stock_lhb_detail_em` / 大宗`stock_dzjy_mrmx(symbol="A股")` / 融资融券沪`stock_margin_detail_sse` / 解禁`stock_restricted_release_detail_em`
|
||||
- **per-period(报告期×1调用)**:业绩预告`stock_yjyg_em` / 业绩快报`stock_yjkb_em`
|
||||
- **one-shot**:指数成分`index_stock_cons_csindex`(300/500/1000) / 行业`sw_index_first_info`(申万,东财`stock_board_industry_name_em`ConnectionError 弃用)
|
||||
|
||||
**有问题/替代**:`stock_margin_detail_szse`(深融资融券)超时频繁→先跳过;`stock_gdfx_holding_detail_em(date)`按日全市场超时→改个股循环;`stock_a_indicator_lg`新版删→用`stock_value_em`。
|
||||
|
||||
**大小明细**:估值150MB / 三大报表1.2GB / 财务摘要300MB / 北向120MB / 融资融券500MB / 十大流通60MB / 其余<100MB各。**总~2.5-3GB**。
|
||||
|
||||
**优先级**:P0 三大报表+财务摘要(~1.5GB,~10h,核心财务)→ P1 估值+北向+融资融券(~770MB,~3h)→ P2 龙虎榜/大宗/解禁/业绩预告/股本/指数/行业(~300MB,~1h)。
|
||||
|
||||
## 部署架构(自愈链,2026-07-19)
|
||||
- **15min baostock**:schtask `sanguo-bs15min` + 自愈.bat(ping-sleep 30min重试)。2026-07-19 12:00 baostock全球故障(Mac+VPS同挂10002007),自愈中,恢复即续 from marker 2847。
|
||||
- **baostock静态**:schtask `sanguo-bs-static` + 自愈.bat(**wait15**等15min "ALL DONE" → 自动接力 → ping-sleep自愈)。脚本`baostock_static_download.py`已部署(basic/adjust_factor/dividend,rs.fields动态取字段)。
|
||||
- **akshare静态**:schtask `sanguo-bs-akshare`(待建)+ 自愈.bat。脚本构建中。**不同源,可与baostock并行**。
|
||||
- 监控:cron 779cbb71 每30min probe_all + 异常自修 + 完成报告。caffeinate防睡眠。
|
||||
- **.bat sleep 用 `ping -n N 127.0.0.1`**(timeout.exe在SYSTEM schtask下失效,见 memory schtasks-system-bat-gotchas)。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 静态数据 3 个真缺口 — 补充设计(2026-07-19 记录)
|
||||
|
||||
> **✅ 方案A 2026-07-22 落地后状态**(见 [[data-fusion-design-finalized]] memory §14):
|
||||
> - **缺口1 日线换手/涨跌**:已实现——baostock `turn`/`pctChg` 拆 `valuation_baostock/<year>.parquet` 按年宽表(2003-2026),非派生(原设计的"派生方案"已被 baostock 现成字段替代)。
|
||||
> - **缺口2 ETF 入 universe**:已完成——xtata `sanguo-xt-eod` 18:40 跑,universe 沪深A股∪ETF∪基金=7414,dividend_type='front',ETF 入 dbbardata 与个股共表。
|
||||
> - **缺口3 指数成分历史**:✅ **2026-07-23 全闭环**——`constituent_unified` 8466行/9指数(300/500/50 baostock 时点聚合全集 + 深证 4 指 akshare cni union 含被踢 + **中证1000/2000 csindex 公告回溯全集**)。**原"永久 gap"已推翻**:csindex 公告 JSON 接口(queryAnnouncementByVo + PDF/xlsx 附件)回溯调整公告治偏差,000852 1000→1672(was_removed=672)/ 932000 2000→2684(684)。详见 memory [[csindex-announce-backfill]]。残留 gap:932000 中间调整 csindex 无公告(launch∪current 近似)/ 000852 2007-2016 部分公告无附件(2016-12 起完整)。
|
||||
>
|
||||
> 下文为 2026-07-19 原始设计,保留作历史参考。
|
||||
|
||||
## 数据现状实测(VPS quant_trading.db + data/ 目录,非推理)
|
||||
- **DB dbbardata**: 15m(2025-07~2026-07,1年,xt_tacitdata源)/5m(1年)/**d日线(2010~2026,16年,5205 symbols,OHLCV+amount)**。dbbardata schema 有 turnover(=成交额amount),**无换手率/涨跌幅列**。
|
||||
- **parquet data/raw + data/qfq**: 各 59816,O H L C V 6列,16年(xtdata建,build_daily_from_xtdata)。
|
||||
- **data/static/**: akshare balance 跑着(4300+ parquet)。
|
||||
- **实测缺口**:
|
||||
1. 日线**缺换手率+涨跌幅**(amount已在DB 16年)
|
||||
2. **ETF不在daily universe**(5205 symbols大概率纯股票,518880等海外ETF缺)
|
||||
3. **指数成分历史(含被踢)完全无**(全项目无脚本,akshare只当前快照)
|
||||
|
||||
## 3 缺口设计(派生方案,避开数据混乱+接口限流)
|
||||
|
||||
### 缺口1:日线换手/涨跌 → 派生,不新下载
|
||||
- **pct_chg** = (close今 - close昨)/close昨,**从 qfq close 算**(16年,避免除权跳空)。
|
||||
- **换手率** = volume / 流通股本。volume在DB;**流通股本在 akshare valuation(stock_value_em,排队P1,8.5年)**。
|
||||
- amount:DB已有(16年)。
|
||||
- **不开 daily_extra 新目录**,读取层派生 或 DB加列 → 不加剧"四套口径分裂" + **零新东财负载**。
|
||||
|
||||
### 缺口2:ETF日线 → 加进现有 xtdata universe
|
||||
- ETF清单:海外 518880(黄金)/513100(纳指)/513030(德国)/164824(石油)/159866(有色) + 主 510300/510500/159915等,maintain成config。
|
||||
- 加进 `build_daily_from_xtdata` 的 universe → 走现有 xtdata 本地管线(miniQMT),**不碰东财,无限流**。
|
||||
|
||||
### 缺口3:指数成分历史(含被踢) → csindex 抓取,先 spike
|
||||
- 范围:hs300(000300)/zz500(000905)/zz50(000016)/中小综指(399101)/创业板指(399006)。
|
||||
- **先派 agent spike 调研源**:akshare 有无历史成分API(index_stock_cons_weight_csindex带日期?fund_portfolio_hold_em?)、csindex.cn 历史成分xlsx URL规律+反爬、深证399101/399006 巨潮/szse 源。
|
||||
- **B档(务实,先行)**:抓全部历史调仓成分→并集(曾经入选集),消灭幸存者偏差。output `data/index_const_hist/<indexcode>.parquet`。
|
||||
- A档(精确,后做):时间序列(指数,生效日,成分,加/剔)。
|
||||
- csindex 独立源,串行单线程抓,**限流风险低**。
|
||||
|
||||
## 风险结论(为何用派生方案)
|
||||
- **原设计(akshare daily_extra)** 有双风险:① 数据混乱——日线口径第四处(DB/parquet raw/parquet qfq/daily_extra),回测不知读哪;② 接口限流——daily_fields_akshare 并发 akshare_static = 第二股东财流量→东财封(同 baostock 黑名单原理)。
|
||||
- **派生方案**:缺口1 两风险全消(不下载/不开新目录);ETF走xtdata无限流;csindex独立源串行低风险。
|
||||
- 唯一仍调外部API:缺口3(csindex)+ 已排队的 akshare static 本身——保持串行+限速,不新增并发。
|
||||
|
||||
## 决策与顺序(下载完后)
|
||||
1. **派生换手/涨跌**(读取层工具 或 DB加列)——缺口1
|
||||
2. **ETF 入 xtdata daily universe**——缺口2
|
||||
3. **csindex spike 调研** → 定 B档抓取脚本——缺口3
|
||||
4. (远期)A档精确成分时间序列
|
||||
|
||||
## 关联
|
||||
- 主计划:`docs/static_data_cache_plan.md`
|
||||
- 现状memory:`baostock-15min-vps-deploy-plan` / `db-primary-parquet-fallback` / `data-download-architecture`
|
||||
@@ -0,0 +1,141 @@
|
||||
# 数据源体系建设 - 项目汇总报告
|
||||
|
||||
**任务ID**: data-platform-20260502
|
||||
**汇总人**: 庞统(副军师)
|
||||
**日期**: 2026-05-02
|
||||
**状态**: P1完成,P2-P4待后续任务
|
||||
|
||||
---
|
||||
|
||||
## 一、项目目标
|
||||
|
||||
打通从数据获取到vnpy回测的完整数据通路:**NAS Parquet → vnpy SQLite DB → 回测引擎**
|
||||
|
||||
核心问题:vnpy回测服务的 quant_trading.db 是空的(8KB),所有回测任务必然失败。
|
||||
|
||||
---
|
||||
|
||||
## 二、各节点产出汇总
|
||||
|
||||
| 节点 | 负责人 | 核心产出 | 结论 |
|
||||
|------|--------|---------|------|
|
||||
| pangtong_requirements | 庞统 | 需求规格文档(7个维度、4个阶段、9项不确定项) | ✅ 通过 |
|
||||
| zhaoyun_acquire | 赵云 | vnpy DB Schema确认 + 全量日线导入(1281万行)+ P0限频验证 | ✅ 通过 |
|
||||
| jiangwei_storage | 姜维 | Docker数据通路打通 + executor bug修复 + 端到端回测验证 | ✅ 通过 |
|
||||
| simayi_verify | 司马懿 | 数据完整性/正确性/回测可用性逐项验证 | ✅ 通过 |
|
||||
|
||||
---
|
||||
|
||||
## 三、P1 完成成果
|
||||
|
||||
### 3.1 数据导入
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 总行数 | **12,811,513** |
|
||||
| 股票数 | **5,191** |
|
||||
| 日期范围 | 2010-01-04 ~ 2026-03-27 |
|
||||
| DB文件大小 | 1.4 GB(NAS)/ 1.51 GB(Docker内) |
|
||||
| 导入耗时 | ~45 分钟 |
|
||||
|
||||
### 3.2 回测验证
|
||||
|
||||
| 验证项 | 结果 |
|
||||
|--------|------|
|
||||
| vnpy load_data() | ✅ 加载237根日K线(000001.SZSE 2025年) |
|
||||
| 回测服务API | ✅ 提交→执行→返回统计 |
|
||||
| 回测统计 | total_days=237, return=1.30%, sharpe=0.857 |
|
||||
| 数据质量 | 6条异常(占比0.00005%),源自原始Parquet |
|
||||
|
||||
### 3.3 解决的关键问题
|
||||
|
||||
1. **vnpy DB Schema确认**:DbBarData表11个字段,唯一索引(symbol,exchange,interval,datetime)
|
||||
2. **SMB写入SQLite锁库**:先写本地/tmp,完成后复制到NAS
|
||||
3. **Docker未挂载数据目录**:通过Mac HTTP服务从Docker内wget DB文件到~/.vntrader/
|
||||
4. **executor date→datetime bug**:修补版executor.py,字符串转datetime后再传给vnpy
|
||||
|
||||
---
|
||||
|
||||
## 四、产出的文件清单
|
||||
|
||||
### 代码文件(sanguo_vnpy/data_platform/)
|
||||
|
||||
| 文件 | 说明 | 行数 |
|
||||
|------|------|------|
|
||||
| import_vnpy_daily_fast.py | 全量日线导入脚本(pandas向量化) | 126 |
|
||||
|
||||
### 数据文件
|
||||
|
||||
| 文件 | 大小 | 路径 |
|
||||
|------|------|------|
|
||||
| quant_trading.db | 1.4 GB | /Volumes/stock/sanguo_vnpy/data/ |
|
||||
| quant_trading.db.bak | 8 KB | /Volumes/stock/sanguo_vnpy/data/(原始空库备份) |
|
||||
| database.db(Docker内) | 1.51 GB | /home/vnpy/.vntrader/ |
|
||||
|
||||
### 修复文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| executor_patched.py | executor.py date→datetime 修复版 |
|
||||
| restore_backtest_service.sh | 容器重启后恢复脚本 |
|
||||
| start_backtest.sh | Docker内回测服务启动脚本 |
|
||||
|
||||
### 文档文件
|
||||
|
||||
| 文件 | 路径 |
|
||||
|------|------|
|
||||
| 01-requirements.md | ~/.openclaw/sanguo_projects/sanguo_vnpy/docs/data-platform/ |
|
||||
|
||||
---
|
||||
|
||||
## 五、P0 腾讯API限频验证结果
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 测试规模 | 100只股票15分钟线 |
|
||||
| 成功率 | **100%** |
|
||||
| 平均响应时间 | 0.19秒/请求 |
|
||||
| 封禁 | **无** |
|
||||
| 预估全市场下载 | ~17分钟(5500只) |
|
||||
|
||||
**结论**:腾讯API限频不构成阻塞,P3分钟线可执行。
|
||||
|
||||
---
|
||||
|
||||
## 六、遗留问题(不阻塞P1)
|
||||
|
||||
| # | 问题 | 影响 | 建议处理 |
|
||||
|---|------|------|---------|
|
||||
| 1 | **容器重启需手动恢复回测服务** | 回测不自动启动 | 修改Docker entrypoint或Synology配置 |
|
||||
| 2 | NAS数据停在2026-03-27 | 缺34天日线 | P2增量更新 |
|
||||
| 3 | 6条异常数据(原始Parquet) | 影响极小 | P4全量校验 |
|
||||
| 4 | DB导入非全自动(/tmp手动复制) | 运维不便 | 优化导入脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 七、P2-P4 待后续任务推进
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| P2: 数据基础设施 | 降级管理器+校验层+实时行情+增量更新+cron | 待创建任务 |
|
||||
| P3: 分钟线数据 | 限频已验证通过,下载+导入 | 待创建任务 |
|
||||
| P4: 配套skill | skill更新+全量校验+周维护 | 待创建任务 |
|
||||
|
||||
---
|
||||
|
||||
## 八、数据流架构(当前状态)
|
||||
|
||||
```
|
||||
NAS Parquet (5191只×17年)
|
||||
↓ import_vnpy_daily_fast.py
|
||||
SQLite DB (1281万行, 1.4GB)
|
||||
↓ Mac HTTP → Docker wget
|
||||
Docker ~/.vntrader/database.db (1.51GB)
|
||||
↓ engine.load_data()
|
||||
vnpy BacktestingEngine → 回测结果 ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*汇总完成:2026-05-02*
|
||||
*庞统(副军师)🐦*
|
||||
Reference in New Issue
Block a user