245 lines
9.6 KiB
Python
245 lines
9.6 KiB
Python
"""成份股 merge / migrate pipeline 可重跑(idempotent)回归测试。
|
|
|
|
背景: 方案A 落地后 VPS 上的状态是
|
|
- ``constituent_unified`` 已存在(正式表)
|
|
- ``bs_index_constituent_old`` 已存在(baostock 300/500/50 全部历史时点, 权威历史源)
|
|
- ``bs_index_constituent`` 不存在(被 rename 走了)
|
|
- ``constituent_unified_staging`` 仅在 migrate 跑完后存在
|
|
|
|
旧 ``merge_constituent.py`` 用 RENAME + 旧 ``migrate_constituent.py`` 直接
|
|
``FROM bs_index_constituent`` → 月度 schtask 再跑会崩。这组测试断言:
|
|
|
|
1. ``merge`` 跑两次都不崩, 行数稳定(DROP+CREATE from staging 幂等, 不用 RENAME),
|
|
且不碰 ``bs_index_constituent_old``。
|
|
2. ``migrate`` 跑两次都不崩, 从 ``bs_index_constituent_old`` 读, staging 行数稳定。
|
|
|
|
全部在 Mac 本地用 tmp sqlite + 空 HIST 目录跑, 不依赖 VPS / baostock / akshare。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
# 让测试能 import scripts/data_platform/ 下的模块
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_SCRIPT_DIR = os.path.abspath(os.path.join(_HERE, "..", "..", "scripts", "data_platform"))
|
|
if _SCRIPT_DIR not in sys.path:
|
|
sys.path.insert(0, _SCRIPT_DIR)
|
|
|
|
import merge_constituent as mc # noqa: E402
|
|
import migrate_constituent as mig # noqa: E402
|
|
|
|
|
|
# ======================== helpers ========================
|
|
def _create_staging(db_path: str, rows: list[tuple[str, str, str, str, int, int]]) -> None:
|
|
"""在 tmp db 里造一张 constituent_unified_staging。
|
|
|
|
rows: (index_code, code, code_name, source, in_current, was_removed)
|
|
"""
|
|
with sqlite3.connect(db_path) as c:
|
|
c.execute("PRAGMA busy_timeout = 60000")
|
|
c.execute(
|
|
"CREATE TABLE constituent_unified_staging ("
|
|
"index_code TEXT, code TEXT, code_name TEXT, source TEXT, "
|
|
"in_current INTEGER, was_removed INTEGER)"
|
|
)
|
|
c.executemany(
|
|
"INSERT INTO constituent_unified_staging VALUES (?,?,?,?,?,?)", rows
|
|
)
|
|
c.commit()
|
|
|
|
|
|
def _create_bs_old(db_path: str, rows: list[tuple[str, str, str, str]]) -> None:
|
|
"""在 tmp db 里造一张 bs_index_constituent_old(updateDate, index_code, code, code_name)。
|
|
|
|
默认 index_code 用 baostock 原始命名(hs300/zz500/sz50), migrate 内有 BS_MAP 映射。
|
|
"""
|
|
with sqlite3.connect(db_path) as c:
|
|
c.execute("PRAGMA busy_timeout = 60000")
|
|
c.execute(
|
|
"CREATE TABLE bs_index_constituent_old ("
|
|
"updateDate TEXT, index_code TEXT, code TEXT, code_name TEXT)"
|
|
)
|
|
c.executemany(
|
|
"INSERT INTO bs_index_constituent_old VALUES (?,?,?,?)", rows
|
|
)
|
|
c.commit()
|
|
|
|
|
|
def _table_exists(db_path: str, table: str) -> bool:
|
|
with sqlite3.connect(db_path) as c:
|
|
row = c.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def _count_rows(db_path: str, table: str) -> int:
|
|
with sqlite3.connect(db_path) as c:
|
|
return c.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
|
|
|
|
# ======================== test 1: merge 幂等 ========================
|
|
def test_merge_idempotent_rerun(tmp_path):
|
|
"""merge 跑两次不崩, constituent_unified 行数 == staging 行数; _old 不被碰。
|
|
|
|
断言的是 DROP+CREATE from staging 语义(幂等), 而不是 RENAME(只能跑一次)。
|
|
"""
|
|
db = tmp_path / "test.db"
|
|
db_path = str(db)
|
|
|
|
# staging 5 行 (300 + 500 各几只)
|
|
staging_rows = [
|
|
("000300", "600519", "贵州茅台", "baostock", 1, 0),
|
|
("000300", "601318", "中国平安", "baostock", 1, 0),
|
|
("000300", "000001", "平安银行", "baostock", 0, 1),
|
|
("000905", "002415", "海康威视", "baostock", 1, 0),
|
|
("000905", "300750", "宁德时代", "baostock", 1, 0),
|
|
]
|
|
_create_staging(db_path, staging_rows)
|
|
|
|
# 模拟方案A后状态: bs_index_constituent_old 已存在(不该被 merge 碰)
|
|
_create_bs_old(db_path, [
|
|
("2024-06-17", "hs300", "sh.600519", "贵州茅台"),
|
|
("2024-06-17", "hs300", "sh.601318", "中国平安"),
|
|
])
|
|
bs_old_rows_before = _count_rows(db_path, "bs_index_constituent_old")
|
|
|
|
# Act 1: 第一次 merge
|
|
mc.merge(db_path)
|
|
|
|
# Assert 1: constituent_unified 存在, 行数 == staging 行数
|
|
assert _table_exists(db_path, "constituent_unified") is True
|
|
assert _count_rows(db_path, "constituent_unified") == len(staging_rows)
|
|
# staging 仍可读(没被 rename 走)
|
|
assert _count_rows(db_path, "constituent_unified_staging") == len(staging_rows)
|
|
# bs_old 没被碰
|
|
assert _count_rows(db_path, "bs_index_constituent_old") == bs_old_rows_before
|
|
|
|
# Act 2: 第二次 merge —— 关键回归(RENAME 方案这里会崩)
|
|
mc.merge(db_path)
|
|
|
|
# Assert 2: 行数稳定, 没异常
|
|
assert _count_rows(db_path, "constituent_unified") == len(staging_rows)
|
|
assert _count_rows(db_path, "constituent_unified_staging") == len(staging_rows)
|
|
assert _count_rows(db_path, "bs_index_constituent_old") == bs_old_rows_before
|
|
|
|
|
|
# ======================== test 2: migrate 幂等 ========================
|
|
def test_migrate_idempotent_rerun_from_old(tmp_path, monkeypatch):
|
|
"""migrate 跑两次不崩, 从 bs_index_constituent_old 读, staging 行数稳定。
|
|
|
|
构造 baostock 原始 index_code (hs300/zz500/sz50), migrate 内 BS_MAP 映射到
|
|
000300/000905/000016。HIST 目录用空目录(深证/中证 parquet 缺失, migrate
|
|
应优雅产出 0 行不崩)。
|
|
"""
|
|
db = tmp_path / "test.db"
|
|
db_path = str(db)
|
|
|
|
# baostock 历史时点(2 个日期, 模拟一只股被踢出)
|
|
bs_rows = [
|
|
# 2024-06-17 时点: hs300 含 600519 / 601318
|
|
("2024-06-17", "hs300", "sh.600519", "贵州茅台"),
|
|
("2024-06-17", "hs300", "sh.601318", "中国平安"),
|
|
# 2024-12-16 时点: hs300 600519 留下, 601318 被踢, 新进 000001
|
|
("2024-12-16", "hs300", "sh.600519", "贵州茅台"),
|
|
("2024-12-16", "hs300", "sz.000001", "平安银行"),
|
|
# zz500 一只
|
|
("2024-06-17", "zz500", "sh.600036", "招商银行"),
|
|
# sz50 一只
|
|
("2024-06-17", "sz50", "sh.600000", "浦发银行"),
|
|
]
|
|
_create_bs_old(db_path, bs_rows)
|
|
|
|
# HIST 指向空目录(深证 union / 中证 snapshot 都缺失, migrate 应优雅跳过)
|
|
empty_hist = tmp_path / "index_const_hist"
|
|
empty_hist.mkdir()
|
|
monkeypatch.setattr(mig, "HIST", str(empty_hist))
|
|
|
|
# Act 1
|
|
mig.migrate(db_path)
|
|
|
|
# Assert 1: staging 建好, 行数 == 去重后的 _old 行数
|
|
assert _table_exists(db_path, "constituent_unified_staging") is True
|
|
# 去重后 6 个唯一 (index_code, code) 对:
|
|
# hs300: 600519 / 601318 / 000001 (3)
|
|
# zz500: 600036 (1)
|
|
# sz50: 600000 (1)
|
|
# 注意 600519 在两个时点出现, groupby first() 去重为 1 行 → 共 5 行
|
|
expected_rows = 5
|
|
assert _count_rows(db_path, "constituent_unified_staging") == expected_rows
|
|
|
|
# 内容抽查: hs300 映射对了, was_removed 标记对了
|
|
with sqlite3.connect(db_path) as c:
|
|
# hs300 应有 3 行
|
|
n_hs300 = c.execute(
|
|
"SELECT COUNT(*) FROM constituent_unified_staging WHERE index_code='000300'"
|
|
).fetchone()[0]
|
|
assert n_hs300 == 3
|
|
# 601318 在最后时点已不在 → was_removed=1
|
|
row_601318 = c.execute(
|
|
"SELECT in_current, was_removed FROM constituent_unified_staging "
|
|
"WHERE index_code='000300' AND code='601318'"
|
|
).fetchone()
|
|
assert row_601318 == (0, 1)
|
|
# 600519 仍在 → in_current=1
|
|
row_600519 = c.execute(
|
|
"SELECT in_current, was_removed FROM constituent_unified_staging "
|
|
"WHERE index_code='000300' AND code='600519'"
|
|
).fetchone()
|
|
assert row_600519 == (1, 0)
|
|
|
|
# Act 2: 第二次 migrate —— 关键回归
|
|
mig.migrate(db_path)
|
|
|
|
# Assert 2: staging 行数稳定(DROP+rebuild 幂等)
|
|
assert _count_rows(db_path, "constituent_unified_staging") == expected_rows
|
|
# _old 没被碰
|
|
assert _count_rows(db_path, "bs_index_constituent_old") == len(bs_rows)
|
|
|
|
|
|
def test_migrate_reads_bs_old_not_live_when_both_exist(tmp_path, monkeypatch):
|
|
"""migrate 兼容性: _old 和 live 表都在时, 两边 UNION 不丢数据(robustness)。
|
|
|
|
方案A 后正常只有 _old; 但若将来有人重建了 live bs_index_constituent,
|
|
migrate 应把两者都读(UNION ALL 去重), 兼容两种状态。
|
|
"""
|
|
db = tmp_path / "test.db"
|
|
db_path = str(db)
|
|
|
|
# _old 里有 hs300 600519
|
|
_create_bs_old(db_path, [
|
|
("2024-06-17", "hs300", "sh.600519", "贵州茅台"),
|
|
])
|
|
# live 表里补一只新的(模拟未来重建 live 后采到新时点)
|
|
with sqlite3.connect(db_path) as c:
|
|
c.execute(
|
|
"CREATE TABLE bs_index_constituent ("
|
|
"updateDate TEXT, index_code TEXT, code TEXT, code_name TEXT)"
|
|
)
|
|
c.execute(
|
|
"INSERT INTO bs_index_constituent VALUES (?,?,?,?)",
|
|
("2025-06-16", "hs300", "sh.688981", "中芯国际"),
|
|
)
|
|
c.commit()
|
|
|
|
empty_hist = tmp_path / "index_const_hist"
|
|
empty_hist.mkdir()
|
|
monkeypatch.setattr(mig, "HIST", str(empty_hist))
|
|
|
|
# Act
|
|
mig.migrate(db_path)
|
|
|
|
# Assert: staging 应同时含 _old 的 600519 + live 的 688981 (UNION 后去重 2 行)
|
|
with sqlite3.connect(db_path) as c:
|
|
codes = sorted(
|
|
r[0] for r in c.execute(
|
|
"SELECT code FROM constituent_unified_staging "
|
|
"WHERE index_code='000300'"
|
|
).fetchall()
|
|
)
|
|
assert codes == ["600519", "688981"]
|