326 lines
16 KiB
Python
326 lines
16 KiB
Python
"""日频行业观点快照的离线单测(不连库)。
|
||
|
||
覆盖:从研判结论视图正常取数并归一(只要产业研判与环节评析两类、自我校验三值、校验问题条数、
|
||
指纹与生成日期);视图读失败返回空列表不抛错;同一计划日重跑覆盖当日行不追加重复行;材料指纹
|
||
未变时陈旧天数累加、采信倾向的上一版值带出来;材料指纹变化时算作一次迁移且陈旧天数归零;
|
||
第一次见到的主题不判迁移;版本史里摘迁移的入口函数。
|
||
|
||
取数、读上一版、落库三处都接受注入的函数,落库这一路用一张内存里的假表接住真实的 SQL
|
||
(DELETE 当日行 + 批插),所以幂等测的是真语句不是桩。
|
||
|
||
开发机没有 pandas 与数据库驱动时,只给缺席的模块装最小桩(与 test_market_context.py 同一约定:
|
||
仅在模块缺席时装桩,不覆盖真实模块)。
|
||
|
||
跑法:python3 test_judgement_snapshot.py 或 pytest test_judgement_snapshot.py
|
||
"""
|
||
import datetime as dt
|
||
import sys
|
||
import types
|
||
|
||
_STUBS = ("pandas", "psycopg", "pymysql", "dotenv")
|
||
for _n in _STUBS:
|
||
if _n not in sys.modules:
|
||
try:
|
||
__import__(_n)
|
||
except ImportError:
|
||
_m = types.ModuleType(_n)
|
||
if _n == "pandas": # db.py 的函数签名在定义时引用这两个名字
|
||
_m.DataFrame = type("DataFrame", (), {})
|
||
_m.Series = type("Series", (), {})
|
||
sys.modules[_n] = _m
|
||
|
||
import config # noqa: E402
|
||
import judgement # noqa: E402
|
||
import sources # noqa: E402
|
||
|
||
|
||
def t(name, cond):
|
||
assert cond, name
|
||
print(" ok", name)
|
||
|
||
|
||
DAY = "2026-09-03"
|
||
TABLE = "t_akg_judgement_snapshot_test"
|
||
|
||
|
||
# ---------------------------------------------------------------- 假数据
|
||
def _view_rows(fp_a="fp-a-1", leaning_a="偏多"):
|
||
"""研判结论视图的两行产业研判、一行环节评析,外加两行该被挡掉的(个股评析、簇键为空)。"""
|
||
return [
|
||
{"scope": "industry", "subject_name": "液冷散热", "segment_name": None,
|
||
"cluster_key": "Industry::液冷散热", "leaning": leaning_a, "n_bull": 4, "n_bear": 2,
|
||
"n_flags": 1, "verified": True, "verify_problems": [], "n_materials": 37,
|
||
"model": "m1", "rounds": 3, "review_date": dt.date(2026, 8, 19),
|
||
"reviewed_at": dt.datetime(2026, 8, 19, 10, 30, 5), "input_version": fp_a,
|
||
"review_id": "rv1"},
|
||
{"scope": "industry", "subject_name": "固态电池", "segment_name": None,
|
||
"cluster_key": "Industry::固态电池", "leaning": "证据不足", "n_bull": "1", "n_bear": 0,
|
||
"n_flags": 0, "verified": "false", "verify_problems": '["缺少产能口径", "价格来源不一致"]',
|
||
"n_materials": 12, "model": "m1", "rounds": 3, "review_date": "2026-08-11",
|
||
"reviewed_at": "2026-08-11T09:00:00", "input_version": "fp-b-1", "review_id": "rv2"},
|
||
{"scope": "segment", "subject_name": "正极材料", "segment_name": "正极材料",
|
||
"cluster_key": "Segment:正极材料", "leaning": "中性", "n_bull": 2, "n_bear": 2,
|
||
"n_flags": None, "verified": None, "verify_problems": None, "n_materials": 8,
|
||
"model": "m1", "rounds": 2, "review_date": "2026-08-20", "reviewed_at": None,
|
||
"input_version": None, "review_id": "rv3"},
|
||
{"scope": "industry", "subject_name": None, "segment_name": None,
|
||
"cluster_key": "Industry::", "leaning": "偏多", "n_bull": 1, "n_bear": 0,
|
||
"n_flags": 0, "verified": True, "verify_problems": None, "n_materials": 3,
|
||
"model": "m1", "rounds": 3, "review_date": "2026-08-01", "reviewed_at": None,
|
||
"input_version": "fp-x", "review_id": "rv4"},
|
||
]
|
||
|
||
|
||
def _pg_ok(sql, params=None):
|
||
s = " ".join(sql.split())
|
||
assert "v_factor_judgement" in s, s
|
||
assert set(params) == {"industry", "segment"}, params # 只要这两类
|
||
return _view_rows()
|
||
|
||
|
||
def _boom(*a, **k):
|
||
raise OSError("connection refused")
|
||
|
||
|
||
class _FakeCursor:
|
||
"""只认本模块会发的三种语句的假游标:建表、按计划日删、批插。"""
|
||
|
||
def __init__(self, store):
|
||
self.store = store
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *a):
|
||
return False
|
||
|
||
def execute(self, sql, params=None):
|
||
s = " ".join(sql.split())
|
||
if s.upper().startswith("CREATE TABLE"):
|
||
self.store["created"] += 1
|
||
assert TABLE in s, s
|
||
return
|
||
if s.upper().startswith("DELETE"):
|
||
assert "WHERE plan_date = %s" in s, s
|
||
day = params[0]
|
||
self.store["deleted"].append(day)
|
||
self.store["rows"] = [r for r in self.store["rows"] if r[0] != day]
|
||
return
|
||
raise AssertionError(f"意外的语句: {s}")
|
||
|
||
def executemany(self, sql, rows):
|
||
s = " ".join(sql.split())
|
||
assert s.upper().startswith("INSERT INTO") and TABLE in s, s
|
||
assert s.count("%s") == len(judgement.COLUMNS), s
|
||
for r in rows:
|
||
assert len(r) == len(judgement.COLUMNS)
|
||
self.store["rows"].extend(list(rows))
|
||
|
||
|
||
class _FakeConn:
|
||
def __init__(self, store):
|
||
self.store = store
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *a):
|
||
return False
|
||
|
||
def cursor(self):
|
||
return _FakeCursor(self.store)
|
||
|
||
def commit(self):
|
||
self.store["commits"] += 1
|
||
|
||
|
||
def _fake_store():
|
||
return {"rows": [], "deleted": [], "created": 0, "commits": 0}
|
||
|
||
|
||
def _as_dicts(store):
|
||
return [dict(zip(judgement.COLUMNS, r)) for r in store["rows"]]
|
||
|
||
|
||
# ---------------------------------------------------------------- 用例
|
||
def test_fetch():
|
||
config.JUDGEMENT_SCOPES = {"industry", "segment"}
|
||
rows = sources.judgement_rows(read_pg=_pg_ok)
|
||
t("只出簇键与主题名齐全的行(空主题那行被挡掉)", len(rows) == 3)
|
||
a = next(r for r in rows if r["cluster_key"] == "Industry::液冷散热")
|
||
t("产业研判字段齐:采信倾向、多空条数、材料指纹、生成日期",
|
||
a["scope"] == "industry" and a["subject_name"] == "液冷散热" and a["leaning"] == "偏多"
|
||
and a["n_bull"] == 4 and a["n_bear"] == 2 and a["input_version"] == "fp-a-1"
|
||
and a["review_date"] == "2026-08-19" and a["reviewed_at"] == "2026-08-19 10:30:05")
|
||
t("自我校验为真、校验问题零条", a["verified"] is True and a["n_verify_problems"] == 0)
|
||
b = next(r for r in rows if r["cluster_key"] == "Industry::固态电池")
|
||
t("字符串形态的数字与真假值都归一、JSON 串的校验问题按条数",
|
||
b["n_bull"] == 1 and b["verified"] is False and b["n_verify_problems"] == 2
|
||
and b["reviewed_at"] == "2026-08-11 09:00:00")
|
||
c = next(r for r in rows if r["scope"] == "segment")
|
||
t("环节评析带环节名;空的自我校验保持为空不当成假",
|
||
c["segment_name"] == "正极材料" and c["verified"] is None
|
||
and c["n_verify_problems"] is None and c["input_version"] is None)
|
||
t("scope 传参可覆盖旋钮",
|
||
sources.judgement_rows(scopes=("industry", "segment"), read_pg=_pg_ok) == rows)
|
||
|
||
|
||
def test_fetch_failure():
|
||
t("视图读失败返回空列表不抛错", sources.judgement_rows(read_pg=_boom) == [])
|
||
config.JUDGEMENT_SCOPES = set()
|
||
t("一类都不抄时不查库", sources.judgement_rows(read_pg=_boom) == [])
|
||
config.JUDGEMENT_SCOPES = {"industry", "segment"}
|
||
|
||
|
||
def test_prev_read_failure():
|
||
config.JUDGEMENT_SNAPSHOT_TABLE = TABLE
|
||
t("表还不存在时上一版为空字典、不抛错", judgement.load_previous(DAY, read_mysql=_boom) == {})
|
||
t("计划日不合法时也只是没有上一版",
|
||
judgement.load_previous("不是日期", read_mysql=_boom) == {})
|
||
|
||
|
||
def test_same_day_idempotent():
|
||
config.JUDGEMENT_SNAPSHOT_TABLE = TABLE
|
||
store = _fake_store()
|
||
|
||
def _write(day, rows):
|
||
judgement.save(day, rows, conn_factory=lambda: _FakeConn(store))
|
||
|
||
def _fetch():
|
||
return sources.judgement_rows(read_pg=_pg_ok)
|
||
|
||
r1 = judgement.snapshot(DAY, fetch=_fetch, load_prev=lambda d: {}, write=_write)
|
||
t("首日写三行、每个主题一行", r1["rows"] == 3 and len(store["rows"]) == 3)
|
||
t("按 scope 计数:产业研判两个、环节评析一个",
|
||
r1["by_scope"] == {"industry": 2, "segment": 1})
|
||
judgement.snapshot(DAY, fetch=_fetch, load_prev=lambda d: {}, write=_write)
|
||
t("同一计划日重跑仍是三行,不追加重复行", len(store["rows"]) == 3)
|
||
t("重跑先删当日行(删的正是这个计划日)", store["deleted"] == [DAY, DAY])
|
||
keys = [r["cluster_key"] for r in _as_dicts(store)]
|
||
t("三个簇键各一行、无重复", sorted(keys) == sorted(set(keys)) and len(keys) == 3)
|
||
t("每行都带计划日与写入时刻",
|
||
all(r["plan_date"] == DAY and len(r["snapshot_at"] or "") == 19 for r in _as_dicts(store)))
|
||
t("首次见到不判迁移、陈旧天数从零起算",
|
||
all(r["migrated"] is None and r["stale_days"] == 0 for r in _as_dicts(store))
|
||
and r1["first_seen"] == 3 and r1["migrated"] == 0)
|
||
|
||
# 视图这天一行都读不到:当日行照样先删,不留上一次重跑的残行。
|
||
judgement.snapshot(DAY, fetch=lambda: [], load_prev=lambda d: {}, write=_write)
|
||
t("视图无行时当日行被清空,不留残行", store["rows"] == [])
|
||
|
||
|
||
def test_stale_days_accumulate():
|
||
"""指纹未变:陈旧天数按距上一个计划日的自然日数累加,采信倾向的上一版值带出来。"""
|
||
prev = {"Industry::液冷散热": {"plan_date": "2026-09-01", "cluster_key": "Industry::液冷散热",
|
||
"leaning": "偏多", "input_version": "fp-a-1", "stale_days": 5}}
|
||
rows = judgement.build_rows(DAY, sources.judgement_rows(read_pg=_pg_ok), prev, now="x")
|
||
a = next(r for r in rows if r["cluster_key"] == "Industry::液冷散热")
|
||
t("指纹未变:不算迁移", a["migrated"] == 0)
|
||
t("陈旧天数 = 上一版 5 天 + 两个计划日相隔 2 天 = 7", a["stale_days"] == 7)
|
||
t("上一版采信倾向带出来", a["leaning_prev"] == "偏多" and a["leaning"] == "偏多")
|
||
other = next(r for r in rows if r["cluster_key"] == "Industry::固态电池")
|
||
t("上一版里没有的主题按第一次见到", other["migrated"] is None and other["stale_days"] == 0)
|
||
|
||
# 指纹本身缺失(环节评析那行没有指纹):不判迁移,但日子照样变老。
|
||
prev2 = {"Segment:正极材料": {"plan_date": "2026-09-02", "leaning": "中性",
|
||
"input_version": None, "stale_days": 3}}
|
||
rows = judgement.build_rows(DAY, sources.judgement_rows(read_pg=_pg_ok), prev2, now="x")
|
||
seg = next(r for r in rows if r["cluster_key"] == "Segment:正极材料")
|
||
t("指纹缺失:不判迁移,陈旧天数照常累加", seg["migrated"] is None and seg["stale_days"] == 4)
|
||
|
||
# 计划日之间隔了一整个周末也照样按自然日数累加。
|
||
prev3 = {"Industry::液冷散热": {"plan_date": "2026-08-28", "leaning": "偏多",
|
||
"input_version": "fp-a-1", "stale_days": 0}}
|
||
rows = judgement.build_rows("2026-08-31", sources.judgement_rows(read_pg=_pg_ok), prev3, now="x")
|
||
a = next(r for r in rows if r["cluster_key"] == "Industry::液冷散热")
|
||
t("跨周末按自然日数累加 3 天", a["stale_days"] == 3)
|
||
|
||
|
||
def test_migration_on_fingerprint_change():
|
||
"""指纹变化才算一次迁移:陈旧天数归零,上一版采信倾向留在 leaning_prev 里。"""
|
||
prev = {"Industry::液冷散热": {"plan_date": "2026-09-02", "cluster_key": "Industry::液冷散热",
|
||
"leaning": "偏多", "input_version": "fp-a-1", "stale_days": 9}}
|
||
|
||
def _fetch_changed(sql=None, params=None):
|
||
return _view_rows(fp_a="fp-a-2", leaning_a="偏空")
|
||
|
||
rows = judgement.build_rows(DAY, sources.judgement_rows(read_pg=_fetch_changed), prev, now="x")
|
||
a = next(r for r in rows if r["cluster_key"] == "Industry::液冷散热")
|
||
t("指纹变化:算一次迁移", a["migrated"] == 1)
|
||
t("迁移时陈旧天数归零", a["stale_days"] == 0)
|
||
t("采信倾向从偏多变成偏空,两版都在行上",
|
||
a["leaning_prev"] == "偏多" and a["leaning"] == "偏空")
|
||
|
||
# 同一份材料指纹没变、只是采信倾向的文本被重新生成时,不算迁移(口径就是只认指纹)。
|
||
def _fetch_same_fp(sql=None, params=None):
|
||
return _view_rows(fp_a="fp-a-1", leaning_a="偏空")
|
||
|
||
rows = judgement.build_rows(DAY, sources.judgement_rows(read_pg=_fetch_same_fp), prev, now="x")
|
||
a = next(r for r in rows if r["cluster_key"] == "Industry::液冷散热")
|
||
t("指纹没变就不算迁移,哪怕采信倾向的文本变了",
|
||
a["migrated"] == 0 and a["stale_days"] == 10)
|
||
|
||
hist = [{"plan_date": "2026-09-01", "migrated": None, "leaning": "偏多", "leaning_prev": None,
|
||
"input_version": "fp-a-1", "review_date": "2026-08-19"},
|
||
{"plan_date": "2026-09-02", "migrated": 0, "leaning": "偏多", "leaning_prev": "偏多",
|
||
"input_version": "fp-a-1", "review_date": "2026-08-19"},
|
||
{"plan_date": DAY, "migrated": 1, "leaning": "偏空", "leaning_prev": "偏多",
|
||
"input_version": "fp-a-2", "review_date": "2026-09-02"}]
|
||
got = judgement.migrations(hist)
|
||
t("版本史里只摘出被判为迁移的那一天,带方向与出处",
|
||
len(got) == 1 and got[0]["plan_date"] == DAY and got[0]["leaning_from"] == "偏多"
|
||
and got[0]["leaning_to"] == "偏空" and got[0]["review_date"] == "2026-09-02")
|
||
t("空版本史不报错", judgement.migrations([]) == [] and judgement.migrations(None) == [])
|
||
|
||
|
||
def test_recent_rows_and_holiday_guard():
|
||
print("近日行读取与非交易日守卫(审查 2026-09-07 第 9、10 条)")
|
||
rows = [
|
||
{"plan_date": "2026-09-01", "cluster_key": "Segment::甲", "leaning": "偏多", "migrated": 0},
|
||
{"plan_date": "2026-09-02", "cluster_key": "Segment::甲", "leaning": "偏空", "migrated": 1,
|
||
"leaning_prev": "偏多"},
|
||
{"plan_date": "2026-09-03", "cluster_key": "Segment::甲", "leaning": "偏空", "migrated": 0},
|
||
{"plan_date": "2026-09-03", "cluster_key": "Segment::乙", "leaning": "偏多", "migrated": None,
|
||
"n_bull": float("nan")},
|
||
]
|
||
got = judgement.recent_rows("2026-09-04", days=2, read_mysql=lambda src, sql, params: rows)
|
||
t("按簇索引、只留最近 days 行、按日升序",
|
||
[r["plan_date"] for r in got["Segment::甲"]] == ["2026-09-02", "2026-09-03"])
|
||
t("NaN 归一成 None", got["Segment::乙"][0]["n_bull"] is None)
|
||
t("读失败返回空字典不抛错", judgement.recent_rows("2026-09-04", read_mysql=_boom) == {})
|
||
t("日期认不出返回空字典", judgement.recent_rows("不是日期", read_mysql=lambda *a, **k: rows) == {})
|
||
|
||
wrote = []
|
||
r = judgement.snapshot(None, fetch=lambda: [], load_prev=lambda d: {},
|
||
write=lambda d, rs: wrote.append((d, rs)), calendar=["2000-01-03"])
|
||
t("今天不在交易日历里 -> 不落行、返回跳过", r.get("skipped") == "非交易日" and wrote == [])
|
||
import datetime as _dt
|
||
today = _dt.date.today().isoformat()
|
||
r = judgement.snapshot(None, fetch=lambda: [], load_prev=lambda d: {},
|
||
write=lambda d, rs: wrote.append((d, rs)), calendar=["2000-01-03", today])
|
||
t("今天是交易日 -> 照常落行", r.get("rows") == 0 and wrote and wrote[0][0] == today)
|
||
wrote.clear()
|
||
r = judgement.snapshot(None, fetch=lambda: [], load_prev=lambda d: {},
|
||
write=lambda d, rs: wrote.append((d, rs)), calendar=[])
|
||
t("日历读不到 -> 不拦,按今天落行(不让守卫本身成为断产点)", wrote and wrote[0][0] == today)
|
||
wrote.clear()
|
||
r = judgement.snapshot("2026-09-01", fetch=lambda: [], load_prev=lambda d: {},
|
||
write=lambda d, rs: wrote.append((d, rs)), calendar=["2000-01-03"])
|
||
t("显式给了计划日 -> 不查日历,照写", wrote and wrote[0][0] == "2026-09-01")
|
||
|
||
|
||
def main():
|
||
test_fetch()
|
||
test_recent_rows_and_holiday_guard()
|
||
test_fetch_failure()
|
||
test_prev_read_failure()
|
||
test_same_day_idempotent()
|
||
test_stale_days_accumulate()
|
||
test_migration_on_fingerprint_change()
|
||
print("ALL OK — 行业观点快照:取数归一 / 读失败为空 / 同日重跑幂等 / 陈旧天数累加 / "
|
||
"指纹变化算迁移 / 版本史摘迁移 全部通过")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|