feat(data): 方案A 数据层落地(spec §14)— DB唯一表+治幸存者偏差+权威源

spec §14 方案A 数据层迁移完成 + E2E 验证(read_db_daily: 在市/退市治偏差/ETF 全OK):
- dbbardata('d') 1826万含退市(治回测幸存者偏差, INSERT OR REPLACE staging迁移, WHERE OHLC NOT NULL+COALESCE)
- constituent_unified 7110行/9指数(300/500/50 baostock全集 + 深证4指 akshare cni union + 中证1000/2000 snapshot)
- pe/pb 不进DB -> valuation_baostock/<year>.parquet 按年宽表(2003-2026)
- 废弃 daily_baostock_full/bs_index_constituent(rename _old 保留); 旧4 schtask disabled
- 新 schtask sanguo-bs-eod 18:05(baostock个股日线+15min+拆pe/pb DAILY_LIMIT 48000) + sanguo-xt-eod 18:40(ETF/基金xtata)
- 权威源: baostock个股日线+估值+15min+复权+300/500/50 / xtata ETF+基金+当天实时 / akshare三表+事件+深证中证成份股
- 全程备份+staging+_old保留可回滚; 脚本 audit/probe/migrate/merge/cleanup/fix_config/verify/bs_eod/xt_eod/wrapper/register_schtasks
- 待办(spec §6 使用层): LocalParquetProvider 接 constituent_unified+valuation_baostock + 实时拼接
This commit is contained in:
2026-07-23 07:20:34 +08:00
parent b270faf4b9
commit c2a89d01a4
27 changed files with 2062 additions and 0 deletions
@@ -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 → 收尾(严格风险升序)
@@ -230,6 +230,68 @@ get_delisted_kline(...) # 退市股 K 线
---
## 14. 方案 A 定稿(2026-07-22 讨论收敛 — 本节为最新,覆盖前文相关决策)
### 14.1 背景
P0 补全 + schtask 部署完成后,审计(`audit_data_layout.py` + `dbbardata_probe.py` 实证)发现「同类多源、多份落地、DB 多表」混乱。讨论收敛为方案 A:**每类数据唯一权威源(允许互补 fallback,禁止并行)、DB 表唯一、估值/三表/事件不进 DB、DB 只放高频随机读取的 K 线+成份股+复权**。
### 14.2 权威源最终分工
| 数据类 | 权威源 | 备注 |
|---|---|---|
| 个股日线 OHLCV(含退市) | **baostock** | raw 真实价 → dbbardata('d') |
| ETF/基金日线 | **xtata** | baostock 盲区(只取 type=1) |
| 个股当天实时(盘后 baostock 未更窗口/盘中) | **xtata** | 拼 baostock 历史,同为 raw |
| 15min | **baostock** | 历史+增量同源 |
| 5min/1min | baostock(暂停) | 战略占位,size 扩容续 |
| 估值 pe/pb/ps/pcf/turn/pctChg/isST | **baostock** | 日线18字段拆出 |
| 基本面三表 | **akshare** | |
| 成份股 300/500/50 | **baostock**(历史) | |
| 成份股 深证 399xxx | **akshare cni**(历史 union) | |
| 成份股 中证1000/2000 | akshare csindex(当前快照) | 历史不可补=永久 gap |
| 复权因子 | **baostock** | 全链路复权基准 |
| 事件类(龙虎榜/大宗/北向/两融/解禁/预告) | **akshare** | |
三大源:xtata(ETF+实时) / baostock(个股日线+估值+15min+复权+300.500.50) / akshare(三表+事件+深证中证成份股)。同类不并行,按标的/字段互补。
### 14.3 DB 边界(只放)
- `dbbardata`:日线('d')+15min('15m')+5min('5m'占位)— **唯一行情表**
- `constituent_unified`:成份股合并唯一表(date,index_code,code,code_name,source)
- `bs_adjust_factor`:复权
- `backtest_stats`:回测(已有)
- **不进 DB**:基本面三表、估值 pe/pb、事件类、回测明细(独立 db)
- **废弃** `daily_baostock_full`(OHLCV→dbbardata,pe/pb→parquet)、`bs_index_constituent`(合入 constituent_unified)
### 14.4 pe/pb 落地:parquet 按年宽表
`data/valuation_baostock/<year>.parquet`,列 `date,symbol,pe_ttm,pb_mrq,ps_ttm,pcf_ncf_ttm,turn,pct_chg,is_st`。选股排序(全市场某日)列存宽表秒级。baostock 日线增量同源拆出。
### 14.5 增量 schtask(4 个)
| schtask | 时间 | 源 | 内容 | 落点 | 预算 |
|---|---|---|---|---|---|
| sanguo-bs-eod | 18:05 | baostock | 个股日线+15min 增量+拆 pe/pb | dbbardata('d'/'15m')+valuation_baostock/ | ~11000/48000 |
| sanguo-xt-eod | 18:40 | xtata | ETF/基金日线+个股当天实时补 | dbbardata('d') | 无限流 |
| sanguo-bs-akshare | 19:00 | akshare | 三表+事件类 | parquet | 限频单线程 |
| sanguo-index | 月度 19:50 | baostock+akshare | 成份股合并 | constituent_unified | 小 |
baostock 单进程单登录,`DAILY_LIMIT=48000`,sleep 限速,login 探针 graceful skip,直连不走代理。5min/1min 不设 schtask。
### 14.6 dbbardata 补退市(本次审计发现,顺带治回测幸存者偏差)
实证(`dbbardata_probe`):退市股(000005/000023/600811)在 dbbardata **只有 15m,无日线** → 回测读 dbbardata 日线天然幸存者偏差。迁移:`daily_baostock_full` OHLCV 全量(含退市)→ dbbardata('d') INSERT OR REPLACE(本地 DB 迁移,无网络)。ETF 已在 dbbardata(不碰)。
### 14.7 复权统一
全链路 raw 真实价存储(dbbardata 存 raw),前复权由消费端按 `bs_adjust_factor`(baostock)统一算。xtata 当天实时同为 raw,可拼接。禁止混 xtata dividend_type=front。
### 14.8 数据迁移(5 单元,风险升序,备份+staging+可回滚+审计日志)
1. 存量垃圾清理(`_staging_xtdata` 14万文件 / `_xtdata.tar` 1.4G / 归拢 cta_* dbg_* → backtest_files/)
2. config 统一 VPS 路径(NAS 仅备份)
3. 成份股合并(bs_index_constituent + index_const_hist union → constituent_unified,按指数代码去重,新浪300/50丢弃)
4. daily_baostock_full 拆分(OHLCV→dbbardata('d') 含退市;pe/pb→valuation_baostock/<year>.parquet;旧表 rename _old)
5. dbbardata 个股日线切 baostock 源(单元4已覆盖:daily_baostock_full 含全量,在市股 REPLACE 覆盖 xtata,数值一致)
每单元:全库 `sqlite3 .backup` + rsync NAS → 脚本写 staging(新表/新目录)→ 验证探针(行数/distinct symbol/抽样价格/成功率)→ 用户确认合并 → 旧数据 rename _old 保留 7 天。全程 nohup + 审计日志 `data/migration_logs/`
### 14.9 config 清理
VPS config 的 daily_dir/raw_dir/qfq_dir/minute_15_dir 改 `C:\sanguo_vnpy_v2\data\...`(当前 yaml 指 NAS /volume1,容器版遗留)。NAS config 保留作备份。read_parquet_daily 的 daily_dir 统一指向 qfq(消除 daily/ vs qfq/ 分叉)。
## 参考(调查来源)
- xtdata 官方:https://dict.thinktrader.net/nativeApi/xtdata.html
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""audit_data_layout.py — 只读盘点 VPS 本地数据布局(DB表行数/maxdate + data/目录)。
用法:
python audit_data_layout.py [BASE_DIR]
默认 BASE=C:\\sanguo_vnpy_v2 (VPS)。Mac 调试传本地路径。
只 SELECT / 遍历目录,不写任何东西。
"""
import sqlite3
import sys
from pathlib import Path
BASE = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(r"C:\sanguo_vnpy_v2")
DB = BASE / "data" / "quant_trading.db"
DATA = BASE / "data"
def line(s=""):
print(s)
# ======================== DB 表盘点 ========================
line("=" * 72)
line(f"DB: {DB} exists={DB.exists()}")
line("=" * 72)
if DB.exists():
c = sqlite3.connect(str(DB), timeout=60)
tables = [r[0] for r in c.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
SKIP_COUNT = {"dbbardata", "daily_baostock_full"} # 千万/亿级 COUNT 慢, 跳过
for t in tables:
if t in SKIP_COUNT:
line(f" {t:42s} (skip COUNT, see max below)")
continue
try:
n = c.execute(f"SELECT COUNT(*) FROM '{t}'").fetchone()[0]
except Exception as e:
n = f"ERR {e}"
line(f" {t:42s} {n}")
# 关键表 schema + 时间范围 (大表 dbbardata/daily_baostock_full 跳过慢查询)
for t in ("bs_index_constituent", "bs_adjust_factor"):
try:
cols = [r[1] for r in c.execute(f"PRAGMA table_info('{t}')")]
if not cols:
continue
line("")
line(f"[{t}] cols({len(cols)}): {cols}")
if "symbol" in cols:
nd = c.execute(f"SELECT COUNT(DISTINCT symbol) FROM '{t}'").fetchone()[0]
line(f" distinct symbol = {nd}")
for dc in ("date", "datetime", "trade_date"):
if dc in cols:
mn = c.execute(f"SELECT MIN({dc}) FROM '{t}'").fetchone()[0]
mx = c.execute(f"SELECT MAX({dc}) FROM '{t}'").fetchone()[0]
line(f" {dc}: {mn} ~ {mx}")
break
except Exception as e:
line(f"[{t}] ERR {e}")
c.close()
# ======================== data/ 目录盘点 ========================
line("")
line("=" * 72)
line(f"DATA DIR: {DATA} exists={DATA.exists()}")
line("=" * 72)
if DATA.exists():
for sub in sorted(DATA.iterdir()):
name = sub.name
if name.startswith("."):
continue
try:
if sub.is_dir():
files = list(sub.rglob("*"))
n_files = sum(1 for f in files if f.is_file())
n_pq = sum(1 for f in files if f.suffix == ".parquet")
n_xls = sum(1 for f in files if f.suffix in (".xls", ".xlsx"))
line(f" [DIR] {name:34s} files={n_files:6d} parquet={n_pq:6d} xls={n_xls}")
elif sub.is_file():
sz = sub.stat().st_size
unit, val = ("GB", sz / 1024 ** 3) if sz > 1024 ** 3 else (
"MB", sz / 1024 ** 2)
line(f" [FILE] {name:32s} {val:8.1f} {unit}")
except Exception as e:
line(f" {name} ERR {e}")
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""backup_db.py — VPS 本地全库备份(回滚保险)。
sqlite3 .backup 在线一致 + WAL checkpoint(TRUNCATE) + integrity_check。
所有迁移单元的前置。BAK 同机存放,可立即恢复(满足"备份能回滚搞不坏")。
注:VPS(公网)↔ NAS(内网)不直连,异地冗余需 Mac 中转(28GB 大,可选)。
此处只做 VPS 本地 .bak(核心回滚保障)。
"""
import sqlite3
import datetime
from pathlib import Path
DB = Path(r"C:\sanguo_vnpy_v2\data\quant_trading.db")
ts = datetime.date.today().strftime("%Y%m%d")
BAK = DB.parent / f"quant_trading.db.bak_{ts}"
print(f"DB : {DB} ({DB.stat().st_size / 1024**3:.1f} GB)")
print(f"BAK: {BAK}")
src = sqlite3.connect(str(DB), timeout=120)
src.execute("PRAGMA busy_timeout = 120000")
print("WAL checkpoint(TRUNCATE)...")
print(" ", src.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone())
if BAK.exists():
print(f"BAK 已存在, 覆盖: {BAK}")
print("sqlite3 .backup (在线一致, 可能需几分钟)...")
with sqlite3.connect(str(BAK)) as dst:
src.backup(dst)
src.close()
print(f"BAK 写入: {BAK.stat().st_size / 1024**3:.1f} GB")
# integrity_check on backup
chk = sqlite3.connect(str(BAK))
result = chk.execute("PRAGMA integrity_check").fetchone()[0]
chk.close()
print(f"integrity_check: {result}")
print("BACKUP DONE" if result == "ok" else "BACKUP WARNING: integrity_check 非 ok")
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""bs_eod.py — sanguo-bs-eod (方案A schtask 18:05): baostock 个股 EOD 增量。
每日收盘后跑(VPS, baostock 日终更新就绪):
- 个股日线(含退市, LOOKBACK 7 天) -> dbbardata('d') INSERT OR REPLACE (治幸存者偏差)
- 个股 15min(LOOKBACK 7) -> dbbardata('15m')
- 日线 pe/pb/turn/pctChg/isST -> data/valuation_baostock/<year>.parquet 追加
- DAILY_LIMIT=48000 单进程单登录, sleep 0.3s, login 探针 graceful skip
预算: 5537股 × (1日线+1 15min) ≈ 11000 query/天 = 48000 的 23%, 安全。
退出码: 0=完成; 1=致命; 2=黑名单 graceful skip; 3=query 超限 graceful stop
"""
import argparse
import datetime as dt
import logging
import os
import socket
import sys
import time
from pathlib import Path
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
os.environ.pop(_k, None)
socket.setdefaulttimeout(30)
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
import baostock as bs
import pandas as pd
BASE = Path(r"C:\sanguo_vnpy_v2")
DB = BASE / "data" / "quant_trading.db"
VAL_DIR = BASE / "data" / "valuation_baostock"
LOOKBACK = int(os.environ.get("LOOKBACK_DAYS", "7"))
DAILY_LIMIT = int(os.environ.get("BS_DAILY_LIMIT", "48000"))
BS_INTERVAL = 0.3
QUERY_COUNT = 0
EXC_MAP = {"sh": "SSE", "sz": "SZSE"}
DAILY_FIELDS = ("date,code,open,high,low,close,volume,amount,turn,"
"pctChg,peTTM,psTTM,pcfNcfTTM,pbMRQ,isST")
M15_FIELDS = "date,time,code,open,high,low,close,volume,amount"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)])
log = logging.getLogger(__name__)
def login_once():
try:
lg = bs.login()
if lg.error_code == "0":
return True
log.error("login fail: %s %s", lg.error_code, lg.error_msg)
return False
except Exception as e:
log.error("login exc: %s", e)
return False
def relogin():
try:
bs.logout()
except Exception:
pass
if login_once():
return True
time.sleep(2)
try:
bs.logout()
except Exception:
pass
return login_once()
def fetch_all_stocks():
"""query_stock_basic() 无参 -> 全 A 含退市 (type=1)。返回 [(code, 'sh'/'sz')]。"""
global QUERY_COUNT
QUERY_COUNT += 1
rs = bs.query_stock_basic()
if rs.error_code != "0":
raise RuntimeError(f"query_stock_basic: {rs.error_code} {rs.error_msg}")
fields = list(rs.fields)
idx = {n: i for i, n in enumerate(fields)}
out = []
while rs.next():
r = rs.get_row_data()
if r[idx["type"]] != "1":
continue
bc = r[idx["code"]]
if "." not in bc:
continue
prefix, num = bc.split(".", 1)
if prefix in ("sh", "sz") and len(num) == 6 and num.isdigit():
out.append((num, prefix))
return out
def fetch_k(bs_code, fields, freq, start, end):
global QUERY_COUNT
QUERY_COUNT += 1
rs = bs.query_history_k_data_plus(bs_code, fields, start_date=start,
end_date=end, frequency=freq, adjustflag="3")
if rs.error_code != "0":
raise RuntimeError(f"{bs_code}: {rs.error_code} {rs.error_msg}")
rows = []
while rs.next():
rows.append(rs.get_row_data())
return rows
def upsert_daily(conn, code, prefix, rows):
"""日线 rows -> dbbardata('d') + valuation_baostock 当年 parquet 追加。"""
if not rows:
return 0
df = pd.DataFrame(rows, columns=DAILY_FIELDS.split(","))
for c in ["open", "high", "low", "close", "volume", "amount",
"turn", "pctChg", "peTTM", "psTTM", "pcfNcfTTM", "pbMRQ"]:
df[c] = pd.to_numeric(df[c], errors="coerce")
exc = EXC_MAP[prefix]
# OHLCV -> dbbardata('d')
db = pd.DataFrame({
"symbol": code, "exchange": exc,
"datetime": df["date"].astype(str) + " 00:00:00",
"interval": "d", "volume": df["volume"], "turnover": df["amount"],
"open_interest": 0.0,
"open_price": df["open"], "high_price": df["high"],
"low_price": df["low"], "close_price": df["close"],
})
conn.executemany(
"INSERT OR REPLACE INTO dbbardata "
"(symbol,exchange,datetime,interval,volume,turnover,open_interest,"
"open_price,high_price,low_price,close_price) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
db.itertuples(index=False, name=None))
# pe/pb -> parquet 追加 (isST->int, 修 pyarrow ArrowTypeError)
vdf = df[["date", "peTTM", "psTTM", "pcfNcfTTM", "pbMRQ", "turn", "pctChg", "isST"]].copy()
vdf["isST"] = pd.to_numeric(vdf["isST"], errors="coerce").fillna(0).astype(int)
vdf.insert(0, "symbol", code)
vdf.insert(1, "exchange", exc)
yr = dt.date.today().year
p = VAL_DIR / f"{yr}.parquet"
if p.exists():
try:
old = pd.read_parquet(p)
vdf = pd.concat([old, vdf]).drop_duplicates(["symbol", "date"], keep="last")
except Exception:
pass
vdf.sort_values(["symbol", "date"]).to_parquet(p, index=False)
return len(db)
def upsert_15m(conn, code, prefix, rows):
if not rows:
return 0
df = pd.DataFrame(rows, columns=M15_FIELDS.split(","))
for c in ["open", "high", "low", "close", "volume", "amount"]:
df[c] = pd.to_numeric(df[c], errors="coerce")
exc = EXC_MAP[prefix]
dt_col = (df["date"].astype(str) + " " + df["time"].astype(str).str.slice(0, 6)
).apply(lambda s: f"{s[0:4]}-{s[4:6]}-{s[6:8]} {s[8:10]}:{s[10:12]}:00")
db = pd.DataFrame({
"symbol": code, "exchange": exc, "datetime": dt_col,
"interval": "15m", "volume": df["volume"], "turnover": df["amount"],
"open_interest": 0.0,
"open_price": df["open"], "high_price": df["high"],
"low_price": df["low"], "close_price": df["close"],
})
conn.executemany(
"INSERT OR REPLACE INTO dbbardata "
"(symbol,exchange,datetime,interval,volume,turnover,open_interest,"
"open_price,high_price,low_price,close_price) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
db.itertuples(index=False, name=None))
return len(db)
def main():
global QUERY_COUNT
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--no-15m", action="store_true")
args = ap.parse_args()
today = dt.date.today()
end = today.strftime("%Y-%m-%d")
start = (today - dt.timedelta(days=LOOKBACK)).strftime("%Y-%m-%d")
log.info("bs_eod start window=%s~%s LOOKBACK=%d limit=%s", start, end, LOOKBACK, args.limit or "")
if not login_once():
log.error("[SKIP] baostock 黑名单/冷却, graceful exit 2")
sys.exit(2)
try:
stocks = fetch_all_stocks()
except Exception as e:
log.error("[FATAL] fetch_all: %s", e)
sys.exit(1)
log.info("全 A 含退市: %d", len(stocks))
if args.limit:
stocks = stocks[:args.limit]
import sqlite3
VAL_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB), timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
stats = {"ok": 0, "empty": 0, "failed": 0, "db_rows": 0}
limit_reached = False
t0 = time.time()
conn.execute("BEGIN")
try:
for i, (code, prefix) in enumerate(stocks):
if QUERY_COUNT >= DAILY_LIMIT:
log.warning("query %d 达防线 %d, graceful stop", QUERY_COUNT, DAILY_LIMIT)
limit_reached = True
break
bs_code = f"{prefix}.{code}"
try:
d_rows = fetch_k(bs_code, DAILY_FIELDS, "d", start, end)
n1 = upsert_daily(conn, code, prefix, d_rows)
n2 = 0
if not args.no_15m:
m_rows = fetch_k(bs_code, M15_FIELDS, "15", start, end)
n2 = upsert_15m(conn, code, prefix, m_rows)
stats["db_rows"] += n1 + n2
if n1 + n2:
stats["ok"] += 1
else:
stats["empty"] += 1
except Exception as e:
stats["failed"] += 1
if stats["failed"] <= 5:
log.warning("%s err: %s", code, e)
if not relogin():
log.error("%s relogin 失败, 跳过", code)
if (i + 1) % 500 == 0:
log.info("进度 %d/%d ok=%d empty=%d failed=%d q=%d (%.0fs)",
i + 1, len(stocks), stats["ok"], stats["empty"],
stats["failed"], QUERY_COUNT, time.time() - t0)
if i < len(stocks) - 1:
time.sleep(BS_INTERVAL)
conn.execute("COMMIT")
except Exception as e:
conn.execute("ROLLBACK")
log.error("[FATAL] rollback: %s", e)
sys.exit(1)
finally:
conn.close()
try:
bs.logout()
except Exception:
pass
log.info("[DONE] ok=%d empty=%d failed=%d db_rows=%d query=%d 耗时%.0fs",
stats["ok"], stats["empty"], stats["failed"], stats["db_rows"],
QUERY_COUNT, time.time() - t0)
sys.exit(3 if limit_reached else 0)
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
# bs_eod_wrapper.ps1 — sanguo-bs-eod schtask wrapper (18:05 baostock 个股 EOD 增量)
# 方案A: 个股日线+15min -> dbbardata('d'/'15m') + pe/pb -> valuation_baostock
$env:http_proxy = ''
$env:https_proxy = ''
$env:all_proxy = ''
Set-Location C:\sanguo_vnpy_v2
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
$logDir = 'C:\sanguo_vnpy_v2\data\migration_logs'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$log = Join-Path $logDir "bs_eod_$ts.txt"
C:\Python310\python.exe -X utf8 C:\sanguo_vnpy_v2\scripts\data_platform\bs_eod.py *>> $log
exit $LASTEXITCODE
@@ -0,0 +1,14 @@
# check_schtask_runs.ps1 — 查 4 个数据 schtask 上次运行+结果 + daily-update 数据抽查
$ErrorActionPreference = 'SilentlyContinue'
Write-Output "=== NOW: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
foreach($tn in @('sanguo-daily-update','sanguo-bs-akshare','sanguo-bs-daily-increment','sanguo-index-hist')){
$i = Get-ScheduledTaskInfo -TaskName $tn
if($i){
$code = ('0x{0:X}' -f $i.LastTaskResult)
Write-Output ("{0,-28} last={1} result={2} next={3}" -f $tn, $i.LastRunTime, $code, $i.NextRunTime)
} else { Write-Output "$tn NOT FOUND" }
}
Write-Output ""
Write-Output "=== daily-update 数据抽查(data/qfq/2026 max date, 应=2026-07-22) ==="
$py = 'C:\Python310\python.exe'
& $py -X utf8 -c "import pandas as pd,glob,os; [print(os.path.basename(f),'maxdate=',pd.read_parquet(f)['date'].max()) for f in [r'C:\sanguo_vnpy_v2\data\qfq\2026\sh600519_daily.parquet', r'C:\sanguo_vnpy_v2\data\qfq\2026\sh510300_daily.parquet'] if os.path.exists(f)]"
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""cleanup_staging.py — 单元1: 清理存量垃圾(零风险)。
删:
- data/_staging_xtdata/ (xtata build staging, 已合并到 qfq/raw)
- data/_xtdata.tar (一次性迁移包)
移:
- data/cta_* dbg_* smoke_* trace_* (json/log 回测产物) → data/backtest_files/
用法:
python cleanup_staging.py --dry-run # 只列清单+大小
python cleanup_staging.py --execute # 实际删/移
"""
import argparse
import shutil
from pathlib import Path
BASE = Path(r"C:\sanguo_vnpy_v2")
DATA = BASE / "data"
DELETE_DIRS = [DATA / "_staging_xtdata"]
DELETE_FILES = [DATA / "_xtdata.tar"]
MOVE_GLOBS = ["cta_*", "dbg_*", "smoke_*", "trace_*"]
MOVE_TO = DATA / "backtest_files"
def size_str(n):
for u in ["B", "KB", "MB", "GB"]:
if n < 1024:
return f"{n:.1f}{u}"
n /= 1024
return f"{n:.1f}TB"
def dir_size(p):
total = 0
count = 0
for f in p.rglob("*"):
if f.is_file():
try:
total += f.stat().st_size
count += 1
except OSError:
pass
return total, count
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--execute", action="store_true")
a = ap.parse_args()
if not a.dry_run and not a.execute:
ap.error("需指定 --dry-run 或 --execute")
log = []
for d in DELETE_DIRS:
if d.exists():
sz, n = dir_size(d)
log.append(f"[DEL DIR] {d} files={n} size={size_str(sz)}")
if a.execute:
shutil.rmtree(d, ignore_errors=True)
log.append(f" -> deleted")
else:
log.append(f"[DEL DIR] {d} (不存在)")
for f in DELETE_FILES:
if f.exists():
sz = f.stat().st_size
log.append(f"[DEL FILE] {f} size={size_str(sz)}")
if a.execute:
f.unlink(missing_ok=True)
log.append(f" -> deleted")
else:
log.append(f"[DEL FILE] {f} (不存在)")
MOVE_TO.mkdir(parents=True, exist_ok=True)
moved = 0
for pat in MOVE_GLOBS:
for f in DATA.glob(pat):
if f.is_file():
moved += 1
if moved <= 20:
log.append(f"[MOVE] {f.name}")
if a.execute:
try:
shutil.move(str(f), str(MOVE_TO / f.name))
except OSError as e:
log.append(f" move err {f.name}: {e}")
log.append(f"[MOVE] 总计 {moved} 个回测产物 -> backtest_files/")
print("\n".join(log))
print(f"\nMODE: {'DRY-RUN (未实际操作)' if a.dry_run else 'EXECUTED'}")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dbbardata_probe.py — 只读探测 dbbardata 现状(schema + 抽样各 interval 覆盖)。
走 symbol 索引,避免全表 COUNT(亿级慢)。回答:
- dbbardata 有哪些 interval(d/15m/5m)
- 个股日线(interval='d' 类)含不含退市股(000005/000023/600811/600074)
- 在市股(600519/000001)日线日期范围
- ETF(510300/159915)有没有(判断 dbbardata 是否覆盖 ETF)
用于决定迁移单元5(dbbardata 个股日线切 baostock 源)的工作量。
"""
import sqlite3
from pathlib import Path
DB = Path(r"C:\sanguo_vnpy_v2\data\quant_trading.db")
c = sqlite3.connect(str(DB), timeout=60)
c.execute("PRAGMA busy_timeout = 60000")
cols = [r[1] for r in c.execute("PRAGMA table_info(dbbardata)")]
print(f"DB: {DB}")
print(f"dbbardata cols({len(cols)}): {cols}")
print("\n=== 抽样: 各 symbol 的 interval 覆盖 (走索引, 快) ===")
samples = {
"退市": ["000005", "000023", "600811", "600074"],
"在市个股": ["600519", "000001"],
"ETF/基金": ["510300", "159915", "510050"],
}
for label, syms in samples.items():
print(f"\n[{label}]")
for sym in syms:
rows = c.execute(
"SELECT interval, COUNT(*), MIN(datetime), MAX(datetime) "
"FROM dbbardata WHERE symbol=? GROUP BY interval",
(sym,),
).fetchall()
print(f" {sym}: {rows}")
c.close()
@@ -0,0 +1,25 @@
# diag_daily_update.ps1 — 诊断 sanguo-daily-update 为何跑 2.5h
Write-Output "=== NOW: $(Get-Date) ==="
Write-Output "=== python 进程 (CPU秒/内存MB/启动时间) ==="
Get-Process python -ErrorAction SilentlyContinue |
Select-Object Id, @{n='CPU_s'; e={[math]::Round($_.CPU, 1)}},
@{n='WS_MB'; e={[math]::Round($_.WS / 1MB, 0)}}, StartTime |
Format-Table -Auto
Write-Output "=== schtask sanguo-daily-update (状态+执行的命令) ==="
$i = Get-ScheduledTaskInfo -TaskName sanguo-daily-update
Write-Output ("last={0} result=0x{1:X} next={2}" -f $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime)
Write-Output "--- Task Action (实际执行) ---"
(Get-ScheduledTask -TaskName sanguo-daily-update).Actions |
Select-Object Execute, Arguments | Format-List
Write-Output "=== daily_update.log tail 40 ==="
Get-Content C:\sanguo_vnpy_v2\data\daily_update.log -Tail 40 -ErrorAction SilentlyContinue
Write-Output "=== qfq/ 各年: 文件数 + 最新写入时间 ==="
Get-ChildItem C:\sanguo_vnpy_v2\data\qfq -Directory -ErrorAction SilentlyContinue | ForEach-Object {
$files = Get-ChildItem $_.FullName -File -ErrorAction SilentlyContinue
$mx = ($files | Sort-Object LastWriteTime -Descending | Select-Object -First 1).LastWriteTime
Write-Output (" {0}: files={1} lastwrite={2}" -f $_.Name, $files.Count, $mx)
}
Write-Output "=== qfq/2026 最新写入 top 5 (看在不在写) ==="
Get-ChildItem C:\sanguo_vnpy_v2\data\qfq\2026 -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 5 Name, LastWriteTime |
Format-Table -Auto
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""fix_vps_config.py — 单元2: VPS config data_paths 统一到 C:/sanguo_vnpy_v2/data (正斜杠)。
- 备份原 config -> data_platform.yaml.bak_nas (NAS 路径版)
- data_paths 改 VPS 本地 + daily_dir 统一 qfq (消除 daily/ vs qfq/ 分叉)
- 保留其他段 (yaml safe_dump, 丢注释但语义不变)
- 改完立即调 read_parquet_daily 验证 datareader 能读本地
注: rsync 同步仓库 config 会覆盖 VPS config (NAS 版)。部署 rsync 须 --exclude config,
或 VPS 用独立 config 文件。此处先改 VPS config 让回测读本地, 部署排除留作 TODO。
"""
import shutil
import sys
from pathlib import Path
import yaml
CFG = Path(r"C:\sanguo_vnpy_v2\config\data_platform.yaml")
bak = CFG.with_suffix(".yaml.bak_nas")
if not bak.exists():
shutil.copy2(CFG, bak)
print(f"backup -> {bak.name}")
else:
print(f"backup 已存在: {bak.name}")
d = yaml.safe_load(CFG.read_text(encoding="utf-8"))
print("OLD data_paths:", d.get("data_paths"))
d["data_paths"] = {
"daily_dir": "C:/sanguo_vnpy_v2/data/qfq",
"raw_dir": "C:/sanguo_vnpy_v2/data/raw",
"qfq_dir": "C:/sanguo_vnpy_v2/data/qfq",
"minute_15_dir": "C:/sanguo_vnpy_v2/data/minute_15",
"minute_15_qfq_dir": "C:/sanguo_vnpy_v2/data/minute_15",
"minute_15_raw_dir": "C:/sanguo_vnpy_v2/data/minute_15",
"vnpy_db": "C:/sanguo_vnpy_v2/data/quant_trading.db",
"stock_list": "C:/sanguo_vnpy_v2/data/stock_basic_info.csv",
}
CFG.write_text(yaml.safe_dump(d, allow_unicode=True, sort_keys=False), encoding="utf-8")
print("NEW data_paths:", d["data_paths"])
# 验证 datareader 读本地
sys.path.insert(0, r"C:\sanguo_vnpy_v2")
from sanguo_data.config import load_config
from sanguo_data.datareader import read_parquet_daily
cfg = load_config()
print("cfg daily_dir:", cfg.data_paths["daily_dir"])
print("cfg vnpy_db:", cfg.data_paths["vnpy_db"])
bars = read_parquet_daily("600519", "2026-07-01", "2026-07-22", cfg)
print("600519 bars:", len(bars),
"last:", bars[-1].datetime.date() if bars else "NONE")
print("CONFIG FIX DONE" if bars else "CONFIG FIX WARN: 0 bars")
@@ -0,0 +1,9 @@
# kill_backup_only.ps1 — 杀卡在 integrity_check 的 backup_db.py python(产物 .bak 已完整验证)
$bakProc = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -match 'backup_db|audit_data_layout|diag_daily' }
if ($bakProc) {
Write-Output ("killed backup python pid={0}" -f $bakProc.ProcessId)
Stop-Process -Id $bakProc.ProcessId -Force -ErrorAction SilentlyContinue
} else {
Write-Output "backup python 不在(已结束/被杀)"
}
@@ -0,0 +1,23 @@
# kill_backup_verify.ps1 — 杀卡在 integrity_check 的 backup python, 快速验证 .bak 可读
$bakProc = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*backup_db*' }
if ($bakProc) {
Write-Output ("kill backup python pid={0}" -f $bakProc.ProcessId)
Stop-Process -Id $bakProc.ProcessId -Force -ErrorAction SilentlyContinue
Start-Sleep 3
} else { Write-Output "backup python 已不在" }
$code = @"
import sqlite3, os
BAK = r'C:\sanguo_vnpy_v2\data\quant_trading.db.bak_20260722'
print('bak size=%.2f GB' % (os.path.getsize(BAK)/1024**3))
c = sqlite3.connect(BAK, timeout=30)
tabs = [r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
print('tables:', tabs)
print('backtest_stats:', c.execute('SELECT COUNT(*) FROM backtest_stats').fetchone()[0])
print('bs_adjust_factor:', c.execute('SELECT COUNT(*) FROM bs_adjust_factor').fetchone()[0])
print('bs_index_constituent:', c.execute('SELECT COUNT(*) FROM bs_index_constituent').fetchone()[0])
c.close()
print('BAK READABLE OK')
"@
$code | & 'C:\Python310\python.exe' -X utf8 -
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""merge_constituent.py — 单元3 合并: staging -> constituent_unified, bs_index_constituent -> _old。
前提: migrate_constituent.py 已建 constituent_unified_staging (验证通过)。
- constituent_unified_staging RENAME constituent_unified (正式表)
- bs_index_constituent RENAME bs_index_constituent_old (保留 988 时点精度, 不删)
- 建索引 (index_code, code) 加速 get_index_stocks 查询
回滚: rename 反向 (constituent_unified->staging, _old->bs_index_constituent)
"""
import sqlite3
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
c = sqlite3.connect(DB, timeout=60)
c.execute("PRAGMA busy_timeout = 60000")
c.execute("ALTER TABLE constituent_unified_staging RENAME TO constituent_unified")
print("renamed constituent_unified_staging -> constituent_unified")
c.execute("ALTER TABLE bs_index_constituent RENAME TO bs_index_constituent_old")
print("renamed bs_index_constituent -> bs_index_constituent_old (时点精度保留)")
c.execute("CREATE INDEX IF NOT EXISTS idx_constituent_unified "
"ON constituent_unified(index_code, code)")
c.commit()
print("constituent_unified rows:", c.execute(
"SELECT COUNT(*) FROM constituent_unified").fetchone()[0])
print("bs_index_constituent_old rows:", c.execute(
"SELECT COUNT(*) FROM bs_index_constituent_old").fetchone()[0])
c.close()
print("MERGE DONE")
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""merge_daily_baostock.py — 单元4 合并: staging->dbbardata + daily_baostock_full->_old + 补 parquet。
前置: migrate_daily_baostock.py 已灌 dbbardata_staging_daily (1826万, verify OK);
dbbardata 有 UNIQUE(symbol,exchange,interval,datetime) (probe 确认)。
1. INSERT OR REPLACE staging -> dbbardata('d'): 个股(含退市) REPLACE 在市/新增退市 (治偏差)
2. daily_baostock_full -> daily_baostock_full_old (保留, pe/pb 源)
3. pe/pb -> data/valuation_baostock/<year>.parquet (从 _old, isST->int 修 ArrowTypeError)
回滚: rename daily_baostock_full_old->daily_baostock_full; dbbardata 从 .bak 恢复
"""
import sqlite3
from pathlib import Path
import pandas as pd
DB = Path(r"C:\sanguo_vnpy_v2\data\quant_trading.db")
VAL_DIR = Path(r"C:\sanguo_vnpy_v2\data\valuation_baostock")
def log(m):
print(m, flush=True)
VAL_DIR.mkdir(parents=True, exist_ok=True)
c = sqlite3.connect(str(DB), timeout=600)
c.execute("PRAGMA busy_timeout = 600000")
c.execute("PRAGMA synchronous = NORMAL")
log("1. INSERT OR REPLACE staging(1826万) -> dbbardata('d') 含退市治偏差 ...")
# dbbardata 全列 NOT NULL; 跳过停牌(OHLC NULL), volume/turnover COALESCE 0
c.execute(
"INSERT OR REPLACE INTO dbbardata "
"(symbol,exchange,datetime,interval,volume,turnover,open_interest,"
"open_price,high_price,low_price,close_price) "
"SELECT symbol, exchange, datetime, interval, "
"COALESCE(volume, 0), COALESCE(turnover, 0), 0, "
"open_price, high_price, low_price, close_price "
"FROM dbbardata_staging_daily "
"WHERE open_price IS NOT NULL AND high_price IS NOT NULL "
"AND low_price IS NOT NULL AND close_price IS NOT NULL")
c.commit()
log(" INSERT OR REPLACE done")
log("2. daily_baostock_full -> daily_baostock_full_old")
c.execute("ALTER TABLE daily_baostock_full RENAME TO daily_baostock_full_old")
c.commit()
log("3. pe/pb -> valuation_baostock/<year>.parquet (isST->int, 流式避 OOM)")
val_by_year = {}
n = 0
for chunk in pd.read_sql(
"SELECT symbol,exchange,date,peTTM,psTTM,pcfNcfTTM,pbMRQ,turn,pctChg,isST "
"FROM daily_baostock_full_old", c, chunksize=200000):
n += 1
chunk["isST"] = pd.to_numeric(chunk["isST"], errors="coerce").fillna(0).astype(int)
chunk["_y"] = pd.to_datetime(chunk["date"]).dt.year
for yr, sub in chunk.groupby("_y"):
sub = sub.drop(columns=["_y"])
val_by_year.setdefault(int(yr), []).append(sub)
if n % 10 == 0:
log(f" parquet chunk#{n}")
for yr in sorted(val_by_year):
df_y = pd.concat(val_by_year[yr], ignore_index=True).sort_values(["symbol", "date"])
df_y.to_parquet(VAL_DIR / f"{yr}.parquet", index=False)
log(f" {yr}: {len(df_y)} rows")
log("4. verify dbbardata('d'):")
for sym, label in [("000005", "退市"), ("000023", "退市"), ("600519", "在市"), ("510300", "ETF")]:
r = c.execute(
"SELECT COUNT(*), MAX(datetime) FROM dbbardata "
"WHERE symbol=? AND interval='d'", (sym,)).fetchone()
log(f" {sym}({label}): {r}")
c.close()
log("MERGE DONE")
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""migrate_constituent.py — 单元3: 合并成份股 -> constituent_unified_staging (全集型)。
设计 (spec §14, 治幸存者偏差选股池):
- baostock (bs_index_constituent 988 时点) -> 聚合成全集 (hs300/zz500/sz50 -> 000300/000905/000016),
in_current=最后时点成份, was_removed=历史入选过但已踢出
- 深证/国证 _union.parquet (399001/399006/399005/399330) -> 直入 (akshare cni)
- 中证1000/2000 _snapshot.parquet (000852/932000) -> 当前 (akshare csindex)
- 新浪 _sina.parquet -> 丢弃 (baostock 300/500/50 已权威)
- code 统一 6 位无前缀 (sh.600000 / 600519.SH -> 600519)
输出 constituent_unified_staging(index_code, code, code_name, source, in_current, was_removed)。
验证 OK 后手动 rename: staging->constituent_unified, bs_index_constituent->_old。
"""
import glob
import os
import re
import sqlite3
import pandas as pd
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
HIST = r"C:\sanguo_vnpy_v2\data\index_const_hist"
STAGING = "constituent_unified_staging"
BS_MAP = {"hs300": "000300", "zz500": "000905", "sz50": "000016"}
def norm_code(code):
s = re.sub(r"^(sh|sz|SH|SZ)\.?", "", str(code))
s = re.sub(r"\.(SH|SZ|sh|sz)$", "", s)
return s.zfill(6) if s.isdigit() and len(s) <= 6 else s
def main():
c = sqlite3.connect(DB, timeout=60)
c.execute("PRAGMA busy_timeout = 60000")
# 1. baostock -> 全集
df_bs = pd.read_sql(
"SELECT updateDate, index_code, code, code_name FROM bs_index_constituent", c)
df_bs["index_code"] = df_bs["index_code"].map(BS_MAP)
df_bs["code"] = df_bs["code"].apply(norm_code)
last_sets = {}
for idx, grp in df_bs.groupby("index_code"):
last_d = grp["updateDate"].max()
last_sets[idx] = set(grp[grp["updateDate"] == last_d]["code"])
pool = (df_bs.groupby(["index_code", "code"])["code_name"]
.first().reset_index())
pool["in_current"] = pool.apply(
lambda r: r["code"] in last_sets.get(r["index_code"], set()), axis=1)
pool["was_removed"] = ~pool["in_current"]
pool["source"] = "baostock"
print(f"[baostock] pool rows={len(pool)} (300/500/50 全集)")
# 2. 深证 union
deep = []
for f in sorted(glob.glob(os.path.join(HIST, "*_union.parquet"))):
d = pd.read_parquet(f)[["index_code", "code", "code_name",
"in_current", "was_removed"]]
d["source"] = "akshare_cni"
deep.append(d)
df_deep = pd.concat(deep, ignore_index=True) if deep else pd.DataFrame(
columns=["index_code", "code", "code_name", "in_current", "was_removed", "source"])
df_deep["code"] = df_deep["code"].apply(norm_code)
print(f"[深证 union] rows={len(df_deep)}")
# 3. 中证 snapshot
snap = []
for f in [os.path.join(HIST, "000852_snapshot.parquet"),
os.path.join(HIST, "932000_snapshot.parquet")]:
if os.path.exists(f):
d = pd.read_parquet(f)[["index_code", "code", "code_name"]]
d["in_current"] = True
d["was_removed"] = False
d["source"] = "akshare_csindex"
snap.append(d)
df_snap = pd.concat(snap, ignore_index=True) if snap else pd.DataFrame(
columns=["index_code", "code", "code_name", "in_current", "was_removed", "source"])
df_snap["code"] = df_snap["code"].apply(norm_code)
print(f"[中证 snapshot] rows={len(df_snap)}")
# 合并 + 去重 (同 index+code+source)
all_df = pd.concat([pool, df_deep, df_snap], ignore_index=True)
all_df = all_df.drop_duplicates(["index_code", "code", "source"])
print(f"\n[TOTAL] constituent_unified: {len(all_df)} rows, "
f"{all_df['index_code'].nunique()} indices")
print("\n各指数分布:")
print(all_df.groupby("index_code").agg(
n=("code", "count"), src=("source", "first"),
in_cur=("in_current", "sum"), removed=("was_removed", "sum")))
# 写 staging
c.execute(f"DROP TABLE IF EXISTS {STAGING}")
c.execute(f"""CREATE TABLE {STAGING} (
index_code TEXT, code TEXT, code_name TEXT, source TEXT,
in_current INTEGER, was_removed INTEGER)""")
work = all_df[["index_code", "code", "code_name", "source",
"in_current", "was_removed"]].copy()
work["in_current"] = work["in_current"].astype(int)
work["was_removed"] = work["was_removed"].astype(int)
c.executemany(f"INSERT INTO {STAGING} VALUES (?,?,?,?,?,?)",
work.itertuples(index=False, name=None))
c.commit()
n = c.execute(f"SELECT COUNT(*) FROM {STAGING}").fetchone()[0]
# 抽样验证
print(f"\n[staging] {STAGING}: {n} rows")
print("sample 300:", c.execute(
"SELECT COUNT(*), SUM(in_current), SUM(was_removed) FROM "
f"{STAGING} WHERE index_code='000300'").fetchone())
print("sample 399001:", c.execute(
"SELECT COUNT(*), SUM(in_current), SUM(was_removed) FROM "
f"{STAGING} WHERE index_code='399001'").fetchone())
print("sample 000852:", c.execute(
"SELECT COUNT(*) FROM " f"{STAGING} WHERE index_code='000852'").fetchone())
c.close()
print("\nMIGRATE STAGING DONE (未 rename, 验证 OK 后单独合并)")
if __name__ == "__main__":
main()
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""migrate_daily_baostock.py — 单元4: daily_baostock_full 拆分 -> staging (本地DB, 无网络)。
- OHLCV -> dbbardata_staging_daily (interval='d', 含退市, 治回测幸存者偏差)
exchange SH/SZ -> SSE/SZSE; date -> 'YYYY-MM-DD 00:00:00'; amount->turnover
- pe/pb/turn/pctChg/isST -> data/valuation_baostock/<year>.parquet (按年宽表)
- 全量读 + groupby year (避免 17 次全表扫; 内存峰值~5GB, VPS 16GB OK)
- staging 验证后单独合并 (INSERT OR REPLACE dbbardata + daily_baostock_full->_old)
"""
import sqlite3
from pathlib import Path
import pandas as pd
DB = Path(r"C:\sanguo_vnpy_v2\data\quant_trading.db")
VAL_DIR = Path(r"C:\sanguo_vnpy_v2\data\valuation_baostock")
EXC_MAP = {"SH": "SSE", "SZ": "SZSE"}
def log(m):
print(m, flush=True)
c = sqlite3.connect(str(DB), timeout=120)
c.execute("PRAGMA busy_timeout = 120000")
c.execute("PRAGMA synchronous = NORMAL")
cols = [r[1] for r in c.execute("PRAGMA table_info(daily_baostock_full)")]
log(f"daily_baostock_full cols({len(cols)}): {cols}")
total = c.execute("SELECT COUNT(*) FROM daily_baostock_full").fetchone()[0]
mn, mx = c.execute("SELECT MIN(date), MAX(date) FROM daily_baostock_full").fetchone()
log(f" rows={total} date {mn}~{mx}")
# staging 表
c.execute("DROP TABLE IF EXISTS dbbardata_staging_daily")
c.execute("""CREATE TABLE dbbardata_staging_daily (
symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT,
volume REAL, turnover REAL, open_interest REAL,
open_price REAL, high_price REAL, low_price REAL, close_price REAL)""")
c.execute("CREATE INDEX idx_staging_daily_sym ON dbbardata_staging_daily(symbol, datetime)")
c.commit()
VAL_DIR.mkdir(parents=True, exist_ok=True)
log("流式读 daily_baostock_full (chunksize=200000) -> staging + pe/pb 累积 (避 OOM) ...")
val_by_year = {}
db_total = val_total = 0
n_chunk = 0
for chunk in pd.read_sql("SELECT * FROM daily_baostock_full", c, chunksize=200000):
n_chunk += 1
odb = pd.DataFrame({
"symbol": chunk["symbol"].values,
"exchange": chunk["exchange"].map(EXC_MAP).values,
"datetime": (chunk["date"].astype(str) + " 00:00:00").values,
"interval": "d",
"volume": chunk["volume"].values,
"turnover": chunk["amount"].values,
"open_interest": 0.0,
"open_price": chunk["open"].values,
"high_price": chunk["high"].values,
"low_price": chunk["low"].values,
"close_price": chunk["close"].values,
})
c.executemany(
"INSERT INTO dbbardata_staging_daily VALUES (?,?,?,?,?,?,?,?,?,?,?)",
odb.itertuples(index=False, name=None))
c.commit()
db_total += len(odb)
chunk["_y"] = pd.to_datetime(chunk["date"]).dt.year
for yr, sub in chunk.groupby("_y"):
vdf = sub[["symbol", "exchange", "date", "peTTM", "psTTM", "pcfNcfTTM",
"pbMRQ", "turn", "pctChg", "isST"]]
val_by_year.setdefault(int(yr), []).append(vdf)
val_total += len(vdf)
if n_chunk % 10 == 0:
log(f" chunk#{n_chunk} db累计={db_total} val累计={val_total}")
log("写 valuation_baostock/<year>.parquet ...")
for yr in sorted(val_by_year):
df_y = pd.concat(val_by_year[yr], ignore_index=True).sort_values(["symbol", "date"])
df_y.to_parquet(VAL_DIR / f"{yr}.parquet", index=False)
log(f" {yr}: {len(df_y)} rows")
# 退市/在市抽样验证
log("\n[verify staging]")
for sym, label in [("000005", "退市"), ("000023", "退市"),
("600811", "退市"), ("600519", "在市"), ("000001", "在市")]:
r = c.execute(
"SELECT COUNT(*), MIN(datetime), MAX(datetime) "
"FROM dbbardata_staging_daily WHERE symbol=?", (sym,)).fetchone()
log(f" {sym}({label}): {r}")
log(f" staging distinct symbol: "
f"{c.execute('SELECT COUNT(DISTINCT symbol) FROM dbbardata_staging_daily').fetchone()[0]}")
c.close()
log(f"\nstaging dbbardata_staging_daily: {db_total} rows")
log(f"valuation_baostock: {val_total} rows, "
f"{len(list(VAL_DIR.glob('*.parquet')))} 年 parquet")
log("MIGRATE STAGING DONE (未合并主表, 验证 OK 后单独 merge)")
@@ -0,0 +1,580 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""csindex 公告解析 - 重建中证1000/2000 历史成份股曾经入选集 (治幸存者偏差)
数据源:
1. 列表 API: POST https://www.csindex.com.cn/csindex-home/announcement/queryAnnouncementByVo
payload: {lang, classlist, indexlist, page:{desc,key,page,rows}, related_topics, typelist}
2. 详情 API: GET https://www.csindex.com.cn/csindex-home/announcement/queryAnnouncementById?id={nid}
{content (HTML), enclosureList:[{fileUrl, fileName}]}
3. 附件: PDF (定期调整 多指数合并) 或 xlsx/xls (临时调整 单/多指数)
实证覆盖 (2026-07-22 实地验证):
CSI 1000 (000852) 2014-09 发布:
- 2018-07-11 起 csindex 有调整公告 (28 份: 7 定期 + 21 临时)
- 2014-2018 期间的 June/Dec 定期调整不在 csindex (gap: ~7 round)
CSI 2000 (932000) 2023-08-10 发布:
- csindex 上无任何 CSI 2000 样本调整公告 (定期调整 PDF 中均无 CSI 2000 section)
- 仅 launch xlsx 有初始 2000 只样本 (2023-08-10)
- 历史/最新调整需查指数详情页 "拟生效样本" 或 wind/choice (不在本脚本范围)
输出:
data/index_const_hist/000852_announce_union.parquet - CSI 1000 曾经入选集
data/index_const_hist/932000_announce_union.parquet - CSI 2000 (initial only)
schema: updateDate / index_code / code / code_name / adjust_type / notice_id / source
约束:
- 直连不走代理 (unset)
- 串行 + sleep(1.0~1.5s)
- UA header
"""
import argparse
import json
import logging
import os
import re
import socket
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# ======================== 硬约束 ========================
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
os.environ.pop(_k, None)
socket.setdefaulttimeout(30)
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
import pandas as pd # noqa: E402
import openpyxl # noqa: E402
import pdfplumber # noqa: E402
# ======================== 配置 ========================
BASE = "https://www.csindex.com.cn/csindex-home"
LIST_URL = f"{BASE}/announcement/queryAnnouncementByVo"
DETAIL_URL = f"{BASE}/announcement/queryAnnouncementById"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
DEFAULT_OUT_DIR = "/Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2/data/index_const_hist"
OUT_DIR = Path(os.environ.get("INDEX_HIST_OUT_DIR", DEFAULT_OUT_DIR))
CACHE_DIR = Path(os.environ.get("CSINDEX_CACHE", "/tmp/csindex_raw"))
DETAIL_CACHE_DIR = CACHE_DIR / "detail"
# CSI 1000 相关调整公告 id (实证已发现, 脚本也支持自动发现)
CSI1000_REGULAR_IDS = [14796, 15044, 15267, 15471, 15690, 3006000, 3006137]
CSI1000_TEMP_IDS = [
12446, 12965, 13070, 13212, 13281, 13334, 13765, 14092,
14842, 15019, 15232, 15342, 15357, 15390, 15575, 15613, 15648,
1006042, 3006027, 3006041, 3006120,
]
# CSI 2000 launch xlsx (initial 2000 stocks)
CSI2000_LAUNCH_ID = 14883
# ======================== logging ========================
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("csindex")
# ======================== HTTP ========================
def _post_json(url: str, payload: dict, timeout: int = 30) -> dict:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=data, method="POST",
headers={"Content-Type": "application/json", "User-Agent": UA},
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
def _get_json(url: str, timeout: int = 30) -> dict:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
def _download(url: str, target: Path, timeout: int = 60) -> int:
"""下载含中文名 URL (自动 quote, 防双重编码)"""
p = urllib.parse.urlsplit(url)
path_q = p.path if "%" in p.path else urllib.request.quote(p.path)
quoted = urllib.parse.urlunsplit((p.scheme, p.netloc, path_q, p.query, p.fragment))
req = urllib.request.Request(quoted, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
data = r.read()
target.parent.mkdir(parents=True, exist_ok=True)
with open(target, "wb") as f:
f.write(data)
return len(data)
# ======================== 1. 列表 ========================
def fetch_all_notices(cache_path: Path, force: bool = False) -> List[dict]:
"""拉全量公告列表 (cached)"""
if cache_path.exists() and not force:
log.info(f"使用缓存列表: {cache_path}")
with open(cache_path, encoding="utf-8") as f:
return json.load(f)
log.info(f"分页拉取全量公告: {LIST_URL}")
all_items = []
page = 1
while True:
payload = {
"lang": "cn", "classlist": [], "indexlist": [],
"page": {"desc": "", "key": "", "page": page, "rows": 100},
"related_topics": [], "typelist": [],
}
try:
d = _post_json(LIST_URL, payload)
except Exception as e:
log.error(f"page {page} err: {e}; retry once after 5s")
time.sleep(5)
d = _post_json(LIST_URL, payload)
if d.get("code") != "200":
log.error(f"page {page} API err: {d}")
break
items = d.get("data") or []
all_items.extend(items)
total = d.get("total") or 0
log.info(f" page {page}: +{len(items)} (cum={len(all_items)}/{total})")
if not items or len(all_items) >= total:
break
page += 1
time.sleep(1.0)
cache_path.parent.mkdir(parents=True, exist_ok=True)
with open(cache_path, "w", encoding="utf-8") as f:
json.dump(all_items, f, ensure_ascii=False)
log.info(f"缓存列表: {cache_path} (total={len(all_items)})")
return all_items
def filter_csi1000_notices(notices: List[dict]) -> List[dict]:
"""筛 CSI 1000 调整公告 (theme=指数调样 + title 含 中证1000)"""
out = []
seen_ids = set()
for x in notices:
title = x.get("title") or ""
if x.get("theme") != "指数调样":
continue
if "中证1000" not in title:
continue
if "调整" not in title:
continue
# 排除 "不实施" 通知
if "不实施" in title:
continue
if x["id"] in seen_ids:
continue
seen_ids.add(x["id"])
out.append(x)
out.sort(key=lambda x: x.get("publishDate", ""))
return out
# ======================== 2. 详情 + 下载 ========================
def fetch_detail(nid: int, cache_dir: Path) -> dict:
"""详情 (cached by id)"""
cache_file = cache_dir / f"detail_{nid}.json"
if cache_file.exists():
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
try:
d = _get_json(f"{DETAIL_URL}?id={nid}")
except Exception as e:
log.error(f"detail {nid} err: {e}")
return {}
cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False)
time.sleep(1.0)
return d
def extract_file_urls(detail_data: dict) -> List[Tuple[str, str]]:
"""从 enclosureList + content 内嵌 href 抽 (fileName, fileUrl)"""
urls = []
for e in (detail_data.get("enclosureList") or []):
fu = e.get("fileUrl")
fn = e.get("fileName") or "file"
if fu:
urls.append((fn, fu))
content = detail_data.get("content") or ""
for m in re.finditer(r'href="([^"]+)"', content):
fu = m.group(1)
if re.search(r"\.(xlsx|pdf|xls|csv)(\?|$)", fu, re.I):
urls.append(("inline", fu))
# 去重保序
seen, out = set(), []
for fn, fu in urls:
if fu not in seen:
seen.add(fu)
out.append((fn, fu))
return out
def download_notice_files(nid: int, publish_date: str, urls: List[Tuple[str, str]], cache_dir: Path) -> List[Path]:
"""下载该公告的所有附件,返本地路径列表"""
out = []
for fn, fu in urls:
ext = fu.rsplit(".", 1)[-1].lower().split("?")[0]
local = cache_dir / f"{nid}_{publish_date}.{ext}"
if not local.exists():
try:
sz = _download(fu, local)
log.info(f" dl [{sz}B] {fn} -> {local.name}")
except Exception as ex:
log.error(f" dl ERR {fu}: {ex}")
continue
else:
log.info(f" cache {local.name}")
out.append(local)
return out
# ======================== 3. 解析 ========================
def _norm_code(c) -> Optional[str]:
"""规整为 6 位字符串代码 (前补 0)"""
if c is None:
return None
s = str(c).strip()
# 排除空/NA
if not s or s.lower() in ("nan", "none", ""):
return None
# 纯数字 (可能 int 转 str 失去前导 0)
if s.isdigit():
s = s.zfill(6)
if len(s) > 6:
return None # 异常长
return s
# H开头 (港股代码, CSI 1000 不含, 跳过)
if s.startswith("H") or s.startswith("688") and not s.isdigit():
return None
# 6 位字母数字 (如 SH/SZ 前缀)
m = re.search(r"(\d{6})", s)
return m.group(1) if m else None
def parse_xlsx_adjustments(path: Path, target_index_code: str = "000852") -> Tuple[List[dict], List[dict]]:
"""解析 xlsx/xls -> (add_rows, remove_rows)
每个 row = {code, code_name}
Sheet 名: 调入/调出 或 换入/换出 (反向)
"""
add_rows, remove_rows = [], []
if path.suffix.lower() == ".xls":
xl = pd.ExcelFile(path)
df_map = {sn: xl.parse(sn, dtype=str) for sn in xl.sheet_names}
else:
wb = openpyxl.load_workbook(path, data_only=True)
df_map = {}
for sn in wb.sheetnames:
ws = wb[sn]
rows = list(ws.iter_rows(values_only=True))
if not rows:
continue
df_map[sn] = pd.DataFrame(rows[1:], columns=rows[0])
for sn, df in df_map.items():
if df.empty or len(df.columns) < 4:
continue
# 判定方向
sn_norm = sn.strip()
if sn_norm in ("调入", "换入", "新增"):
direction = "add"
elif sn_norm in ("调出", "换出", "删除"):
direction = "remove"
else:
continue
# 筛 target index
col0 = df.iloc[:, 0].astype(str).str.strip()
mask = col0 == target_index_code
sub = df[mask]
for _, row in sub.iterrows():
code = _norm_code(row.iloc[2])
name = str(row.iloc[3]).strip() if row.iloc[3] is not None else ""
if not code:
continue
r = {"code": code, "code_name": name}
if direction == "add":
add_rows.append(r)
else:
remove_rows.append(r)
return add_rows, remove_rows
def parse_pdf_adjustments(path: Path, target_section: str = "中证1000") -> Tuple[List[dict], List[dict]]:
"""解析 PDF 的指定指数 section
target_section: '中证1000' or '中证2000'
返 (add_rows, remove_rows)
"""
with pdfplumber.open(path) as pdf:
full_text = "\n".join((p.extract_text() or "") for p in pdf.pages)
# 定位所有 section header
header_re = re.compile(
r"(沪深300|中证500|中证1000|中证2000|中证A\d+|上证\d+|科创50|北证\d+)\s*指数样本调整名单[:]?"
)
headers = list(header_re.finditer(full_text))
if not headers:
return [], []
# 找 target section
target_text = None
for i, m in enumerate(headers):
if m.group(1) == target_section:
start = m.end()
end = headers[i + 1].start() if i + 1 < len(headers) else len(full_text)
target_text = full_text[start:end].strip()
break
if not target_text:
return [], []
# 解析 4 列 (调出代码 调出名称 调入代码 调入名称)
add_rows, remove_rows = [], []
in_data = False
last_side = None # 处理 2 列 (只有一侧) 的情况
for ln in target_text.split("\n"):
ln = ln.strip()
if not ln:
continue
# 表头行跳过
if re.search(r"证券代码|股票代码|调出名单|调入名单|指数代码|换出|换入", ln):
in_data = True
if "调出名单" in ln and "调入名单" not in ln:
last_side = "remove"
elif "调入名单" in ln and "调出名单" not in ln:
last_side = "add"
else:
last_side = None
continue
if not in_data:
continue
# 4 列
m4 = re.match(r"^(\d{6})\s+(\S+?)\s+(\d{6})\s+(\S+)", ln)
if m4:
rc, rn, ac, an = m4.group(1), m4.group(2), m4.group(3), m4.group(4)
remove_rows.append({"code": rc, "code_name": rn})
add_rows.append({"code": ac, "code_name": an})
last_side = None
continue
# 2 列 (单侧)
m2 = re.match(r"^(\d{6})\s+(\S+)", ln)
if m2:
r = {"code": m2.group(1), "code_name": m2.group(2)}
if last_side == "add":
add_rows.append(r)
elif last_side == "remove":
remove_rows.append(r)
return add_rows, remove_rows
# ======================== 4. 主流程 ========================
def process_notice(nid: int, detail_cache: Path, file_cache: Path,
target_index_code: str, target_section: str) -> List[dict]:
"""处理单个公告 -> 返回 records 列表
record: {updateDate, index_code, code, code_name, adjust_type, notice_id, source}
"""
detail = fetch_detail(nid, detail_cache)
if not detail or not detail.get("data"):
log.warning(f" {nid}: detail empty")
return []
data = detail["data"]
publish_date = (data.get("publishDate") or "").split("T")[0]
title = data.get("title") or ""
urls = extract_file_urls(data)
files = download_notice_files(nid, publish_date, urls, file_cache)
if not files:
log.warning(f" {nid} {publish_date}: no files; title={title}")
return []
records = []
for f in files:
ext = f.suffix.lower()
try:
if ext == ".pdf":
add, rem = parse_pdf_adjustments(f, target_section=target_section)
elif ext in (".xlsx", ".xls"):
add, rem = parse_xlsx_adjustments(f, target_index_code=target_index_code)
else:
continue
except Exception as e:
log.error(f" parse {f.name} ERR: {e}")
continue
for r in add:
records.append({
"updateDate": publish_date, "index_code": target_index_code,
"code": r["code"], "code_name": r["code_name"],
"adjust_type": "add", "notice_id": nid, "source": f.name,
})
for r in rem:
records.append({
"updateDate": publish_date, "index_code": target_index_code,
"code": r["code"], "code_name": r["code_name"],
"adjust_type": "remove", "notice_id": nid, "source": f.name,
})
log.info(f" {nid} {publish_date}: parsed add={sum(1 for r in records if r['adjust_type']=='add')}, "
f"remove={sum(1 for r in records if r['adjust_type']=='remove')}, title={title}")
return records
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--out-dir", default=str(OUT_DIR))
parser.add_argument("--cache-dir", default=str(CACHE_DIR))
parser.add_argument("--refresh-list", action="store_true", help="强制重新拉全量公告列表")
parser.add_argument("--only", choices=["1000", "2000", "both"], default="both")
args = parser.parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
cache_dir = Path(args.cache_dir)
detail_cache = cache_dir / "detail"
file_cache = cache_dir / "files"
# 1. 拉/缓存全量列表
notices = fetch_all_notices(cache_dir / "all_notices.json", force=args.refresh_list)
# ========== CSI 1000 ==========
if args.only in ("1000", "both"):
log.info("\n========== CSI 1000 (000852) ==========")
# 过滤 + 附加已知 id
filtered = filter_csi1000_notices(notices)
log.info(f"filtered CSI 1000 adjustment notices: {len(filtered)}")
all_ids = sorted({x["id"] for x in filtered} | set(CSI1000_REGULAR_IDS) | set(CSI1000_TEMP_IDS))
log.info(f"total CSI 1000 notice ids to process: {len(all_ids)}")
records_1000 = []
for nid in all_ids:
try:
recs = process_notice(nid, detail_cache, file_cache, "000852", "中证1000")
records_1000.extend(recs)
except Exception as e:
log.error(f" {nid} FAILED: {e}")
# 加 initial 集合 (launch 2014-09 -> 2014-09-18 id=2998, 无初始样本 xlsx, 跳过)
# 加 current (akshare 现快照)
try:
import akshare as ak
log.info("拉 akshare 中证1000 当前快照...")
df = ak.index_stock_cons_csindex(symbol="000852")
for _, row in df.iterrows():
code = str(row.iloc[0]).zfill(6) if str(row.iloc[0]).isdigit() else str(row.iloc[0])
if not code.isdigit() or len(code) != 6:
continue
records_1000.append({
"updateDate": "current", "index_code": "000852",
"code": code, "code_name": str(row.iloc[1]) if df.shape[1] > 1 else "",
"adjust_type": "current", "notice_id": 0, "source": "akshare.index_stock_cons_csindex",
})
log.info(f" akshare current: {sum(1 for r in records_1000 if r['adjust_type']=='current')} stocks")
except Exception as e:
log.warning(f"akshare 当前快照拉取失败 (非致命): {e}")
df_1000 = pd.DataFrame(records_1000, columns=[
"updateDate", "index_code", "code", "code_name", "adjust_type", "notice_id", "source"])
out_1000 = out_dir / "000852_announce_union.parquet"
df_1000.to_parquet(out_1000, index=False)
distinct_1000 = df_1000["code"].nunique()
n_add = (df_1000["adjust_type"] == "add").sum()
n_rem = (df_1000["adjust_type"] == "remove").sum()
n_cur = (df_1000["adjust_type"] == "current").sum()
log.info(f"\nCSI 1000 result: {out_1000}")
log.info(f" total records: {len(df_1000)} (add={n_add}, remove={n_rem}, current={n_cur})")
log.info(f" distinct codes (ever-included): {distinct_1000}")
log.info(f" date range: {df_1000['updateDate'].min()} ~ {df_1000['updateDate'].max()}")
log.info(f" distinct notice_ids: {df_1000['notice_id'].nunique()}")
# ========== CSI 2000 ==========
if args.only in ("2000", "both"):
log.info("\n========== CSI 2000 (932000) ==========")
log.warning("GAP: csindex 公告系统未发布任何 CSI 2000 样本调整公告")
log.warning(" 仅可从 2023-08-10 launch xlsx 获取初始 2000 只样本")
records_2000 = []
# launch xlsx (initial)
try:
detail = fetch_detail(CSI2000_LAUNCH_ID, detail_cache)
if detail.get("data"):
data = detail["data"]
publish_date = (data.get("publishDate") or "2023-08-10").split("T")[0]
urls = extract_file_urls(data)
files = download_notice_files(CSI2000_LAUNCH_ID, publish_date, urls, file_cache)
for f in files:
if f.suffix.lower() != ".xlsx":
continue
wb = openpyxl.load_workbook(f, data_only=True)
for sn in wb.sheetnames:
ws = wb[sn]
rows = list(ws.iter_rows(values_only=True))
if not rows:
continue
header = rows[0]
# 找 code 列 (通常第 1 列 或 命名"证券代码"/"成分券代码")
for row in rows[1:]:
if not row:
continue
code = _norm_code(row[0] if len(row) > 0 else None)
if not code:
continue
name = str(row[1]).strip() if len(row) > 1 and row[1] else ""
records_2000.append({
"updateDate": publish_date, "index_code": "932000",
"code": code, "code_name": name,
"adjust_type": "initial", "notice_id": CSI2000_LAUNCH_ID, "source": f.name,
})
break # 只用第一个 xlsx
except Exception as e:
log.error(f"CSI 2000 launch xlsx ERR: {e}")
# current snapshot via akshare
try:
import akshare as ak
log.info("拉 akshare 中证2000 当前快照...")
df = ak.index_stock_cons_csindex(symbol="932000")
for _, row in df.iterrows():
code = str(row.iloc[0]).zfill(6) if str(row.iloc[0]).isdigit() else str(row.iloc[0])
if not code.isdigit() or len(code) != 6:
continue
records_2000.append({
"updateDate": "current", "index_code": "932000",
"code": code, "code_name": str(row.iloc[1]) if df.shape[1] > 1 else "",
"adjust_type": "current", "notice_id": 0, "source": "akshare.index_stock_cons_csindex",
})
log.info(f" akshare current: {sum(1 for r in records_2000 if r['adjust_type']=='current')} stocks")
except Exception as e:
log.warning(f"akshare CSI 2000 当前快照失败: {e}")
df_2000 = pd.DataFrame(records_2000, columns=[
"updateDate", "index_code", "code", "code_name", "adjust_type", "notice_id", "source"])
out_2000 = out_dir / "932000_announce_union.parquet"
df_2000.to_parquet(out_2000, index=False)
distinct_2000 = df_2000["code"].nunique()
n_init = (df_2000["adjust_type"] == "initial").sum()
n_cur = (df_2000["adjust_type"] == "current").sum()
log.info(f"\nCSI 2000 result: {out_2000}")
log.info(f" total records: {len(df_2000)} (initial={n_init}, current={n_cur})")
log.info(f" distinct codes: {distinct_2000}")
log.info(f" GAP: 无调整公告,初始集 ∪ 当前集 (中间调整无记录)")
# 总结
log.info("\n========== DONE ==========")
log.info(f"OUT_DIR: {out_dir.resolve()}")
for f in sorted(out_dir.glob("*_announce_union.parquet")):
log.info(f" {f.name}: {f.stat().st_size} bytes")
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""probe_constituent.py — 单元3 前置: 探查成份股两源 schema (写 migrate 前必须看清)。
1. bs_index_constituent (DB, baostock 300/500/50 历史): cols/rows/distinct index_code/抽样
2. data/index_const_hist/*.parquet (akshare cni 深证 union): 文件列表 + 每个 shape/cols/抽样
"""
import glob
import os
import sqlite3
import pandas as pd
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
HIST = r"C:\sanguo_vnpy_v2\data\index_const_hist"
c = sqlite3.connect(DB, timeout=60)
c.execute("PRAGMA busy_timeout = 60000")
cols = [r[1] for r in c.execute("PRAGMA table_info(bs_index_constituent)")]
print("[bs_index_constituent] cols:", cols)
print(" rows:", c.execute("SELECT COUNT(*) FROM bs_index_constituent").fetchone()[0])
idx = [r[0] for r in c.execute(
"SELECT DISTINCT index_code FROM bs_index_constituent ORDER BY index_code")]
print(" distinct index_code:", idx)
print(" distinct updateDate count:", c.execute(
"SELECT COUNT(DISTINCT updateDate) FROM bs_index_constituent").fetchone()[0])
print(" sample rows:", c.execute(
"SELECT * FROM bs_index_constituent LIMIT 3").fetchall())
c.close()
print("\n[index_const_hist parquets]")
files = sorted(glob.glob(os.path.join(HIST, "*.parquet")))
print("files:", [os.path.basename(f) for f in files])
for f in files:
df = pd.read_parquet(f)
print(f" {os.path.basename(f)}: shape={df.shape} cols={list(df.columns)}")
print(f" head:\n{df.head(2).to_string()}")
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""probe_dbbardata_unique.py — 单元4 merge 前置: 查 dbbardata UNIQUE 约束 + verify staging。
INSERT OR REPLACE 依赖 dbbardata 有 UNIQUE(symbol,exchange,datetime,interval) 才能去重,
否则插重复。vnpy DbBarData 通常有 UniqueConstraint, 此处确认。
"""
import sqlite3
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
c = sqlite3.connect(DB, timeout=120)
c.execute("PRAGMA busy_timeout = 120000")
print("dbbardata sql:", c.execute(
"SELECT sql FROM sqlite_master WHERE name='dbbardata'").fetchone())
print("\ndbbardata indexes:")
for idx in c.execute("PRAGMA index_list('dbbardata')").fetchall():
cols = c.execute(f"PRAGMA index_info('{idx[1]}')").fetchall()
print(f" {idx} cols={cols}")
print("\n--- staging verify ---")
print("staging rows:", c.execute(
"SELECT COUNT(*) FROM dbbardata_staging_daily").fetchone()[0])
print("staging distinct symbol:", c.execute(
"SELECT COUNT(DISTINCT symbol) FROM dbbardata_staging_daily").fetchone()[0])
for sym, label in [("000005", "退市"), ("000023", "退市"),
("600519", "在市"), ("510300", "ETF")]:
r = c.execute(
"SELECT COUNT(*), MIN(datetime), MAX(datetime) "
"FROM dbbardata_staging_daily WHERE symbol=?", (sym,)).fetchone()
print(f" {sym}({label}): {r}")
c.close()
@@ -0,0 +1,10 @@
# register_schtasks.ps1 — 注册方案A 核心 2 个日增量 schtask (bs-eod 18:05, xt-eod 18:40)
# akshare 三表(季度低频) / 成份股(月度) 留 disabled, 后续单独处理
schtasks /create /tn sanguo-bs-eod /tr "powershell -ExecutionPolicy Bypass -File C:\sanguo_vnpy_v2\scripts\data_platform\bs_eod_wrapper.ps1" /sc daily /st 18:05 /ru SYSTEM /rl HIGHEST /f
schtasks /create /tn sanguo-xt-eod /tr "powershell -ExecutionPolicy Bypass -File C:\sanguo_vnpy_v2\scripts\data_platform\xt_eod_wrapper.ps1" /sc daily /st 18:40 /ru SYSTEM /rl HIGHEST /f
Write-Output "=== VERIFY ==="
foreach ($t in @('sanguo-bs-eod', 'sanguo-xt-eod', 'sanguo-daily-update', 'sanguo-bs-akshare', 'sanguo-bs-daily-increment', 'sanguo-index-hist')) {
$q = schtasks /query /tn $t /fo list 2>&1 | Out-String
$status = ($q -split "`n" | Where-Object { $_ -match 'Status' } | Select-Object -First 1).Trim()
Write-Output (" {0,-28} {1}" -f $t, $status)
}
@@ -0,0 +1,29 @@
# stop_all_data_schtasks.ps1 — schtasks.exe 版(可靠):停+禁用+杀残留数据脚本
$tasks = @('sanguo-daily-update', 'sanguo-bs-akshare', 'sanguo-bs-daily-increment', 'sanguo-index-hist')
Write-Output "=== END (停正在跑) + DISABLE ==="
foreach ($t in $tasks) {
$e = (schtasks /end /tn $t 2>&1) -join ' '
$d = (schtasks /change /tn $t /disable 2>&1) -join ' '
Write-Output (" {0,-28} end+disable done" -f $t)
}
Start-Sleep 3
Write-Output ""
Write-Output "=== 杀残留数据脚本 python (精确匹配命令行, 不碰常驻/回测) ==="
$dataProcs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -match 'akshare_static|validate_import|daily_update_xtdata|audit_data_layout|import_vnpy|minute_incr|_run_daily|diag_daily' }
if ($dataProcs) {
foreach ($p in $dataProcs) {
$cmd = if ($p.CommandLine) { $p.CommandLine.Substring(0, [Math]::Min(60, $p.CommandLine.Length)) } else { '<null>' }
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
Write-Output (" killed pid={0} {1}" -f $p.ProcessId, $cmd)
}
} else { Write-Output " 无残留数据脚本 python" }
Write-Output ""
Write-Output "=== VERIFY schtask 状态 (应=Disabled) ==="
foreach ($t in $tasks) {
$q = schtasks /query /tn $t /fo list 2>&1 | Out-String
$status = ($q -split "`n" | Where-Object { $_ -match 'Status' } | Select-Object -First 1).Trim()
Write-Output (" {0,-28} {1}" -f $t, $status)
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""verify_backup.py — 快速验证 .bak 可读(连库读表清单+小表 COUNT, 非全表 integrity)。
sqlite3 .backup 是原子一致机制, .bak 28.4GB 完整即一致。本脚本确认 .bak 能连+表在=回滚可用。
"""
import os
import sqlite3
BAK = r"C:\sanguo_vnpy_v2\data\quant_trading.db.bak_20260722"
print("bak size=%.2f GB" % (os.path.getsize(BAK) / 1024 ** 3))
c = sqlite3.connect(BAK, timeout=60)
c.execute("PRAGMA busy_timeout = 60000")
tabs = [r[0] for r in c.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
print("tables:", tabs)
print("backtest_stats:", c.execute("SELECT COUNT(*) FROM backtest_stats").fetchone()[0])
print("bs_adjust_factor:", c.execute("SELECT COUNT(*) FROM bs_adjust_factor").fetchone()[0])
print("bs_index_constituent:", c.execute("SELECT COUNT(*) FROM bs_index_constituent").fetchone()[0])
c.close()
print("BAK READABLE OK")
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""verify_config.py — 单元2 验证: VPS datareader 能否读到本地全量日线。
打印 SANGUO_DATA_ROOT env + load_config 结果 + read_parquet_daily 600519。
据此判断: env 覆盖 vs yaml 直改 哪个生效, daily_dir 是否指 qfq(全量)。
"""
import os
import sys
sys.path.insert(0, r"C:\sanguo_vnpy_v2")
print("SANGUO_DATA_ROOT:", repr(os.environ.get("SANGUO_DATA_ROOT")))
from sanguo_data.config import load_config
from sanguo_data.datareader import read_parquet_daily
cfg = load_config(r"C:\sanguo_vnpy_v2\config\data_platform.yaml")
print("daily_dir:", cfg.data_paths.get("daily_dir"))
print("vnpy_db:", cfg.data_paths.get("vnpy_db"))
bars = read_parquet_daily("600519", "2026-07-01", "2026-07-22", cfg)
print("600519 bars:", len(bars),
"last:", bars[-1].datetime.date() if bars else "NONE")
print("VERIFY OK" if bars else "VERIFY WARN: 0 bars")
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""verify_e2e.py — 收尾 E2E: 回测入口 read_db_daily 读 dbbardata 验证。
方案A 数据层落地后, 确认回测能读到:
- 在市股(600519) 日线到 2026-07-22
- 退市股(000005) 日线(治幸存者偏差, 迁移前 dbbardata 没有)
- ETF(510300) 日线(xtata 源保留)
"""
import sys
sys.path.insert(0, r"C:\sanguo_vnpy_v2")
from sanguo_data.config import load_config
from sanguo_data.datareader import read_db_daily
cfg = load_config(r"C:\sanguo_vnpy_v2\config\data_platform.yaml")
print("vnpy_db:", cfg.data_paths["vnpy_db"])
for sym, label, start, end in [
("600519", "在市", "2026-07-01", "2026-07-22"),
("000005", "退市(治偏差)", "2024-01-01", "2024-04-30"),
("510300", "ETF", "2026-07-01", "2026-07-22"),
]:
bars = read_db_daily(sym, start, end, cfg)
last = bars[-1].datetime.date() if bars else "NONE"
print(f" {sym}({label}): bars={len(bars)} last={last}")
print("E2E DBBARDATA OK")
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""xt_eod.py — sanguo-xt-eod (方案A schtask 18:40): xtata ETF/基金 EOD 增量 -> dbbardata('d')。
baostock 只取 type=1 股票, 不覆盖 ETF/基金 -> xtata 独占 (spec §14)。
个股日线由 bs_eod (baostock) 灌, 此处不碰个股, 避免 dbbardata 两源冲突。
- universe = 沪深ETF 沪深基金
- download_history_data2 批量 paced -> 本地缓存
- get_market_data_ex raw(dividend_type=none) -> dbbardata('d') INSERT OR REPLACE
- volume 手->股 (×100, 与 daily_update_xtdata 同口径)
- 无限流, 单进程 download 不并发
用法: python xt_eod.py [--limit N] [--dry-run]
"""
import argparse
import datetime as dt
import sqlite3
import time
from xtquant import xtdata as xd
import pandas as pd
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
LOOKBACK = int(__import__("os").environ.get("LOOKBACK_DAYS", "30"))
T0 = time.time()
def log(m):
print(f"[XT-EOD {time.time()-T0:.0f}s] {m}", flush=True)
def prefix_of(sym):
return "sh" if sym[:2] in ("51", "56", "58", "50") else ("sh" if sym[:2] == "60" else "sz")
def exc_of(sym):
return "SSE" if sym[:2] in ("51", "56", "58", "50", "60", "68") else "SZSE"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
end = dt.datetime.now().strftime("%Y%m%d")
start = (dt.datetime.now() - dt.timedelta(days=LOOKBACK)).strftime("%Y%m%d")
log(f"start window={start}~{end}")
u = list(set(
(xd.get_stock_list_in_sector("沪深ETF") or []) +
(xd.get_stock_list_in_sector("沪深基金") or [])
))
if not u:
log("FATAL empty universe (miniQMT 未连?)")
return 2
if args.limit:
u = u[:args.limit]
log(f"universe ETF+基金: {len(u)}")
# download paced
BATCH = 200
for i in range(0, len(u), BATCH):
try:
xd.download_history_data2(u[i:i+BATCH], "1d", start, end, lambda d, p: None)
except Exception as e:
log(f"dl batch@{i} err: {e}")
time.sleep(1.0)
log("download done")
conn = sqlite3.connect(DB, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
ok = fail = empty = rows = 0
conn.execute("BEGIN")
try:
for i, code in enumerate(u):
sym = code.split(".")[0]
try:
r = xd.get_market_data_ex([], [code], period="1d", start_time=start,
end_time=end, dividend_type="none")
df = r.get(code) if r else None
if df is None or not len(df):
empty += 1
continue
db = pd.DataFrame({
"symbol": sym,
"exchange": exc_of(sym),
"datetime": [str(idx)[:4]+"-"+str(idx)[4:6]+"-"+str(idx)[6:8]+" 00:00:00"
for idx in df.index],
"interval": "d",
"volume": (df["volume"].astype(float).values * 100),
"turnover": df["amount"].astype(float).values,
"open_interest": 0.0,
"open_price": df["open"].astype(float).values,
"high_price": df["high"].astype(float).values,
"low_price": df["low"].astype(float).values,
"close_price": df["close"].astype(float).values,
})
if not args.dry_run:
conn.executemany(
"INSERT OR REPLACE INTO dbbardata "
"(symbol,exchange,datetime,interval,volume,turnover,open_interest,"
"open_price,high_price,low_price,close_price) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
db.itertuples(index=False, name=None))
rows += len(db)
ok += 1
except Exception as e:
fail += 1
if fail <= 5:
log(f"{code} err: {e}")
if (i+1) % 200 == 0:
log(f"进度 {i+1}/{len(u)} ok={ok} empty={empty} fail={fail} rows={rows}")
conn.execute("COMMIT")
except Exception as e:
conn.execute("ROLLBACK")
log(f"FATAL rollback: {e}")
conn.close()
return 1
conn.close()
log(f"DONE ok={ok} empty={empty} fail={fail} rows={rows}"
f"{' [DRY-RUN]' if args.dry_run else ''}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -0,0 +1,9 @@
# xt_eod_wrapper.ps1 — sanguo-xt-eod schtask wrapper (18:40 xtata ETF/基金 EOD 增量)
# 方案A: ETF/基金(baostock 盲区) -> dbbardata('d')
Set-Location C:\sanguo_vnpy_v2
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
$logDir = 'C:\sanguo_vnpy_v2\data\migration_logs'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$log = Join-Path $logDir "xt_eod_$ts.txt"
C:\Python310\python.exe -X utf8 C:\sanguo_vnpy_v2\scripts\data_platform\xt_eod.py *>> $log
exit $LASTEXITCODE