tradingSystem/scripts/test_batch9_units.py

326 lines
14 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_batch9_units.py
覆盖: 接管既有持仓前的成本价体检 (app/core/rebuild_check.py)。
这一批守的是一个**只发生一次、且不可逆**的决定: 账本清空后按「以下游为准」认领真实持仓,
那一刻定死每只票的开仓价 → 摊薄成本 → 安全垫 → 补仓/加仓/保垫减仓的共同判据。
2026-07-29 首次接管 22 只持仓时踩过一次 (全按现价开仓, 安全垫齐刷刷是 0)。
所以用例的重点不是"算得对不对", 而是**该拦的拦不拦得住**, 以及**不该拦的会不会误伤** ——
误伤的代价是多等一天, 漏拦的代价是一本每个数都错、却和真账长得一模一样的账。
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import rebuild_check as rb # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def _p(code, qty=1000, cost=10.0, avail=None, price=None):
"""一行下游持仓 (downstream_repo.fetch_positions 的行形态)。"""
return {"ts_code": code, "qty": qty, "cost": cost,
"avail_qty": qty if avail is None else avail, "price": price}
# ================================================================ [A] 单只票的判定
@case("[A1] 成本价正常 → OK, 并算出安全垫")
def t_a1():
r = rb.check_row(_p("600000.SH", cost=20.0), price=10.0)
assert r["verdict"] == rb.OK
assert abs(r["cushion_pct"] - (-0.5)) < 1e-9, "真实成本 20、现价 10 就是实亏 50%"
@case("[A2] 成本价缺失 / 为 0 / 为负 → MISSING")
def t_a2():
for bad in (None, 0, 0.0, -1.0, ""):
r = rb.check_row(_p("600000.SH", cost=bad), price=10.0)
assert r["verdict"] == rb.MISSING, f"cost={bad!r} 应判 MISSING"
assert "安全垫恒为 0" in rb.check_row(_p("600000.SH", cost=0), price=10.0)["why"]
@case("[A3] 成本 ≈ 现价 → EQ_PRICE (安全垫≈0)")
def t_a3():
r = rb.check_row(_p("600000.SH", cost=10.02), price=10.0) # 差 0.2% < 0.5%
assert r["verdict"] == rb.EQ_PRICE
r2 = rb.check_row(_p("600000.SH", cost=10.30), price=10.0) # 差 3% > 0.5%
assert r2["verdict"] == rb.OK, "正常的小幅浮盈不该被当成'拿现价充数'"
@case("[A4] 成本与现价差两个数量级 → ABSURD (多半是单位错)")
def t_a4():
assert rb.check_row(_p("600000.SH", cost=1000.0), price=10.0)["verdict"] == rb.ABSURD
assert rb.check_row(_p("600000.SH", cost=0.05), price=10.0)["verdict"] == rb.ABSURD
# 分/元 混用是最典型的一种: 成本记成 1002 分而现价是 10.02 元
r = rb.check_row(_p("600000.SH", cost=1002.0), price=10.02)
assert r["verdict"] == rb.ABSURD and "单位" in r["why"]
@case("[A5] 可用量 > 总量 或为负 → AVAIL_BAD")
def t_a5():
assert rb.check_row(_p("600000.SH", qty=1000, avail=1500), price=10.0)["verdict"] \
== rb.AVAIL_BAD
assert rb.check_row(_p("600000.SH", qty=1000, avail=-1), price=10.0)["verdict"] \
== rb.AVAIL_BAD
# 当日买入是合法的: 可用 < 总量 (成本给个与现价不同的值, 免得撞上 EQ_PRICE)
assert rb.check_row(_p("600000.SH", qty=1000, avail=0, cost=20.0),
price=10.0)["verdict"] == rb.OK
@case("[A6] 取不到现价 → NO_PRICE, 但成本本身仍算可用")
def t_a6():
r = rb.check_row(_p("600000.SH", cost=20.0), price=None)
assert r["verdict"] == rb.NO_PRICE and r["cushion_pct"] is None
assert "成本值本身可用" in r["why"]
@case("[A7] 可用量的判定排在成本之前 —— 两个都坏时先报可用量")
def t_a7():
r = rb.check_row(_p("600000.SH", qty=100, avail=999, cost=0), price=10.0)
assert r["verdict"] == rb.AVAIL_BAD
# ================================================================ [B] 整批的阻断判据
@case("[B1] 全部正常 → 不阻断")
def t_b1():
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", cost=1500.0)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 1800.0})
assert out["blocking"] is False and out["reasons"] == []
assert "可以建账" in out["hint"]
@case("[B2] 少数几只没成本价不阻断, 但要单独列出来")
def t_b2():
# 既有设计对"某一只没成本价"有逐只回退现价的路径 (build_recon_fixes, 带 price_source
# 留痕)。闸不该推翻它 —— 闸管的是"一本从头就错的账", 不是替既有设计做二次判断。
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", cost=0)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 1800.0})
assert out["blocking"] is False
assert out["estimated"] == ["600519.SH"]
assert "改不回来" in out["hint"], "估出来的成本后续对账不会再碰, 这句必须说出来"
@case("[B2b] 全部没成本价 → 阻断 (对端那一列整个没填)")
def t_b2b():
rows = [_p("600000.SH", cost=0), _p("600519.SH", cost=None)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 1800.0})
assert out["blocking"] is True and out["estimated"] == []
assert "全部" in out["reasons"][0]
@case("[B2c] 数据是**错的**而不是缺的 → 一只就阻断")
def t_b2c():
# ABSURD/AVAIL_BAD 说明这批数据的生产方式有问题, 不该只怀疑那一只
for bad in ({"cost": 1000.0}, {"avail": 9999}):
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", **bad)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 10.0})
assert out["blocking"] is True, bad
assert "错的" in out["reasons"][0]
@case("[B3] 整组成本≈现价 → 阻断 (这就是 07-29 那个坑的模样)")
def t_b3():
rows = [_p(f"60000{i}.SH", cost=10.0) for i in range(1, 6)]
out = rb.check_costs(rows, {f"60000{i}.SH": 10.0 for i in range(1, 6)})
assert out["blocking"] is True and out["eq_ratio"] == 1.0
assert "安全垫会是 0" in out["reasons"][0]
@case("[B4] 少数几只成本≈现价 → 不阻断 (当日买入是正常的)")
def t_b4():
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", cost=1800.0),
_p("600036.SH", cost=30.0), _p("601318.SH", cost=10.0)]
px = {"600000.SH": 10.0, "600519.SH": 1500.0, "600036.SH": 40.0, "601318.SH": 10.0}
out = rb.check_costs(rows, px)
assert out["counts"].get(rb.EQ_PRICE) == 1
assert out["blocking"] is False, "四只里一只当日买入很正常, 不该拦"
@case("[B5] 只有一只票时不按占比阻断 —— 样本太小, 它可能真是当日买的")
def t_b5():
out = rb.check_costs([_p("600000.SH", cost=10.0)], {"600000.SH": 10.0})
assert out["counts"].get(rb.EQ_PRICE) == 1
assert out["blocking"] is False
@case("[B6] 取不到现价的票不稀释 EQ_PRICE 占比")
def t_b6():
# 两只成本≈现价 + 八只取不到现价。若拿 10 做分母, 占比 20% 不阻断 —— 那是错的:
# 能判的两只全是 EQ_PRICE, 该阻断。
rows = [_p("600001.SH", cost=10.0), _p("600002.SH", cost=10.0)] + \
[_p(f"6001{i:02d}.SH", cost=5.0) for i in range(1, 9)]
px = {"600001.SH": 10.0, "600002.SH": 10.0} # 其余取不到价
out = rb.check_costs(rows, px)
assert out["counts"].get(rb.NO_PRICE) == 8
assert out["eq_ratio"] == 1.0 and out["blocking"] is True
@case("[B7] 零持仓行不参与体检")
def t_b7():
rows = [_p("600000.SH", qty=0, cost=0), _p("600519.SH", cost=1800.0)]
out = rb.check_costs(rows, {"600519.SH": 1500.0})
assert out["n"] == 1 and out["blocking"] is False, "qty=0 的行是历史残留, 不该拖累建账"
@case("[B8] 下游一只持仓都没有 → 不阻断但说清是没账可建")
def t_b8():
out = rb.check_costs([], {})
assert out["n"] == 0 and out["blocking"] is False
assert "无账可建" in out["hint"]
@case("[B9] 阻断时的 hint 要给出可执行的下一步")
def t_b9():
out = rb.check_costs([_p("600000.SH", cost=0)], {"600000.SH": 10.0}) # 唯一一只且缺失
assert "cost_price" in out["hint"], "得说清要对端改哪一列, 不是只说'数据有问题'"
assert "长得一模一样" in out["hint"], "得说清为什么不能将就着建"
# ================================================================ [C] 情形覆盖 (不阻断)
@case("[C1] 四种情形齐全 → enough")
def t_c1():
rows = [_p("600000.SH", cost=10.0), # 浮盈 +50%
_p("600519.SH", cost=20.0), # 浮亏 25%
_p("600036.SH", cost=10.0, qty=1000, avail=0), # 当日买入
_p("601318.SH", cost=10.0)]
px = {"600000.SH": 15.0, "600519.SH": 15.0, "600036.SH": 11.0, "601318.SH": 11.0}
cov = rb.coverage(rb.check_costs(rows, px)["rows"])
assert cov["enough"] is True and cov["missing"] == []
@case("[C2] 全是不赚不亏 → 报出这轮验不到哪些纪律")
def t_c2():
rows = [_p(f"60000{i}.SH", cost=10.0) for i in range(1, 4)]
cov = rb.coverage(rb.check_costs(rows, {f"60000{i}.SH": 10.05 for i in range(1, 4)})["rows"])
assert cov["enough"] is False
assert any("浮盈" in m for m in cov["missing"])
assert any("浮亏" in m for m in cov["missing"])
@case("[C3] 只有一只持仓 → 报持仓不足 3 只")
def t_c3():
cov = rb.coverage(rb.check_costs([_p("600000.SH", cost=20.0)], {"600000.SH": 10.0})["rows"])
assert cov["enough"] is False and any("不足 3 只" in m for m in cov["missing"])
@case("[C4] 覆盖不全绝不阻断建账 —— 数据是真的就该建")
def t_c4():
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", cost=3000.0)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 1500.0})
cov = rb.coverage(out["rows"])
assert out["blocking"] is False and cov["enough"] is False
# ================================================================ [D] 健壮性
@case("[D1] 脏数据不炸")
def t_d1():
for bad in ({"ts_code": "600000.SH"}, {"ts_code": None, "qty": "x", "cost": "y"},
{"ts_code": "600000.SH", "qty": "1000", "cost": "20.0", "avail_qty": "1000"}):
rb.check_row(bad, price=10.0)
out = rb.check_costs([{"ts_code": "600000.SH", "qty": "1000", "cost": "20.0"}],
{"600000.SH": "10.0"})
assert out["n"] == 1, "字符串数字要能吃进去 —— 库里 DECIMAL 列取出来常是字符串"
@case("[D2] rows 为 None 不炸")
def t_d2():
assert rb.check_costs(None, None)["n"] == 0
assert rb.coverage(None)["enough"] is False
@case("[D3] 判定常量互不相同 —— 别把两种故障混成一个码")
def t_d3():
vs = [rb.OK, rb.MISSING, rb.EQ_PRICE, rb.ABSURD, rb.AVAIL_BAD, rb.NO_PRICE]
assert len(set(vs)) == len(vs)
# ================================================================ [E] 连续不一致按日推进
# 2026-07-31 实机暴露: 日报关注区写出「连续 175 日不一致」, 而项目 07-14 才开工。
# 原因是每调一次 reconcile() 就 +1, 而盘中轻对账每分钟调一次 —— 设计里「连续 3 日 → ERROR
# 待人工」实际成了「连续 3 分钟」。假警报天天响, 真告警就被埋掉。
from app.core import recon as rc # noqa: E402
@case("[E1] 同一天内反复对账不重复计数")
def t_e1():
s = rc.advance_streak(0, 0, 20260731, True)
assert s["streak"] == 1 and s["changed"] is True
for _ in range(5): # 手工点五次「对账」
s = rc.advance_streak(s["streak"], s["ymd"], 20260731, True)
assert s["streak"] == 1, "同一天点几次都只算一天 —— 否则三分钟就升 ERROR"
assert s["changed"] is False
@case("[E2] 跨交易日才 +1")
def t_e2():
s = rc.advance_streak(1, 20260731, 20260801, True)
assert s["streak"] == 2 and s["ymd"] == 20260801
s = rc.advance_streak(s["streak"], s["ymd"], 20260803, True) # 跨周末
assert s["streak"] == 3
@case("[E3] 差异消失立刻归零, 不必等下一天")
def t_e3():
s = rc.advance_streak(7, 20260731, 20260731, False)
assert s["streak"] == 0 and s["changed"] is True and s["ymd"] == 20260731
@case("[E4] 本来就是 0 且没差异 → 什么都没变, 不必写库")
def t_e4():
assert rc.advance_streak(0, 20260731, 20260731, False)["changed"] is False
@case("[E5] 三日门槛与 severity 对得上")
def t_e5():
assert rc.recon_severity(0) == rc.SEV_OK
assert rc.recon_severity(1) == rc.SEV_WARN and rc.recon_severity(2) == rc.SEV_WARN
assert rc.recon_severity(3) == rc.SEV_ERROR
# 走满三个交易日才该到 ERROR —— 这条串起来验, 免得两边各改一半
s = {"streak": 0, "ymd": 0}
for i, d in enumerate((20260731, 20260801, 20260803), start=1):
s = rc.advance_streak(s["streak"], s["ymd"], d, True)
assert rc.recon_severity(s["streak"]) == (rc.SEV_ERROR if i >= 3 else rc.SEV_WARN)
@case("[E6] 缺日期时退化成每次都推进, 但不会把已有计数弄丢")
def t_e6():
s = rc.advance_streak(2, 0, 0, True) # 两个 ymd 都拿不到
assert s["streak"] == 3, "判不了是不是同一天就按保守走(照常推进), 别把计数清零"
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()