候选卡文案第一批:预期空间、传导那句话、论断出处、ST 门槛

四条都出自 09-04 的文案扫描,这四个字段被 PMS 原样显示给人读。

一,预期空间原先写「券商预期空间 -5%,低于 +0% 的容忍线」。两处毛病:默认配置下
容忍线就是 0,说「低于 0% 的容忍线」等于没说;两个数一个带正号一个带负号、方向
相反,人要在脑子里翻一次才比得了大小。改成同向同单位:「券商给的目标价比现价低
5%,目标价低于现价的不买」。两处同源的写法抽成一个函数,口径不再分叉。

二,传导那句原先写「所在环节「减速器」被 4 路指向」。「被 4 路指向」不是人话。
改成「这只票在「减速器」环节,今天有 4 个上游来源传导到这个环节」。

三,论断出处原先在行尾挂一个 36 位的内部编号,占掉半行且对读的人没有任何信息。
去掉它,换成距今多少天——旧不旧交给人自己判。追溯用的编号仍在论断原值里,
计划的那一支照旧带着。

四,ST 那道门槛没有对应文案,掉进兜底后页面上会出现「门槛 clean_name 未过」。
补专属文案,并把兜底改写成「还有一条买入条件没通过,暂时没写对应的说明(内部代号
xxx)」——内部代号退到括号里,句子主干永远是中文。

守卫扩到四个人话字段(判决依据、理由、缺失、风险),此前只管住判决依据一个。
另加一条:每句话至少六个字,半句话的电报体也算不合格。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-04 13:17:54 +08:00
parent 5de97173d1
commit 0a70082405
2 changed files with 52 additions and 11 deletions

37
card.py
View File

