From cf4b3a2cb09d90ebd1ac3b2fedb713683eb1c207 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 12 Jul 2026 08:26:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(data):=2015min=E6=95=B0=E6=8D=AE=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E2=80=94=E8=B7=AF=E5=BE=84=E5=8F=82=E6=95=B0=E5=8C=96?= =?UTF-8?q?+cron=E5=85=A5=E5=8F=A3+=E5=9B=9E=E5=A1=AB+=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - download_minute.py: STOCK_ROOT环境变量参数化(Mac/NAS兼容); 修tencent备源amount聚合 last→sum - refresh_15min_daily.py: 新建cron入口(交易日判断baostock/直连pop代理/日志) - 回填470缺口(170 SH/SZ成功, 300 BSE源不支持) - docs/data/15min-data-design.md: 15min数据层设计文档(源/覆盖/脚本/刷新/约束/路径映射) --- docs/data/15min-data-design.md | 195 +++++++++++++++++++ scripts/data_platform/download_minute.py | 11 +- scripts/data_platform/refresh_15min_daily.py | 130 +++++++++++++ 3 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 docs/data/15min-data-design.md create mode 100755 scripts/data_platform/refresh_15min_daily.py diff --git a/docs/data/15min-data-design.md b/docs/data/15min-data-design.md new file mode 100644 index 0000000..5ad9ba2 --- /dev/null +++ b/docs/data/15min-data-design.md @@ -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 | diff --git a/scripts/data_platform/download_minute.py b/scripts/data_platform/download_minute.py index 3123aa2..6ca2310 100644 --- a/scripts/data_platform/download_minute.py +++ b/scripts/data_platform/download_minute.py @@ -23,6 +23,7 @@ import argparse import json +import os import re import sys import time @@ -42,14 +43,16 @@ logging.basicConfig( logger = logging.getLogger(__name__) # --- 配置 --- -OUTPUT_DIR = Path("/Volumes/stock/minute_kline/15min") +# STOCK_ROOT 可通过环境变量覆盖;默认 Mac 挂载点,NAS 用 STOCK_ROOT=/volume1/stock +STOCK_ROOT = Path(os.environ.get("STOCK_ROOT", "/Volumes/stock")) +OUTPUT_DIR = STOCK_ROOT / "minute_kline" / "15min" PROGRESS_FILE = OUTPUT_DIR / "download_progress.json" REQUEST_INTERVAL = 0.3 MAX_RETRIES = 3 CONSECUTIVE_FAIL_PAUSE = 60 MAX_CONSECUTIVE_FAILS = 5 -HS300_FILE = Path("/Volumes/stock/A股数据/stock_info/hs300_constituents_latest.csv") -ALL_STOCKS_FILE = Path("/Volumes/stock/A股数据/stock_info/stock_basic_info_raw_20260326_113530.csv") +HS300_FILE = STOCK_ROOT / "A股数据" / "stock_info" / "hs300_constituents_latest.csv" +ALL_STOCKS_FILE = STOCK_ROOT / "A股数据" / "stock_info" / "stock_basic_info_raw_20260326_113530.csv" HEADERS = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"} @@ -141,7 +144,7 @@ def _aggregate_1m_to_15m(df: pd.DataFrame) -> pd.DataFrame: low=("price", "min"), close=("price", "last"), volume=("vol", "sum"), - amount=("amount", "last"), + amount=("amount", "sum"), ).reset_index() return pd.DataFrame({ diff --git a/scripts/data_platform/refresh_15min_daily.py b/scripts/data_platform/refresh_15min_daily.py new file mode 100755 index 0000000..0349f3c --- /dev/null +++ b/scripts/data_platform/refresh_15min_daily.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +15min 日增量刷新 - NAS Synology 任务计划入口 + +功能: + 1. 清除代理环境变量(直连,避免被识别为异常流量) + 2. 判断今天是否 A 股交易日(baostock query_trade_dates;不可用降级为"周六日跳过") + 3. 交易日 → 调 download_minute.py --scope all --resume + 4. 记日志到 $STOCK_ROOT/logs/daily_update/refresh_15min_YYYYMMDD.log + +环境变量: + STOCK_ROOT 数据根目录(默认 /volume1/stock,Mac 测试用 /Volumes/stock) + +Synology 任务计划命令(容器内 Python,依赖已装好): + docker exec sanguo_vnpy_v2 bash -c "cd /app && python3 scripts/data_platform/refresh_15min_daily.py" + +或 NAS host Python(需自行 pip install baostock pandas pyarrow): + STOCK_ROOT=/volume1/stock python3 /volume1/homes/admin/.sanguo_projects/sanguo_vnpy_v2/scripts/data_platform/refresh_15min_daily.py +""" + +import logging +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Tuple + +# --- 清除代理(脚本一进来就 pop,保证后续 urllib/baostock 直连)--- +for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", + "all_proxy", "ALL_PROXY", "no_proxy", "NO_PROXY"): + os.environ.pop(_k, None) + +# --- 路径配置 --- +# NAS: /volume1/stock;Mac: /Volumes/stock。也可通过环境变量覆盖。 +STOCK_ROOT = Path(os.environ.get("STOCK_ROOT", "/volume1/stock")) +LOG_DIR = STOCK_ROOT / "logs" / "daily_update" +SCRIPT_DIR = Path(__file__).resolve().parent +DOWNLOAD_SCRIPT = SCRIPT_DIR / "download_minute.py" + +# --- 日志(先建目录,LOG_DIR 在 STOCK_ROOT 下) --- +LOG_DIR.mkdir(parents=True, exist_ok=True) +_today_str = datetime.now().strftime("%Y%m%d") +LOG_FILE = LOG_DIR / f"refresh_15min_{_today_str}.log" + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE, encoding="utf-8"), + logging.StreamHandler(), + ], +) +logger = logging.getLogger(__name__) + + +def is_trading_day() -> Tuple[bool, str]: + """ + 判断今天是否 A 股交易日。 + 优先 baostock query_trade_dates;不可用降级为"周六日跳过"(工作日默认交易)。 + 返回 (is_trading, reason) + """ + today_iso = datetime.now().strftime("%Y-%m-%d") + today_date = datetime.now().date() + + # 周末一定非交易日(即使 baostock 失败也能正确跳过) + if today_date.weekday() >= 5: + return False, f"周末({today_date.strftime('%A')})" + + # 工作日 → 用 baostock 查节假日 + try: + import baostock as bs + lg = bs.login() + if lg.error_code != "0": + logger.warning("baostock 登录失败: %s,降级为工作日默认交易日", lg.error_msg) + return True, f"baostock登录失败({lg.error_msg}),工作日默认交易日" + try: + rs = bs.query_trade_dates(start_date=today_iso, end_date=today_iso) + if rs.error_code != "0": + logger.warning("baostock 查询交易日失败: %s,降级", rs.error_msg) + return True, f"baostock查询失败({rs.error_msg}),工作日默认交易日" + while rs.next(): + row = rs.get_row_data() # [date, is_trading_day] + if row[0] == today_iso: + is_trading = (row[1] == "1") + return is_trading, f"baostock is_trading_day={row[1]}" + # 没查到今天 → 工作日默认交易 + return True, "baostock 无今日记录,工作日默认交易日" + finally: + bs.logout() + except Exception as e: + logger.warning("baostock 不可用: %s,降级为工作日默认交易日", e) + return True, f"baostock异常({e}),工作日默认交易日" + + +def run_download() -> int: + """调用 download_minute.py --scope all --resume,继承当前环境(含 STOCK_ROOT)""" + cmd = [sys.executable, str(DOWNLOAD_SCRIPT), "--scope", "all", "--resume"] + env = dict(os.environ) + env["STOCK_ROOT"] = str(STOCK_ROOT) + logger.info("执行命令: %s", " ".join(cmd)) + logger.info("STOCK_ROOT=%s", STOCK_ROOT) + result = subprocess.run(cmd, env=env) + return result.returncode + + +def main() -> int: + logger.info("=" * 60) + logger.info("15min 日刷新开始 @ %s", datetime.now().isoformat()) + logger.info("STOCK_ROOT=%s", STOCK_ROOT) + logger.info("LOG_FILE=%s", LOG_FILE) + + trading, reason = is_trading_day() + logger.info("交易日判断: %s (%s)", trading, reason) + if not trading: + logger.info("非交易日, 跳过下载") + return 0 + + rc = run_download() + logger.info("download_minute.py 退出码: %d", rc) + if rc != 0: + logger.error("❌ 下载脚本失败") + return rc + + logger.info("✅ 15min 日刷新完成") + return 0 + + +if __name__ == "__main__": + sys.exit(main())