764 lines
34 KiB
Python
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()
|