akg-factor-bridge/card.py

249 lines
13 KiB
Python
Raw Normal View History

"""候选卡:把一只票的各条证据线装配成判决与理由(纯函数,零 IO
## 为什么要有它
桥原来的产物是一个分数主榜分 = 200 + 传导档位×20 + 组内分分数排在最前的
强传导档实证里过去一个月是四档中最差的五日超额 2.04
docs/主观选股改进方案_2026-09-02.md 1.8b 方案把桥从"打分排序器"改成
"候选卡装配器"分数与档位一律不动另出一份带理由的候选单规则只有三条硬门槛
一条确认三条硬风险全部是一个主观选股的人会用的判断不算总分
门槛一 所在环节当日被传导指向只认环节类目标
门槛二 成员当日已启动数据日涨幅达到 CARD_START_PCT默认 3%与基座热点扫描同口径
门槛三 有券商覆盖预期空间不低于负容忍线 ST 与退市族
确认 决策系统吸筹评分为"明确吸筹"且评分日龄不超过 CARD_ACCUM_MAX_AGE 个交易日
硬风险 决策系统昨夜给出卖出回避或剔除信号传导快照日与计划日不符吸筹评分为高位派发
判决 候选 = 三门槛全过硬风险为空确认成立
关注 = 三门槛全过硬风险为空但确认线缺失没有吸筹评分或陈旧明确吸筹但评分
日龄超过上限或日龄未知这是"系统无法判断"交人裁决
仅展示 = 其余卡上标明未过项只差券商覆盖吸筹评分为潜在吸筹或其他非明确状态
都归这一档2026-09-03 台账 013潜在吸筹市值中性后为负不升格
依据方案 1.8d"环节被指向、当日已涨 3% 以上、且明确吸筹"是纯可交易口径下唯一
合并样本为正的规则五日 +2.18十日 +1.94但它是顺风策略不免疫环境两个旋钮
保持默认等样本外复盘读数再定不在历史样本上挑参数2026-09-02 决定
关注定义的细化出自主观量化系统方案_2026-09-03 3.2 "无法判断"的三个出口
候选自动进建仓方案关注强制人工确认仅展示不出提议
## 因果论断证据线2026-09-03
证据字典可带 logic数据基座因果论断视图给出的论断列表方向机制时效出处文档标题与
披露日论断编号judge 把它整理成带出处的一行行文字放进输出的 logic 只展示
不作门槛不进判决分析结论到选股的断裂点先接通要不要当门槛等复盘案例再定
## 边界
本模块不读库不读配置只吃调用方装配好的证据字典输出判决字典这样它能
离线单测test_card.py也保证同一段规则在计划装配复盘脚本对账工具里只有一份
坏信号集合 BAD_SIGNALS "三处同源"纪律里选股系统的那一份另两处在择时决策系统
pms_advisor.py PMS rule_gate.py改一处必须三处同改pool.py 从这里引用本仓库只此一处
"""
from __future__ import annotations
from typing import Any
# 决策系统夜间结论表 signal_type 里的坏信号(三处同源,见模块说明)
BAD_SIGNALS = {"SELL", "AVOID", "DROPPED"}
VERDICT_CANDIDATE = "候选"
VERDICT_WATCH = "关注"
VERDICT_SHOW = "仅展示"
_VERDICT_ORDER = {VERDICT_CANDIDATE: 0, VERDICT_WATCH: 1, VERDICT_SHOW: 2}
def _num(v) -> float | None:
if v is None:
return None
try:
x = float(v)
except (TypeError, ValueError):
return None
if x != x: # NaN
return None
return x
def judge(ev: dict[str, Any], *, start_pct: float = 3.0,
2026-09-04 08:59:11 +08:00
accum_max_age: int = 30, neg_tol: float = 0.0,
logic_stale_days: int = 90) -> dict[str, Any]:
"""一只票的证据字典 -> 判决字典。
ev 里认的键缺键按缺失处理不报错
pointed bool 所在环节当日被传导指向只认 Segment 目标
theme str 目标环节名n_sources int 源数chain_fit 链符展示用
pct0 float 数据日涨幅百分数3.2 表示 +3.2%
covered bool 有券商覆盖upside float 预期空间比例0.25 表示 +25%
risk_name bool 证券简称命中 ST / *ST / 退市族
accum_state str 吸筹状态文案决策系统 fund_flow.stateaccum_score float
accum_age int 评分日龄交易日
y_signal str 决策系统昨夜 signal_type
stale_snapshot bool 传导快照日与计划日不符
logic list 数据基座因果论断列表每条是字典direction / mechanism / horizon /
doc_title / disclosure_date / claim_id 只展示不进判决
返回verdict / reasons / missing / risk / gates / confirm / failed_gates / basis / logic
basis 一句话判决依据说明落到这一档的原因关注即"系统无法判断"交人裁决
logic 因果论断整理成的带出处文字行与判决无关
2026-09-04 08:59:11 +08:00
logic_quality 论断的质量画像条数最新披露日距今天数几份来源疑似误抽与
分歧条数按环节展开的条数取数层在截断前算好带上来与判决无关
failed_gates 是未过的门槛名三门槛全过但吸筹评分为非明确状态而落仅展示时附加 "confirm"
"""
pct0 = _num(ev.get("pct0"))
upside = _num(ev.get("upside"))
accum_score = _num(ev.get("accum_score"))
accum_age = ev.get("accum_age")
accum_state = str(ev.get("accum_state") or "")
y_signal = str(ev.get("y_signal") or "").strip().upper()
gates = {
"pointed": bool(ev.get("pointed")),
"started": pct0 is not None and pct0 >= float(start_pct),
"covered": bool(ev.get("covered")) and upside is not None
and upside >= -float(neg_tol or 0.0),
"clean_name": not bool(ev.get("risk_name")),
}
accum_fresh = (isinstance(accum_age, int) and 0 <= accum_age <= int(accum_max_age))
confirm = accum_state.startswith("明确") and accum_fresh
# 确认线的三种"不成立"要分开:缺失(没有评分)与陈旧(明确吸筹但日龄超限或未知)是
# "系统无法判断";评分为潜在吸筹、信号不明、无吸筹等非明确状态,是系统已经判断过、
# 只是没有达到确认线,不升格(台账 013
confirm_missing = not accum_state
confirm_stale = accum_state.startswith("明确") and not accum_fresh
confirm_negative = bool(accum_state) and not accum_state.startswith("明确")
risk: list[str] = []
if y_signal in BAD_SIGNALS:
risk.append(f"决策系统昨夜信号 {y_signal}")
if ev.get("stale_snapshot"):
risk.append("传导快照日与计划日不符")
if "派发" in accum_state:
risk.append("高位派发")
missing: list[str] = []
if pct0 is None:
missing.append("无行情")
if not ev.get("covered"):
missing.append("无券商覆盖(不升格关注,归仅展示)")
elif not gates["covered"]:
missing.append(f"预期空间 {upside:+.0%} 低于负容忍线" if upside is not None else "预期空间缺失")
if confirm_missing:
missing.append("无吸筹评分(未入池)")
elif confirm_stale:
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}」,未达确认线(不升格关注,归仅展示)")
if not gates["pointed"]:
missing.append("无传导")
2026-09-04 08:59:11 +08:00
# 研报论断的质量提示。放进缺失项是因为它和"吸筹评分陈旧"是同一类事:证据还在,
# 但已经旧到不该当新证据用。两者此前一个会过期一个不会,卡上看着一样新,
# 这个不对称本身会误导人。这里只提示不作门槛——门槛仍是那三条可交易口径。
missing.extend(logic_notes(ev.get("logic"), logic_stale_days))
reasons: list[str] = []
if gates["pointed"]:
theme = ev.get("theme") or "环节"
n = ev.get("n_sources")
reasons.append(f"所在环节「{theme}」被{n}路指向" if n else f"所在环节「{theme}」被指向")
if gates["started"]:
reasons.append(f"当日已启动 {pct0:+.1f}%")
if confirm:
reasons.append(f"明确吸筹(评分 {accum_score:.0f}{accum_age} 日前)"
if accum_score is not None else f"明确吸筹({accum_age} 日前)")
if gates["covered"]:
reasons.append(f"券商覆盖,预期空间 {upside:+.0%}")
all_gates = all(gates.values())
failed = [k for k, ok in gates.items() if not ok]
if risk:
verdict = VERDICT_SHOW
basis = "硬风险否决:" + "".join(risk)
elif all_gates and confirm:
verdict = VERDICT_CANDIDATE
basis = "三门槛全过、无硬风险、明确吸筹且评分新鲜"
elif all_gates and (confirm_missing or confirm_stale):
verdict = VERDICT_WATCH
basis = ("三门槛全过、无硬风险,但确认线"
+ ("缺失(无吸筹评分)" if confirm_missing else "陈旧(明确吸筹但评分日龄超限或未知)")
+ "——系统无法判断,交人裁决")
elif all_gates:
verdict = VERDICT_SHOW
failed.append("confirm")
basis = f"三门槛全过,但吸筹评分为「{accum_state.split('·')[0].strip()}」,未达确认线,不升格关注"
else:
verdict = VERDICT_SHOW
basis = "门槛未过:" + "".join(failed)
return {"verdict": verdict, "reasons": reasons, "missing": missing, "risk": risk,
"gates": gates, "confirm": confirm, "failed_gates": failed, "basis": basis,
2026-09-04 08:59:11 +08:00
"logic": logic_lines(ev.get("logic")),
"logic_quality": logic_quality(ev.get("logic"))}
def logic_lines(claims, limit: int = 3) -> list[str]:
"""把因果论断列表整理成带出处的文字行:方向、机制、时效,加"《文档标题》披露日·论断编号"
只展示不进判决缺字段的部分省略不报错"""
out: list[str] = []
for c in (claims or [])[:limit]:
if not isinstance(c, dict):
continue
head = "".join(x for x in (c.get("direction"), c.get("mechanism")) if x)
if c.get("horizon"):
head = f"{head}{c['horizon']}" if head else f"{c['horizon']}"
if c.get("condition"):
head = f"{head},条件:{c['condition']}"
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)
line = head or "因果论断"
if src:
line = f"{line}——出处:{src.strip()}"
out.append(line)
return out
def sort_key(row: dict[str, Any]) -> tuple:
"""卡内序:先判决(候选 < 关注 < 仅展示),再按数据日涨幅降序。不算总分。"""
v = _VERDICT_ORDER.get(row.get("verdict"), 9)
pct0 = _num(row.get("pct0"))
return (v, -(pct0 if pct0 is not None else -1e9))
2026-09-04 08:59:11 +08:00
def logic_quality(claims) -> dict | None:
"""把取数层算好的质量画像取出来。取数层在截断之前算,所以它统计的是这票的
全部论断而卡上只展示前几条两个数不一样是对的论断为空时返回 None
"上游没给这个字段"同形卡上都不显示这一节"""
for c in (claims or []):
if isinstance(c, dict) and isinstance(c.get("quality"), dict):
return c["quality"]
return None
def logic_notes(claims, stale_days: int = 90) -> list[str]:
"""论断质量的提示文字,只提示不作门槛。四条,都以实际读数为准,凑不齐就少写几条:
陈旧最新一条论断距数据日超过 stale_days
单一来源这票有多条论断但全部出自同一份研报那份研报的观点一旦过时或本身
有偏这票的整条研究证据一起失效而分开看每一条都像是独立的证据
疑似误抽逻辑评析读过之后标出来的模型的判断不是确定性事实所以只提示
未结分歧同期方向相反还没有结论的表示市场没有共识
"""
q = logic_quality(claims)
if not q or not q.get("n"):
return []
out: list[str] = []
age = q.get("age_days")
if isinstance(age, int) and age > int(stale_days):
latest = q.get("latest_date")
out.append(f"研报论断已过 {age} 天(最新一条 {latest}" if latest
else f"研报论断已过 {age}")
if q["n"] > 1 and q.get("n_docs") == 1:
out.append(f"{q['n']} 条论断全部出自同一份研报")
if q.get("n_flagged"):
out.append(f"其中 {q['n_flagged']} 条被标为疑似误抽")
if q.get("n_disputed"):
out.append(f"其中 {q['n_disputed']} 条处于未结的方向分歧")
return out