akg-factor-bridge/test_card.py

163 lines
9.1 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.

"""card.judge() 纯逻辑单测(无需 DB、无需 pandas
覆盖2026-09-03 台账 013 的关注定义细化):候选 / 关注只剩"确认线缺失或陈旧"两种 /
只差覆盖与潜在吸筹归仅展示 / 仅展示(未启动、硬风险否决)/ 缺失文案与判决依据 /
因果论断证据线只展示不进判决 / 论断质量标注(陈旧、单一来源、疑似误抽、未结分歧,
四项都只提示不作门槛)/ 旋钮 / 卡内序 / 坏信号集合与 pool 同源。
跑法python3 test_card.py 或 pytest test_card.py
"""
import card
BASE = dict(pointed=True, theme="散热器件", n_sources=2, pct0=4.1,
covered=True, upside=0.25, risk_name=False,
accum_state="明确吸筹·量在价先", accum_score=78, accum_age=3,
y_signal="WATCH", stale_snapshot=False)
CLAIM = {"direction": "利好", "mechanism": "液冷散热渗透率提升带动订单", "horizon": "半年内",
"condition": None, "doc_title": "散热行业深度", "disclosure_date": "2026-08-20",
"claim_id": "clm_001"}
def t(name, cond):
assert cond, name
print(" ok", name)
def main():
r = card.judge(dict(BASE))
t("三门槛全过且明确吸筹 -> 候选", r["verdict"] == "候选" and r["confirm"] and not r["risk"])
t("候选理由四条齐", len(r["reasons"]) == 4 and "已启动" in r["reasons"][1])
t("候选的判决依据一句话", "明确吸筹" in r["basis"] and r["failed_gates"] == [])
t("无论断时 logic 为空列表", r["logic"] == [])
# ---- 关注只剩两种:确认线缺失(无评分)、确认线陈旧(明确吸筹但日龄超限或未知)----
r = card.judge({**BASE, "accum_state": ""})
t("缺评分 -> 仍关注且缺失文案", r["verdict"] == "关注" and "无吸筹评分(未入池)" in r["missing"])
t("关注的判决依据写明无法判断、交人裁决", "无法判断" in r["basis"] and "交人裁决" in r["basis"])
r = card.judge({**BASE, "accum_age": 45})
t("评分陈旧 -> 仍关注、不确认、缺失文案", r["verdict"] == "关注" and not r["confirm"]
and any("陈旧 45" in m for m in r["missing"]))
r = card.judge({**BASE, "accum_age": None})
t("明确吸筹但日龄未知 -> 关注(陈旧一类)", r["verdict"] == "关注"
and any("日龄未知" in m for m in r["missing"]))
# ---- 只差覆盖与潜在吸筹不再升格关注(台账 013----
r = card.judge({**BASE, "covered": False, "upside": None})
t("只差覆盖 -> 仅展示,缺失文案标明不升格", r["verdict"] == "仅展示" and "covered" in r["failed_gates"]
and any(m.startswith("无券商覆盖") and "仅展示" in m for m in r["missing"]))
r = card.judge({**BASE, "accum_state": "潜在吸筹·低位企稳"})
t("潜在吸筹 -> 仅展示failed_gates 附加 confirm", r["verdict"] == "仅展示" and not r["confirm"]
and "confirm" in r["failed_gates"] and any("潜在吸筹" in m and "仅展示" in m for m in r["missing"]))
t("潜在吸筹的判决依据写明未达确认线", "未达确认线" in r["basis"])
r = card.judge({**BASE, "accum_state": "信号不明"})
t("信号不明等非明确状态 -> 仅展示", r["verdict"] == "仅展示" and "confirm" in r["failed_gates"])
r = card.judge({**BASE, "upside": -0.05})
t("负容忍默认 0轻微为负不过覆盖门槛 -> 仅展示", r["verdict"] == "仅展示" and "covered" in r["failed_gates"]
and any("负容忍线" in m for m in r["missing"]))
# ---- 其余仅展示与硬风险 ----
r = card.judge({**BASE, "pct0": 1.2})
t("未启动 -> 仅展示", r["verdict"] == "仅展示" and "started" in r["failed_gates"] and "门槛未过" in r["basis"])
r = card.judge({**BASE, "pointed": False})
t("未被指向 -> 仅展示且标无传导", r["verdict"] == "仅展示" and "无传导" in r["missing"])
r = card.judge({**BASE, "y_signal": "sell"})
t("决策系统 SELL -> 硬风险否决为仅展示", r["verdict"] == "仅展示" and r["risk"] and "硬风险" in r["basis"])
r = card.judge({**BASE, "stale_snapshot": True})
t("快照日不符 -> 硬风险", r["verdict"] == "仅展示" and "传导快照日与计划日不符" in r["risk"])
r = card.judge({**BASE, "accum_state": "⚠️ 高位派发"})
t("高位派发 -> 硬风险,且不重复写进缺失", r["verdict"] == "仅展示" and "高位派发" in r["risk"]
and not any("派发" in m for m in r["missing"]))
r = card.judge({**BASE, "risk_name": True})
t("ST 族 -> 仅展示", r["verdict"] == "仅展示" and "clean_name" in r["failed_gates"])
# ---- 因果论断:只展示,不改判决 ----
r = card.judge({**BASE, "logic": [CLAIM, {**CLAIM, "claim_id": "clm_002", "doc_title": None}]})
t("带论断仍是候选logic 两行带出处", r["verdict"] == "候选" and len(r["logic"]) == 2
and "《散热行业深度》" in r["logic"][0] and "2026-08-20" in r["logic"][0] and "clm_001" in r["logic"][0])
t("论断不进理由", len(r["reasons"]) == 4)
r = card.judge({**BASE, "pct0": 1.2, "logic": [CLAIM]})
t("论断不能把未启动拉成候选", r["verdict"] == "仅展示" and r["logic"])
t("坏论断条目被跳过", card.logic_lines([None, "x", {}]) == ["因果论断"])
# ---- 旋钮 ----
r = card.judge({**BASE, "pct0": 2.5}, start_pct=2.0)
t("启动阈值旋钮生效", r["verdict"] == "候选")
r = card.judge({**BASE, "accum_age": 45}, accum_max_age=60)
t("评分日龄旋钮生效", r["verdict"] == "候选")
r = card.judge({**BASE, "upside": -0.05}, neg_tol=0.10)
t("负容忍线传入生效", r["verdict"] == "候选")
rows = [{"verdict": "关注", "pct0": 9.0}, {"verdict": "候选", "pct0": 3.5},
{"verdict": "候选", "pct0": 7.2}, {"verdict": "仅展示", "pct0": None}]
rows.sort(key=card.sort_key)
t("卡内序:候选在前、同判决涨幅降序、缺行情垫底",
[x["pct0"] for x in rows] == [7.2, 3.5, 9.0, None])
# ---- 论断质量标注(只展示、只提示,不进判决)----
# 质量画像由取数层在截断之前算好挂在每条论断上,这里按那个形状造数据。
def q(**kw):
base = dict(n=1, age_days=10, latest_date="2026-08-25", n_docs=1,
n_flagged=0, n_disputed=0, n_via_segment=0)
base.update(kw)
return base
def claims(n, quality):
return [{**CLAIM, "quality": quality} for _ in range(n)]
r = card.judge({**BASE, "logic": claims(1, q())})
t("论断新鲜且单条:不产生任何质量提示",
r["verdict"] == "候选" and not [m for m in r["missing"] if "论断" in m])
t("质量画像随判决一起出", r["logic_quality"]["age_days"] == 10)
r = card.judge({**BASE, "logic": claims(1, q(age_days=200, latest_date="2026-02-16"))})
t("论断超陈旧线:写明过了多少天与最新一条的日期",
"研报论断已过 200 天(最新一条 2026-02-16" in r["missing"])
t("论断陈旧不改判决(只提示不作门槛)", r["verdict"] == "候选")
r = card.judge({**BASE, "logic": claims(1, q(age_days=91))}, logic_stale_days=120)
t("陈旧线旋钮生效:放宽到 120 天后 91 天不再提示",
not [m for m in r["missing"] if "研报论断已过" in m])
r = card.judge({**BASE, "logic": claims(1, q(age_days=90))})
t("边界:正好等于陈旧线不算陈旧(超过才提示)",
not [m for m in r["missing"] if "研报论断已过" in m])
r = card.judge({**BASE, "logic": claims(3, q(n=3, n_docs=1))})
t("多条论断出自同一份研报:单一来源提示",
"3 条论断全部出自同一份研报" in r["missing"])
r = card.judge({**BASE, "logic": claims(3, q(n=3, n_docs=2))})
t("来源多于一份就不提示", not [m for m in r["missing"] if "同一份研报" in m])
r = card.judge({**BASE, "logic": claims(1, q(n=1, n_docs=1))})
t("只有一条论断时不算单一来源(那是条数少不是来源集中)",
not [m for m in r["missing"] if "同一份研报" in m])
r = card.judge({**BASE, "logic": claims(4, q(n=4, n_docs=2, n_flagged=2, n_disputed=1))})
t("疑似误抽与未结分歧各写一条",
"其中 2 条被标为疑似误抽" in r["missing"] and "其中 1 条处于未结的方向分歧" in r["missing"])
r = card.judge(dict(BASE))
t("没有论断时不产生质量提示、画像为空",
r["logic_quality"] is None and not [m for m in r["missing"] if "论断" in m])
r = card.judge({**BASE, "logic": [CLAIM]})
t("上游没给质量画像时不报错、也不瞎提示",
r["logic_quality"] is None and not [m for m in r["missing"] if "论断" in m])
t("论断为空列表时不产生提示", card.logic_notes([]) == [] and card.logic_quality([]) is None)
t("坏信号集合口径", card.BAD_SIGNALS == {"SELL", "AVOID", "DROPPED"})
print("ALL OK — 候选卡判决 / 关注两种(缺失、陈旧)/ 只差覆盖与潜在吸筹归仅展示 / 硬风险三种 / "
"因果论断只展示 / 论断质量标注四项 / 缺失文案与依据 / 旋钮 / 卡内序 全部通过")
if __name__ == "__main__":
main()