tradingSystem/scripts/test_batch8_units.py

765 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
第八批模块单测 (零外部依赖, 不连库不触网)
==========================================
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch8_units.py
覆盖: 上游榜单的版本比对 (app/core/plan_diff.py) —— 名册与指纹、三种比对语义、
进出榜的尾部闸、档位升降与券商覆盖翻转、名次跳变、持仓票视图。
这一批的重点不是"能不能算出差异", 而是**不报假变化**: 榜单是按 top 截断过的, 尾部的进出
是截断噪音而不是上游观点变化。假警报天天响, 提示就等于没有 —— 与 `_capped` 治的是同一个病。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import plan_diff as pd # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# ================================================================ fixture
def _r(code, rank, score, tier="强传导", theme="储能", bucket="main", name=None):
"""名册单行 (plan_diff 内部形态)。"""
return {"c": code, "n": name or code[:6], "r": rank, "s": score,
"t": tier, "h": theme, "b": bucket}
def roster(*rows) -> dict:
return {r["c"]: r for r in rows}
def plan(main=(), observe=(), date="2026-07-30", capped_main=False, capped_obs=False) -> dict:
"""plan_feed.parse_plan() 结果的最小形态。
`returned` / `truncated` 也要带上 —— 快照落库时靠它们记条数与截断标志, 少了的话
"上一版坏没坏"这类判据在单测里恒为假, 用例看着过了实际什么都没验。
"""
def row(x, bucket):
return {"ts_code": x["c"], "name": x["n"], "rank": x["r"], "score": x["s"],
"tier": x["t"], "theme": x["h"], "bucket": bucket}
return {"date": date,
"main": [row(x, "main") for x in main],
"observe": [row(x, "observe") for x in observe],
"returned": {"main": len(main), "observe": len(observe)},
"truncated": {"main": bool(capped_main), "observe": bool(capped_obs)}}
# ================================================================ [A] 名册与指纹
@case("[A1] roster_of 从计划结构取出名册")
def t_a1():
ro = pd.roster_of(plan(main=[_r("600418.SH", 1, 242.2)],
observe=[_r("600877.SH", 1, 101.1, tier=None, bucket="observe")]))
assert set(ro) == {"600418.SH", "600877.SH"}
assert ro["600418.SH"]["b"] == "main" and ro["600877.SH"]["b"] == "observe"
assert ro["600418.SH"]["t"] == "强传导" and ro["600877.SH"]["t"] is None
@case("[A2] 同一只票两档都出现时以主榜为准")
def t_a2():
p = plan(main=[_r("600418.SH", 3, 240.0)],
observe=[_r("600418.SH", 1, 99.0, tier=None, bucket="observe")])
ro = pd.roster_of(p)
assert len(ro) == 1
assert ro["600418.SH"]["b"] == "main", "主榜在前, 观察档那条不该覆盖它"
@case("[A3] 指纹稳定: 同一名册两次算结果一致")
def t_a3():
a = roster(_r("600000.SH", 1, 10.0), _r("600418.SH", 2, 9.0))
b = roster(_r("600418.SH", 2, 9.0), _r("600000.SH", 1, 10.0)) # 插入次序不同
assert pd.digest_of(a) == pd.digest_of(b), "指纹不能依赖 dict 的插入次序"
@case("[A4] 指纹不含 score —— 分数抖动但次序没变, 不算新版本")
def t_a4():
a = roster(_r("600000.SH", 1, 242.2801))
b = roster(_r("600000.SH", 1, 242.2799))
assert pd.digest_of(a) == pd.digest_of(b), (
"/plan 是实时算的, score 末位天天抖; 算进指纹会让快照表白涨而信息量为零")
@case("[A5] 指纹含 rank —— 次序变了就是新版本")
def t_a5():
assert pd.digest_of(roster(_r("600000.SH", 1, 10.0))) != \
pd.digest_of(roster(_r("600000.SH", 2, 10.0)))
@case("[A6] 指纹含 tier / theme / bucket")
def t_a6():
base = roster(_r("600000.SH", 1, 10.0))
assert pd.digest_of(base) != pd.digest_of(roster(_r("600000.SH", 1, 10.0, tier="弱传导")))
assert pd.digest_of(base) != pd.digest_of(roster(_r("600000.SH", 1, 10.0, theme="整机制造")))
assert pd.digest_of(base) != pd.digest_of(roster(_r("600000.SH", 1, 10.0, bucket="observe")))
@case("[A7] 名册 ↔ 落库行 往返不丢字段")
def t_a7():
ro = roster(_r("600418.SH", 7, 233.3, tier="弱传导", theme="整车"))
back = pd.roster_from_rows(pd.roster_rows(ro))
assert back == ro
assert pd.digest_of(back) == pd.digest_of(ro)
@case("[A8] 落库行里的坏数据跳过而不是整份废掉")
def t_a8():
back = pd.roster_from_rows([{"c": "600000.SH", "r": 1}, "坏行", None, {}, {"r": 5}])
assert list(back) == ["600000.SH"]
@case("[A9] 名册行按代码序输出 —— 落库文本稳定")
def t_a9():
rows = pd.roster_rows(roster(_r("600418.SH", 2, 9.0), _r("600000.SH", 1, 10.0)))
assert [r["c"] for r in rows] == ["600000.SH", "600418.SH"]
# ================================================================ [B] 档位序
@case("[B1] 档位强弱: 强传导 > 弱传导 > 无传导")
def t_b1():
assert pd.tier_rank("强传导") > pd.tier_rank("弱传导") > pd.tier_rank("无传导")
@case("[B2] 未知档位返回 None (判不了就不判)")
def t_b2():
assert pd.tier_rank("超强传导") is None and pd.tier_rank(None) is None
@case("[B3] 未知档位不参与升降判定 —— 宁可不报, 不能报反")
def t_b3():
d = pd.diff(roster(_r("600000.SH", 1, 10.0, tier="超强传导")),
roster(_r("600000.SH", 1, 10.0, tier="强传导")),
prev_date="2026-07-29", curr_date="2026-07-30")
assert d["counts"]["tier_up"] == 0 and d["counts"]["tier_down"] == 0
# ================================================================ [C] 首次
@case("[C1] 没有上一版时一条变化都不报")
def t_c1():
d = pd.diff(None, roster(*[_r(f"60000{i}.SH", i, 100 - i) for i in range(1, 9)]),
curr_date="2026-07-30")
assert d["kind"] == pd.KIND_FIRST
assert sum(d["counts"].values()) == 0, "首次落快照若报'新进榜', 会是整整一榜的假变化"
assert d["roster_size"]["curr"] == 8
@case("[C2] 首次的 note 说明从下一次开始才有比对")
def t_c2():
d = pd.diff({}, roster(_r("600000.SH", 1, 10.0)))
assert "首次" in d["note"] and d["prev_broken"] is False
@case("[C3] 上一版读不出来要说成故障, 不能长得跟'首次'一样")
def t_c3():
# 快照的 roster_json 坏了 → 解析成空。若与"首次"同形, 一条真实变化都不报却显示正常。
d = pd.diff({}, roster(_r("600000.SH", 1, 10.0)),
prev_date="2026-07-29", curr_date="2026-07-30", prev_broken=True)
assert d["prev_broken"] is True
assert "读不出来" in d["note"] and "比不了" in d["note"]
assert "首次" not in d["note"]
@case("[C4] 首次也要回显调用方给的参数, 不能写死默认值")
def t_c4():
d = pd.diff(None, roster(_r("600000.SH", 1, 10.0)), rank_jump=80, tail_guard=0.2)
assert d["params"] == {"rank_jump": 80, "tail_guard": 0.2}, "写死会让页面显示错的口径"
# ================================================================ [D] 三种比对语义
@case("[D1] 计划日不同 = 跨日的正常升降档")
def t_d1():
d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600000.SH", 1, 10.0)),
prev_date="2026-07-29", curr_date="2026-07-30")
assert d["kind"] == pd.KIND_CROSS_DAY
@case("[D2] 同一计划日的不同版本要单独成一类")
def t_d2():
d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600000.SH", 2, 10.0)),
prev_date="2026-07-30", curr_date="2026-07-30")
assert d["kind"] == pd.KIND_REVISION
@case("[D3] 同日新版本的结论要点明'候选池与此前不是同一份'")
def t_d3():
d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600418.SH", 1, 11.0)),
prev_date="2026-07-30", curr_date="2026-07-30")
assert "重算" in d["note"] and "候选池" in d["note"]
# ================================================================ [E] 进出榜与尾部闸
@case("[E1] 新进榜与掉榜各归各位")
def t_e1():
prev = roster(_r("600000.SH", 1, 10.0), _r("600418.SH", 2, 9.0))
curr = roster(_r("600000.SH", 1, 10.0), _r("600519.SH", 2, 9.5))
d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30")
assert [x["ts_code"] for x in d["entered"]] == ["600519.SH"]
assert [x["ts_code"] for x in d["exited"]] == ["600418.SH"]
@case("[E2] 掉榜那条带的是**上一版**的排名与档位")
def t_e2():
prev = roster(_r("600418.SH", 7, 233.3, tier="弱传导"))
d = pd.diff(prev, roster(_r("600000.SH", 1, 10.0)),
prev_date="2026-07-29", curr_date="2026-07-30")
x = d["exited"][0]
assert x["rank"] == 7 and x["tier"] == "弱传导", "掉榜的票在这一版没有数据, 只能示上一版"
@case("[E3] 两版都没吃满 top 时不设尾部闸 —— 消失就是真消失")
def t_e3():
prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 10)]) # 第 10 名没了
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
prev_capped=False, curr_capped=False)
assert d["counts"]["exited"] == 1 and d["tail_churn"]["exited"] == 0
# tail_guarded 报的是各档闸位在第几名, None = 该档没设闸 (让"为什么这条没报"可查)
assert d["tail_guarded"] == {"entered": {"main": None, "observe": None},
"exited": {"main": None, "observe": None}}
@case("[E4] 这一版吃满 top 时, 榜尾掉榜归 tail_churn 不列名")
def t_e4():
prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 10)]) # 原第 10 名没了
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
curr_capped=True) # 榜长 9, 闸位 4.5, 上一版 rank 10 在闸外
assert d["counts"]["exited"] == 0
assert d["tail_churn"]["exited"] == 1, "它可能只是掉到我们要的条数之外, 不是上游摘了它"
@case("[E5] 吃满 top 也照报榜首掉榜")
def t_e5():
prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(2, 11)]) # 榜首没了
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
assert [x["ts_code"] for x in d["exited"]] == ["600001.SH"]
assert d["tail_churn"]["exited"] == 0
@case("[E6] 新进榜的尾部闸看的是**上一版**吃没吃满")
def t_e6():
prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
curr = dict(prev)
curr["600099.SH"] = _r("600099.SH", 9, 91.5) # 新面孔排在榜尾
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", prev_capped=True)
assert d["counts"]["entered"] == 0
assert d["tail_churn"]["entered"] == 1, "上一版吃满了, 这张脸当时可能就在榜上只是没露面"
@case("[E7] 上一版吃满时, 榜首的新面孔照报")
def t_e7():
prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
curr = dict(prev)
curr["600099.SH"] = _r("600099.SH", 1, 200.0) # 新面孔直接冲到第一
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", prev_capped=True)
assert [x["ts_code"] for x in d["entered"]] == ["600099.SH"]
@case("[E8] 两道闸互不干扰: 这一版截断不该影响新进榜的判定")
def t_e8():
prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
curr = dict(prev)
curr["600099.SH"] = _r("600099.SH", 9, 91.5)
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
prev_capped=False, curr_capped=True)
assert d["counts"]["entered"] == 1, "上一版没吃满, 那这张新面孔就是真的新"
assert d["tail_churn"]["entered"] == 0
@case("[E9] 整档消失不被尾部闸吞掉")
def t_e9():
prev = roster(_r("600877.SH", 1, 101.0, tier=None, bucket="observe"),
_r("600000.SH", 1, 10.0))
curr = roster(_r("600000.SH", 1, 10.0)) # 观察档整档没了
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
assert [x["ts_code"] for x in d["exited"]] == ["600877.SH"], (
"这一版观察档一条都没有, 谈不上被 top 截断 —— 整档消失本身就是要报的大事")
@case("[E10] 尾部闸按档各算各的 (闸位取自各自那一档的长度)")
def t_e10():
prev = roster(*([_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)]
+ [_r("600877.SH", 1, 101.0, tier=None, bucket="observe")]))
curr = roster(*([_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 10)]
+ [_r(f"6009{i:02d}.SH", i, 90 - i, tier=None, bucket="observe")
for i in range(1, 11)]))
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
curr_capped={"main": True, "observe": True})
# 主榜榜尾那只走 churn; 观察档那只 rank=1、这一版观察档有 10 条(闸位 5), 在闸内 → 照报
assert [x["ts_code"] for x in d["exited"]] == ["600877.SH"]
assert d["tail_churn"]["exited"] == 1
@case("[E11] 主榜吃满不代表观察档也吃满 —— 别拿主榜的截断解释观察档的摘牌")
def t_e11():
# 上游两个独立参数: top=10 吃满了, obs_top=100 只回了 4 条 (没吃满)。
# 观察档那只真被摘了 —— 若两档共用主榜那一个标志, 它会被记成"截断噪音"永远看不见。
prev = roster(*([_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)]
+ [_r(f"6009{i:02d}.SH", i, 90 - i, tier=None, bucket="observe")
for i in range(1, 5)]))
curr = roster(*([_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)]
+ [_r(f"6009{i:02d}.SH", i, 90 - i, tier=None, bucket="observe")
for i in range(1, 4)])) # 观察档第 4 名被摘
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
curr_capped={"main": True, "observe": False})
assert [x["ts_code"] for x in d["exited"]] == ["600904.SH"], (
"观察档没吃满就不该设闸 —— 拿主榜的截断解释观察档, 会吞掉一次真实摘牌")
assert d["tail_churn"]["exited"] == 0
# 反过来: 两档都按吃满算, 这条就会被吞掉 (证明上面那条断言不是白写的)
d2 = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
assert d2["counts"]["exited"] == 0 and d2["tail_churn"]["exited"] == 1
@case("[E12] 持仓票不受榜尾闸约束 —— 漏报一条就是一个仓位")
def t_e12():
prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 10)])
# 非持仓: 上一版 rank 10 在闸外 → 吞掉
d0 = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
assert d0["counts"]["exited"] == 0
# 同一只票是持仓: 照报, 且记一笔豁免
d1 = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
curr_capped=True, held=["600010.SH"])
assert [x["ts_code"] for x in d1["exited"]] == ["600010.SH"]
assert d1["tail_churn"]["exited"] == 0 and d1["tail_churn"]["held_exempt"] == 1
assert d1["held"]["n_watch"] == 1
@case("[E13] rank 判不出来时照报, 不当噪音吞掉")
def t_e13():
prev = roster(_r("600000.SH", None, 10.0), _r("600001.SH", 1, 99.0))
curr = roster(_r("600001.SH", 1, 99.0))
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
assert [x["ts_code"] for x in d["exited"]] == ["600000.SH"], (
"这个函数的'是'代表吞掉一条变化, 所以拿不准时必须放行")
# ================================================================ [F] 档位与券商覆盖
@case("[F1] 升档与降档")
def t_f1():
prev = roster(_r("600000.SH", 1, 10.0, tier="弱传导"), _r("600418.SH", 2, 9.0, tier="强传导"))
curr = roster(_r("600000.SH", 1, 10.0, tier="强传导"), _r("600418.SH", 2, 9.0, tier="弱传导"))
d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30")
assert [x["ts_code"] for x in d["tier_up"]] == ["600000.SH"]
assert d["tier_up"][0]["tier_from"] == "弱传导" and d["tier_up"][0]["tier_to"] == "强传导"
assert [x["ts_code"] for x in d["tier_down"]] == ["600418.SH"]
@case("[F2] 主榜 ↔ 观察档 互换 = 券商覆盖翻转, 单独一段")
def t_f2():
prev = roster(_r("600000.SH", 1, 10.0))
curr = roster(_r("600000.SH", 1, 10.0, tier=None, bucket="observe"))
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30")
assert len(d["bucket_moved"]) == 1
m = d["bucket_moved"][0]
assert m["moved_from"] == "main" and m["moved_to"] == "observe"
assert d["counts"]["exited"] == 0, "换档不是掉榜 —— 票还在, 只是没券商覆盖了"
@case("[F3] 换了档就不算名次跳变 —— 两档的 rank 不可比")
def t_f3():
prev = roster(_r("600000.SH", 300, 10.0))
curr = roster(_r("600000.SH", 1, 10.0, tier=None, bucket="observe"))
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", rank_jump=10)
assert d["counts"]["rank_jump"] == 0
assert d["counts"]["bucket_moved"] == 1
# ================================================================ [G] 名次跳变
@case("[G1] 跳变到阈值才报")
def t_g1():
prev = roster(_r("600000.SH", 100, 10.0), _r("600418.SH", 20, 20.0))
curr = roster(_r("600000.SH", 20, 30.0), _r("600418.SH", 15, 22.0))
d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30", rank_jump=50)
assert [x["ts_code"] for x in d["rank_jump"]] == ["600000.SH"]
assert d["rank_jump"][0]["rank_from"] == 100 and d["rank_jump"][0]["rank_delta"] == -80
@case("[G2] 按跳变幅度降序")
def t_g2():
prev = roster(_r("600000.SH", 200, 1.0), _r("600418.SH", 60, 2.0))
curr = roster(_r("600000.SH", 100, 5.0), _r("600418.SH", 1, 9.0))
d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30", rank_jump=50)
assert [x["ts_code"] for x in d["rank_jump"]] == ["600000.SH", "600418.SH"]
@case("[G3] rank 缺失时不算跳变")
def t_g3():
prev = roster(_r("600000.SH", None, 1.0))
curr = roster(_r("600000.SH", 1, 9.0))
d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30", rank_jump=1)
assert d["counts"]["rank_jump"] == 0
# ================================================================ [H] 持仓票视图
@case("[H1] 持仓票掉榜进 n_watch")
def t_h1():
prev = roster(_r("600000.SH", 1, 10.0), _r("600418.SH", 2, 9.0))
curr = roster(_r("600418.SH", 1, 9.0))
d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30",
held=["600000.SH"])
assert [x["ts_code"] for x in d["held"]["exited"]] == ["600000.SH"]
assert d["held"]["n_watch"] == 1
assert d["exited"][0]["held"] is True
@case("[H2] 持仓票降档也进 n_watch")
def t_h2():
d = pd.diff(roster(_r("600000.SH", 1, 10.0, tier="强传导")),
roster(_r("600000.SH", 1, 10.0, tier="弱传导")),
prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
assert d["held"]["n_watch"] == 1 and len(d["held"]["tier_down"]) == 1
@case("[H2b] 持仓票掉进观察档 = 丢了券商覆盖, 也要进 n_watch")
def t_h2b():
# 观察档的行没有 tier, 所以这既不算 exited 也不算 tier_down —— 不显式算进来的话,
# 一只持仓票丢了估值锚, 页面红条不亮、探活脚本还会打一句"没被摘也没降档"。
d = pd.diff(roster(_r("600000.SH", 3, 240.0, tier="强传导")),
roster(_r("600000.SH", 5, 99.0, tier=None, bucket="observe")),
prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
assert d["counts"]["exited"] == 0 and d["counts"]["tier_down"] == 0
assert [x["ts_code"] for x in d["held"]["coverage_lost"]] == ["600000.SH"]
assert d["held"]["n_watch"] == 1
assert "券商覆盖" in d["note"]
@case("[H2c] 掉进主榜(拿到券商覆盖)不算要盯的事")
def t_h2c():
d = pd.diff(roster(_r("600000.SH", 5, 99.0, tier=None, bucket="observe")),
roster(_r("600000.SH", 3, 240.0, tier="强传导")),
prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
assert d["counts"]["bucket_moved"] == 1
assert d["held"]["coverage_lost"] == [] and d["held"]["n_watch"] == 0
@case("[H3] 持仓票升档只进 n_total, 不进 n_watch")
def t_h3():
d = pd.diff(roster(_r("600000.SH", 1, 10.0, tier="弱传导")),
roster(_r("600000.SH", 1, 10.0, tier="强传导")),
prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
assert d["held"]["n_watch"] == 0 and d["held"]["n_total"] == 1
@case("[H4] 非持仓票不进持仓视图")
def t_h4():
d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600418.SH", 1, 10.0)),
prev_date="2026-07-29", curr_date="2026-07-30", held=["600519.SH"])
assert d["held"]["n_total"] == 0 and d["counts"]["exited"] == 1
@case("[H5] 持仓票掉榜要在结论句里显眼")
def t_h5():
d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600418.SH", 1, 10.0)),
prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
assert "持仓票" in d["note"] and "掉榜" in d["note"]
# ================================================================ [I] 健壮性
@case("[I1] 空对空不炸")
def t_i1():
d = pd.diff({}, {}, prev_date="2026-07-30", curr_date="2026-07-30")
assert d["kind"] == pd.KIND_FIRST and sum(d["counts"].values()) == 0
@case("[I2] 名册全没了也报得出来")
def t_i2():
d = pd.diff(roster(_r("600000.SH", 1, 10.0)), {},
prev_date="2026-07-29", curr_date="2026-07-30")
assert d["counts"]["exited"] == 1 and d["roster_size"]["curr"] == 0
@case("[I3] tail_guard 越界值被夹回 [0,1]")
def t_i3():
prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11) if i != 5])
for given, clamped, reported in ((5.0, 1.0, 1), (-1.0, 0.0, 0)):
d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
curr_capped=True, tail_guard=given)
assert d["params"]["tail_guard"] == clamped
# 闸开到底(1.0): 榜内的第 5 名掉了要报; 闸关到底(0.0): 一律当截断噪音
assert d["counts"]["exited"] == reported
@case("[I4] counts 与各段长度始终一致")
def t_i4():
prev = roster(_r("600000.SH", 1, 10.0, tier="强传导"), _r("600418.SH", 200, 2.0))
curr = roster(_r("600000.SH", 1, 10.0, tier="弱传导"), _r("600519.SH", 3, 8.0))
d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30")
for k, n in d["counts"].items():
assert len(d[k]) == n, f"{k} 的 counts 与实际条数对不上"
# ================================================================ [J] 服务层落库往返
# 纯逻辑之外唯一容易藏 bug 的地方: 名册 → JSON 落库 → 读回来 → 比对 这条往返。
# 单测不连库 —— 拿内存桩顶掉 pms_repo 与 param_store (plan_feed 是函数内 import, 装在
# sys.modules 上就生效; 与 test_batch7 用假 requests 是同一套路)。
def _with_fake_repo(params=None):
import json as _j
import types
store = {"rows": [], "seq": 0}
repo = types.ModuleType("app.repo.pms_repo")
def insert_plan_snapshot(*, plan_date, digest, roster, meta=None, n_main=0, n_observe=0,
capped_main=False, capped_obs=False, fetched_at=None):
# 与生产同一条判据: 只跟**最新那行**比, 不是"曾经出现过的任何一行"。
# 早先桩里写的是后者 (模拟唯一键), 于是 A→B→A 这个 case 在桩上永远复现不出来 ——
# 桩比生产更严, 单测就成了给错实现背书。
last = max(store["rows"], key=lambda x: x["id"]) if store["rows"] else None
if last and last["plan_date"] == plan_date and last["digest"] == digest:
return 0
store["seq"] += 1
store["rows"].append({
"id": store["seq"], "plan_date": plan_date, "digest": digest,
"fetched_ymd": 20260731, "fetched_at": f"t{store['seq']}",
"n_main": n_main, "n_observe": n_observe,
"capped_main": bool(capped_main), "capped_obs": bool(capped_obs),
# 真表是 MEDIUMTEXT, 落进去的是 JSON 文本、读回来才转对象。桩必须照做 ——
# 否则"名册里塞了不可序列化的东西"这类事故单测里根本看不见。
"roster_json": _j.dumps(roster, ensure_ascii=False), "meta": meta or {}})
return 1
def latest_plan_snapshots(limit=2):
out = []
for r in sorted(store["rows"], key=lambda x: -x["id"])[:limit]:
r2 = dict(r)
r2["roster"] = _j.loads(r2.pop("roster_json"))
out.append(r2)
return out
def list_plan_snapshots(*, plan_date=None, limit=50):
rows = [dict(r) for r in sorted(store["rows"], key=lambda x: -x["id"])]
if plan_date:
rows = [r for r in rows if r["plan_date"] == plan_date]
for r in rows:
r.pop("roster_json", None)
return rows[:limit]
repo.insert_plan_snapshot = insert_plan_snapshot
repo.latest_plan_snapshots = latest_plan_snapshots
repo.list_plan_snapshots = list_plan_snapshots
repo.prune_plan_snapshots = lambda keep=200: 0
repo.list_positions = lambda only_open=False: []
ps = types.ModuleType("app.services.param_store")
vals = dict(params or {})
ps.get = lambda k, d=None: vals.get(k, d)
ps.get_int = lambda k, d=0: int(vals.get(k, d))
ps.get_float = lambda k, d=0.0: float(vals.get(k, d))
ps.get_bool = lambda k, d=False: bool(vals.get(k, d))
ps.get_list = lambda k, d=(): list(vals.get(k, d))
saved = {n: sys.modules.get(n) for n in ("app.repo.pms_repo", "app.services.param_store")}
sys.modules["app.repo.pms_repo"] = repo
sys.modules["app.services.param_store"] = ps
def restore():
for n, m in saved.items():
if m is None:
sys.modules.pop(n, None)
else:
sys.modules[n] = m
return store, restore
@case("[J1] 落库往返: 名册经 JSON 存取后指纹不变")
def t_j1():
import json as _j
from app.services import plan_feed as pf
store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
p = plan(main=[_r("600418.SH", 1, 242.24, theme="整车"),
_r("600000.SH", 2, 241.55, tier="弱传导", theme=None)],
observe=[_r("600877.SH", 1, 101.1, tier=None, bucket="observe")],
date="2026-07-30")
out = pf.snapshot(p)
assert out["stored"] is True and out["rows"] == 3
back = pd.roster_from_rows(_j.loads(store["rows"][0]["roster_json"]))
assert pd.digest_of(back) == out["digest"], "存取一轮指纹就变了, 比对全废"
assert back["600000.SH"]["h"] is None, "theme 为空的票也要原样存回来"
finally:
restore()
@case("[J2] 同一份榜重复落只留一行")
def t_j2():
from app.services import plan_feed as pf
store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
p = plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30")
assert pf.snapshot(p)["stored"] is True
assert pf.snapshot(p)["stored"] is False, "内容没变还新增一行, 表会白涨"
assert len(store["rows"]) == 1
finally:
restore()
@case("[J3] score 变了但次序没变, 不算新版本")
def t_j3():
from app.services import plan_feed as pf
store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
pf.snapshot(plan(main=[_r("600418.SH", 1, 242.2801)], date="2026-07-30"))
pf.snapshot(plan(main=[_r("600418.SH", 1, 242.2799)], date="2026-07-30"))
assert len(store["rows"]) == 1
finally:
restore()
@case("[J4] 只读预览不写库, 且认得出'与库里那份相同'")
def t_j4():
from app.services import plan_feed as pf
store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
p = plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30")
pf.snapshot(p)
n = len(store["rows"])
out = pf.preview_changes(p)
assert out["ok"] and out["same_as_stored"] is True
assert len(store["rows"]) == n, "preview 必须只读 —— 探活脚本的只读契约靠它"
finally:
restore()
@case("[J5] 只读预览能比出手上这份与库里那份的差异")
def t_j5():
from app.services import plan_feed as pf
store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
pf.snapshot(plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30"))
out = pf.preview_changes(plan(main=[_r("600519.SH", 1, 250.0)], date="2026-07-30"))
d = out["diff"]
assert d["kind"] == pd.KIND_REVISION
assert [x["ts_code"] for x in d["entered"]] == ["600519.SH"]
assert [x["ts_code"] for x in d["exited"]] == ["600418.SH"]
assert len(store["rows"]) == 1
finally:
restore()
@case("[J6] 快照关掉时 changes 明说不可用, 而不是装作没变化")
def t_j6():
from app.services import plan_feed as pf
_, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": False})
try:
out = pf.changes()
assert out["ok"] is False and out["diff"] is None
assert "PMS_PLAN_SNAPSHOT" in out["hint"], "得说清是关掉了, 不是没有变化"
finally:
restore()
@case("[J7] 快照落库失败不阻断候选池, 但要把错带出来")
def t_j7():
from app.services import plan_feed as pf
_, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
def _boom(**kw):
raise RuntimeError("DB 挂了")
sys.modules["app.repo.pms_repo"].insert_plan_snapshot = _boom
out = pf._snapshot_quiet(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-30"))
assert out["stored"] is False and "DB 挂了" in out["error"]
finally:
restore()
@case("[J7b] 上游 A→B→A 改回去时, 比对方向不能反")
def t_j7b():
# 曾经用 (plan_date, digest) 唯一键去重, 于是第三次的 A 落不下去、库里最新仍是 B,
# 页面把 B→A 这次真实回退显示成 A→B —— 方向整个反了, 还顺带诬告一句"落库失败"。
from app.services import plan_feed as pf
store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
a = plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30")
b = plan(main=[_r("600519.SH", 1, 250.00)], date="2026-07-30")
pf.snapshot(a)
pf.snapshot(b)
assert pf.snapshot(a)["stored"] is True, "改回去也是一次真实变化, 必须落得下去"
assert len(store["rows"]) == 3
out = pf.preview_changes(a)
assert out["same_as_stored"] is True
d = pf.changes()["diff"]
assert [x["ts_code"] for x in d["entered"]] == ["600418.SH"]
assert [x["ts_code"] for x in d["exited"]] == ["600519.SH"]
finally:
restore()
@case("[J7c] 上一版名册坏了要报故障, 不能显示成'首次'")
def t_j7c():
import json as _j
from app.services import plan_feed as pf
store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
pf.snapshot(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-30"))
store["rows"][0]["roster_json"] = _j.dumps([]) # 名册没了, n_main 还写着 1
out = pf.preview_changes(plan(main=[_r("600519.SH", 1, 2.0)], date="2026-07-31"))
assert out["diff"]["prev_broken"] is True
assert "读不出来" in out["hint"]
finally:
restore()
@case("[J7d] 持仓代码是前缀式也要认得出来")
def t_j7d():
from app.services import plan_feed as pf
_, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
sys.modules["app.repo.pms_repo"].list_positions = \
lambda only_open=False: [{"ts_code": "SH600418"}] # 前缀式
pf.snapshot(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-30"))
out = pf.preview_changes(plan(main=[_r("600519.SH", 1, 2.0)], date="2026-07-31"))
assert out["diff"]["held"]["n_watch"] == 1, "不归一的后果是持仓视图静默为空"
finally:
restore()
@case("[J8] 快照台账数得出同一计划日有几版")
def t_j8():
from app.services import plan_feed as pf
_, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
pf.snapshot(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-30"))
pf.snapshot(plan(main=[_r("600418.SH", 2, 1.0)], date="2026-07-30"))
pf.snapshot(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-29"))
log = pf.snapshot_log()
assert log["ok"] and log["versions_per_date"]["2026-07-30"] == 2
assert log["revised"] == {"2026-07-30": 2}, "同一 date 多版是 §7.3 那个现象, 要数出来"
finally:
restore()
def main():
import logging
logging.disable(logging.CRITICAL)
passed, failed = 0, 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()