tradingSystem/scripts/test_batch8_units.py

764 lines
34 KiB
Python

# -*- coding: utf-8 -*-
"""
第八批模块单测 (零外部依赖, 不连库不触网)
==========================================
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch8_units.py
覆盖: 上游榜单的版本比对 (app/core/plan_diff.py) —— 名册与指纹、三种比对语义、
进出榜的尾部闸、档位升降与券商覆盖翻转、名次跳变、持仓票视图。
这一批的重点不是"能不能算出差异", 而是**不报假变化**: 榜单是按 top 截断过的, 尾部的进出
是截断噪音而不是上游观点变化。假警报天天响, 提示就等于没有 —— 与 `_capped` 治的是同一个病。
"""
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()