@ -135,8 +135,7 @@ def judge(ev: dict[str, Any], *, start_pct: float = 3.0,
if not ev.get("covered"):
missing.append("没有券商覆盖")
elif not gates["covered"]:
missing.append(f"券商预期空间 {upside:+.0%},低于 {neg_tol:+.0%} 的容忍线"
if upside is not None else "算不出券商预期空间")
missing.append(upside_text(upside, neg_tol))
if confirm_missing:
missing.append("还没有吸筹评分(这只票没进过夜间扫描)")
elif confirm_stale:
@ -156,7 +155,8 @@ def judge(ev: dict[str, Any], *, start_pct: float = 3.0,
if gates["pointed"]:
theme = ev.get("theme") or "环节"
n = ev.get("n_sources")
reasons.append(f"所在环节「{theme}」被{n}路指向" if n else f"所在环节「{theme}」被指向")
reasons.append(f"这只票在「{theme}」环节,今天有 {n} 个上游来源传导到这个环节"
if n else f"这只票在「{theme}」环节,今天有上游传导过来")
if gates["started"] and require_started:
reasons.append(f"当日已启动 {pct0:+.1f}%")
elif not require_started and pct0 is not None:
@ -215,10 +215,15 @@ def logic_lines(claims, limit: int = 3) -> list[str]:
head = f"{head}{c['horizon']}" if head else f"{c['horizon']}"
if c.get("condition"):
head = f"{head},条件:{c['condition']}"
# 出处只写研报名、披露日、距今多少天。论断编号是 36 位的内部标识,
# 原先挂在行尾占掉半行、对读的人没有任何信息;追溯用的编号仍在论断原值里,
# 计划的 card.logic_claims 那一支照旧带着,只是不进给人读的这一行。
# 距今天数取取数层算好的质量画像——旧不旧交给人自己判,系统不替他判。
age = (c.get("quality") or {}).get("age_days")
src = "".join(x for x in (
f"{c['doc_title']}" if c.get("doc_title") else "",
f" {c['disclosure_date']}" if c.get("disclosure_date") else "",
f" · {c['claim_id']}" if c.get("claim_id") else "") if x)
f"(距今 {age} 天)" if isinstance(age, int) else "") if x)
line = head or "因果论断"
if src:
line = f"{line}——出处:{src.strip()}"
@ -292,11 +297,31 @@ def gate_reasons(failed, *, pct0=None, start_pct: float = 3.0, covered: bool = F
if not covered:
out.append("没有券商覆盖")
elif upside is not None:
out.append(f"券商预期空间 {upside:+.0%},低于 {neg_tol:+.0%} 的容忍线")
out.append(upside_text(upside, neg_tol))
else:
out.append("算不出券商预期空间")
elif k == "confirm":
out.append("吸筹没到明确吸筹这条线")
elif k == "clean_name":
out.append("简称带 ST含 *ST 与退市整理),这类票一律不买")
else:
out.append(f"门槛 {k} 未过")
# 兜底要让人看出"这是系统没写说明",而不是让人去猜一个英文词是什么意思。
# 内部代号退到括号里,句子主干仍是中文。
out.append(f"还有一条买入条件没通过,暂时没写对应的说明(内部代号 {k}")
return out
def upside_text(upside, neg_tol: float = 0.0) -> str:
"""券商预期空间没过门槛时的那句话。两处用到,口径必须一样。
不写成"预期空间 -5% 低于 +0% 的容忍线"有两个原因一是默认配置下容忍线就是 0
"低于 0% 的容忍线"等于没说二是两个数一个带正号一个带负号方向相反
人要在脑子里翻一次才比得了大小改成同向同单位券商目标价比现价低多少
我们最多接受低多少
"""
if upside is None:
return "算不出券商预期空间(券商没给目标价,或现价取不到)"
gap = -float(upside)
if float(neg_tol) > 0:
return f"券商给的目标价比现价低 {gap:.0%},最多只接受低 {float(neg_tol):.0%}"
return f"券商给的目标价比现价低 {gap:.0%},目标价低于现价的不买"

View File

@ -63,7 +63,7 @@ def main():
r = card.judge({**BASE, "upside": -0.05})
t("负容忍默认 0轻微为负不过覆盖门槛 -> 仅展示", r["verdict"] == "仅展示" and "covered" in r["failed_gates"]
and any("预期空间 -5%" in m and "容忍线" in m for m in r["missing"]))
and any("目标价比现价低 5%" in m for m in r["missing"]))
# ---- 其余仅展示与硬风险 ----
r = card.judge({**BASE, "pct0": 1.2})
@ -92,7 +92,12 @@ def main():
# ---- 因果论断:只展示,不改判决 ----
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])
and "《散热行业深度》" in r["logic"][0] and "2026-08-20" in r["logic"][0])
t("论断出处不再挂 36 位的内部编号(占半行且对读的人没有信息)",
"clm_001" not in r["logic"][0])
r2 = card.judge({**BASE, "logic": [{**CLAIM, "quality": {"age_days": 137}}]})
t("有质量画像时出处带上距今多少天,旧不旧交人自己判",
"(距今 137 天)" in r2["logic"][0])
t("论断不进理由", len(r["reasons"]) == 4)
r = card.judge({**BASE, "pct0": 1.2, "logic": [CLAIM]})
t("论断不能把未启动拉成候选", r["verdict"] == "仅展示" and r["logic"])
@ -215,19 +220,30 @@ def main():
{**BASE, "pointed": False, "pct0": None, "covered": False,
"upside": None, "accum_state": ""}, # 全都不满足
]
# 四个人话字段一视同仁:判决依据、理由、缺失、风险。这四个都被 PMS 原样显示,
# 纪律必须一样,不能只管住判决依据一个。
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}"
# 半句话也不行:每条都要能单独读懂,不能是「被 3 路指向」这种电报体。
assert len(txt) >= 6, f"这句话太短,单独读不懂:{txt!r}"
assert r["basis"] and len(r["basis"]) >= 6, f"判决依据太短,说不清原因:{r['basis']!r}"
t(f"给人看的三个字段里没有代码名({len(probes)} 种情形逐个扫过)", True)
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.gate_reasons(["新门槛"]) == ["还有一条买入条件没通过,暂时没写对应的说明(内部代号 新门槛)"])
t("ST 那道门槛有专属文案,不掉进兜底",
card.gate_reasons(["clean_name"]) == ["简称带 ST含 *ST 与退市整理),这类票一律不买"])
t("预期空间:默认容忍线为 0 时不说「低于 0% 的容忍线」这种废话",
card.upside_text(-0.05, 0.0) == "券商给的目标价比现价低 5%,目标价低于现价的不买")
t("预期空间:容忍线非 0 时两个数同向同单位,一眼能比大小",
card.upside_text(-0.05, 0.10) == "券商给的目标价比现价低 5%,最多只接受低 10%")
t("预期空间算不出时说清为什么", "券商没给目标价" in card.upside_text(None))
t("坏信号集合口径", card.BAD_SIGNALS == {"SELL", "AVOID", "DROPPED"})
print("ALL OK — 候选卡判决 / 关注两种(缺失、陈旧)/ 只差覆盖与潜在吸筹归仅展示 / 硬风险三种 / "