"""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()