Files
sanguo_vnpy_v2/scripts/data_platform/migrate_constituent.py
T
claude_dev c2a89d01a4 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 + 实时拼接
2026-07-23 07:20:34 +08:00

123 lines
5.2 KiB
Python

#!/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()