diff --git a/card.py b/card.py index b627c8a..17bfdf4 100644 --- a/card.py +++ b/card.py @@ -46,6 +46,13 @@ from typing import Any # 决策系统夜间结论表 signal_type 里的坏信号(三处同源,见模块说明) BAD_SIGNALS = {"SELL", "AVOID", "DROPPED"} +# 昨夜信号的代号翻译成中文。这些代号是决策系统那边的枚举值,只在系统之间传递; +# 一旦要写进给人看的句子里,就必须先翻成人话——页面上出现一个 SELL,读的人得先 +# 知道这是个枚举值才看得懂。翻不出来的原样带上,总比吞掉强。 +SIGNAL_CN = {"SELL": "卖出", "AVOID": "回避", "DROPPED": "已剔除", + "BUY": "买入", "WATCH": "观察", "HOLD": "持有"} + + VERDICT_CANDIDATE = "候选" VERDICT_WATCH = "关注" VERDICT_SHOW = "仅展示" @@ -113,29 +120,30 @@ def judge(ev: dict[str, Any], *, start_pct: float = 3.0, risk: list[str] = [] if y_signal in BAD_SIGNALS: - risk.append(f"决策系统昨夜信号 {y_signal}") + risk.append(f"择时决策系统昨夜给出{SIGNAL_CN.get(y_signal, y_signal)}信号") if ev.get("stale_snapshot"): - risk.append("传导快照日与计划日不符") + risk.append("用的传导快照不是计划当天的") if "派发" in accum_state: - risk.append("高位派发") + risk.append("资金在高位派发") missing: list[str] = [] if pct0 is None: - missing.append("无行情") + missing.append("没有当日行情") if not ev.get("covered"): - missing.append("无券商覆盖(不升格关注,归仅展示)") + missing.append("没有券商覆盖") elif not gates["covered"]: - missing.append(f"预期空间 {upside:+.0%} 低于负容忍线" if upside is not None else "预期空间缺失") + missing.append(f"券商预期空间 {upside:+.0%},低于 {neg_tol:+.0%} 的容忍线" + if upside is not None else "算不出券商预期空间") if confirm_missing: - missing.append("无吸筹评分(未入池)") + missing.append("还没有吸筹评分(这只票没进过夜间扫描)") elif confirm_stale: - missing.append(f"吸筹评分陈旧 {accum_age} 日" if isinstance(accum_age, int) - else "吸筹评分日龄未知") + missing.append(f"吸筹评分是 {accum_age} 个交易日前的,太旧了" + if isinstance(accum_age, int) else "吸筹评分有多旧算不出来") elif confirm_negative and "派发" not in accum_state: state_short = accum_state.split("·")[0].strip() - missing.append(f"吸筹评分为「{state_short}」,未达确认线(不升格关注,归仅展示)") + missing.append(f"吸筹只到「{state_short}」,没到明确吸筹这条线") if not gates["pointed"]: - missing.append("无传导") + missing.append("所在环节当日没有被传导指向") # 研报论断的质量提示。放进缺失项是因为它和"吸筹评分陈旧"是同一类事:证据还在, # 但已经旧到不该当新证据用。两者此前一个会过期一个不会,卡上看着一样新, # 这个不对称本身会误导人。这里只提示不作门槛——门槛仍是那三条可交易口径。 @@ -156,24 +164,32 @@ def judge(ev: dict[str, Any], *, start_pct: float = 3.0, all_gates = all(gates.values()) failed = [k for k, ok in gates.items() if not ok] + # 判决依据是这张卡上唯一一句"为什么",PMS 原样显示在页面上给人读。所以它必须是 + # 一句完整的中文,带上具体数字,不许出现门槛的变量名(started 这类)或信号的 + # 枚举值(SELL 这类)——那些是系统之间传递用的,不是给人看的。 if risk: verdict = VERDICT_SHOW - basis = "硬风险否决:" + ";".join(risk) + basis = ";".join(risk) elif all_gates and confirm: verdict = VERDICT_CANDIDATE - basis = "三门槛全过、无硬风险、明确吸筹且评分新鲜" + basis = (f"三条门槛都过了,没有风险,且明确吸筹({accum_age} 个交易日前评的)" + if isinstance(accum_age, int) else "三条门槛都过了,没有风险,且明确吸筹") elif all_gates and (confirm_missing or confirm_stale): verdict = VERDICT_WATCH - basis = ("三门槛全过、无硬风险,但确认线" - + ("缺失(无吸筹评分)" if confirm_missing else "陈旧(明确吸筹但评分日龄超限或未知)") - + "——系统无法判断,交人裁决") + why = ("这只票还没有吸筹评分" if confirm_missing + else (f"吸筹评分是 {accum_age} 个交易日前的,太旧了" + if isinstance(accum_age, int) else "吸筹评分有多旧算不出来")) + basis = f"三条门槛都过了,也没有风险,但{why},系统判断不了,交人裁决" elif all_gates: verdict = VERDICT_SHOW failed.append("confirm") - basis = f"三门槛全过,但吸筹评分为「{accum_state.split('·')[0].strip()}」,未达确认线,不升格关注" + basis = (f"三条门槛都过了,但吸筹只到「{accum_state.split('·')[0].strip()}」," + f"没到明确吸筹这条线") else: verdict = VERDICT_SHOW - basis = "门槛未过:" + "、".join(failed) + basis = ";".join(gate_reasons(failed, pct0=pct0, start_pct=start_pct, + covered=bool(ev.get("covered")), upside=upside, + neg_tol=neg_tol)) return {"verdict": verdict, "reasons": reasons, "missing": missing, "risk": risk, "gates": gates, "confirm": confirm, "failed_gates": failed, "basis": basis, @@ -246,3 +262,35 @@ def logic_notes(claims, stale_days: int = 90) -> list[str]: if q.get("n_disputed"): out.append(f"其中 {q['n_disputed']} 条处于未结的方向分歧") return out + + +def gate_reasons(failed, *, pct0=None, start_pct: float = 3.0, covered: bool = False, + upside=None, neg_tol: float = 0.0) -> list[str]: + """把没过的门槛翻成一句句人话,带上当时的实际数字。 + + 门槛在代码里的名字是 pointed、started、covered,那是给程序读的。写进给人看的 + 句子必须换成人话,并且带上数字——「当日跌 3.2%,没到 3% 的启动线」比「涨幅门槛 + 未过」有用得多,因为前者能让人自己判断差多少。 + + 认不出的门槛名原样带上而不是吞掉:真出现了新门槛,宁可页面上难看一次, + 也好过一句话里少了一条原因。 + """ + out: list[str] = [] + for k in failed or []: + if k == "pointed": + out.append("所在环节当日没有被传导指向") + elif k == "started": + out.append(f"当日涨幅 {pct0:+.1f}%,没到 {start_pct:g}% 的启动线" + if pct0 is not None else "没有当日行情,算不出涨幅") + elif k == "covered": + if not covered: + out.append("没有券商覆盖") + elif upside is not None: + out.append(f"券商预期空间 {upside:+.0%},低于 {neg_tol:+.0%} 的容忍线") + else: + out.append("算不出券商预期空间") + elif k == "confirm": + out.append("吸筹没到明确吸筹这条线") + else: + out.append(f"门槛 {k} 未过") + return out diff --git a/test_card.py b/test_card.py index 541a80e..dcbcb8a 100644 --- a/test_card.py +++ b/test_card.py @@ -33,49 +33,57 @@ def main(): # ---- 关注只剩两种:确认线缺失(无评分)、确认线陈旧(明确吸筹但日龄超限或未知)---- r = card.judge({**BASE, "accum_state": ""}) - t("缺评分 -> 仍关注且缺失文案", r["verdict"] == "关注" and "无吸筹评分(未入池)" in r["missing"]) - t("关注的判决依据写明无法判断、交人裁决", "无法判断" in r["basis"] and "交人裁决" in r["basis"]) + t("缺评分 -> 仍关注且缺失文案", r["verdict"] == "关注" and "还没有吸筹评分" in r["missing"][0]) + 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"])) + 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"])) + 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"])) + # 「不升格关注、归仅展示」是设计规则,不是给人看的话,2026-09-04 从文案里去掉了。 + # 所以这里钉的是判决本身与缺失项的实质内容,不再钉那句解释规则的话。 + t("只差覆盖 -> 仅展示,缺失项写明没有券商覆盖", + r["verdict"] == "仅展示" and "covered" in r["failed_gates"] + and "没有券商覆盖" 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"]) + 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"])) + and any("预期空间 -5%" in m and "容忍线" 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"]) + t("未启动 -> 仅展示,判决依据带上实际涨幅与门槛值", + r["verdict"] == "仅展示" and "started" in r["failed_gates"] + and "当日涨幅 +1.2%" in r["basis"] and "3% 的启动线" in r["basis"]) r = card.judge({**BASE, "pointed": False}) - t("未被指向 -> 仅展示且标无传导", r["verdict"] == "仅展示" and "无传导" in r["missing"]) + 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"]) + t("昨夜卖出信号 -> 仅展示,信号代号翻成中文", + r["verdict"] == "仅展示" and r["risk"] + and r["basis"] == "择时决策系统昨夜给出卖出信号") r = card.judge({**BASE, "stale_snapshot": True}) - t("快照日不符 -> 硬风险", r["verdict"] == "仅展示" and "传导快照日与计划日不符" in r["risk"]) + t("快照日不符 -> 硬风险", r["verdict"] == "仅展示" and "用的传导快照不是计划当天的" in r["risk"]) r = card.judge({**BASE, "accum_state": "⚠️ 高位派发"}) - t("高位派发 -> 硬风险,且不重复写进缺失", r["verdict"] == "仅展示" and "高位派发" in r["risk"] + t("高位派发 -> 硬风险,且不重复写进缺失", r["verdict"] == "仅展示" and "资金在高位派发" in r["risk"] and not any("派发" in m for m in r["missing"])) r = card.judge({**BASE, "risk_name": True}) @@ -153,6 +161,45 @@ def main(): 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) + # ---- 给人看的文字里不许出现代码名(2026-09-04)---- + # 起因:页面上出现过「选股系统判为仅展示,不出提议:门槛未过:started」。 + # started 是门槛在代码里的变量名,SELL 是决策系统的枚举值,两者都只该在系统之间 + # 传递。判决依据、缺失项、风险这三个字段会被 PMS 原样显示给人读,所以它们里面 + # 一个代码名都不许有。failed_gates 不在此列——那是给程序判断用的机器字段。 + CODE_WORDS = ("started", "pointed", "covered", "confirm", + "SELL", "AVOID", "DROPPED", "BUY", "WATCH", "HOLD") + probes = [ + dict(BASE), # 候选 + {**BASE, "accum_state": ""}, # 关注·没有评分 + {**BASE, "accum_age": 99}, # 关注·评分太旧 + {**BASE, "accum_age": None}, # 关注·日龄算不出 + {**BASE, "accum_state": "潜在吸筹·低位企稳"}, # 仅展示·没到确认线 + {**BASE, "pct0": 1.2}, # 仅展示·没启动 + {**BASE, "pct0": None}, # 仅展示·没有行情 + {**BASE, "pointed": False, "theme": None, "n_sources": None}, # 仅展示·没传导 + {**BASE, "covered": False, "upside": None}, # 仅展示·没覆盖 + {**BASE, "upside": -0.05}, # 仅展示·预期空间为负 + {**BASE, "y_signal": "SELL"}, # 仅展示·昨夜卖出信号 + {**BASE, "y_signal": "AVOID"}, # 仅展示·昨夜回避信号 + {**BASE, "stale_snapshot": True}, # 仅展示·快照日不符 + {**BASE, "accum_state": "高位派发"}, # 仅展示·资金派发 + {**BASE, "pointed": False, "pct0": None, "covered": False, + "upside": None, "accum_state": ""}, # 全都不满足 + ] + for ev in probes: + r = card.judge(ev) + texts = [r["basis"], *r["missing"], *r["risk"], *r["reasons"]] + for txt in texts: + for w in CODE_WORDS: + assert w not in txt, f"给人看的文字里出现了代码名 {w}:{txt}" + assert r["basis"] and len(r["basis"]) >= 6, f"判决依据太短,说不清原因:{r['basis']!r}" + t(f"给人看的三个字段里没有代码名({len(probes)} 种情形逐个扫过)", True) + t("门槛翻译带上实际数字", + card.gate_reasons(["started"], pct0=-3.21, start_pct=3.0)[0] + == "当日涨幅 -3.2%,没到 3% 的启动线") + t("认不出的门槛名原样带上,不吞掉", + card.gate_reasons(["新门槛"]) == ["门槛 新门槛 未过"]) + t("坏信号集合口径", card.BAD_SIGNALS == {"SELL", "AVOID", "DROPPED"}) print("ALL OK — 候选卡判决 / 关注两种(缺失、陈旧)/ 只差覆盖与潜在吸筹归仅展示 / 硬风险三种 / " "因果论断只展示 / 论断质量标注四项 / 缺失文案与依据 / 旋钮 / 卡内序 全部通过")