akg-factor-bridge/logic_state.py

433 lines
24 KiB
Python
Raw Normal View History

逻辑状态四态:合成模块与离线单测(只写不接) 判断的不是"这条产业逻辑对不对"——那是研究员的活;只判断支撑它的证据还在不在。 四路证据各自归一成一个信号(向好、平稳、转弱、缺失,另加分歧表示这一路自己内部 方向相反):甲路研报论断(方向受控必填,两个窗口——新材料窗口决定向好、陈旧线决定 还算不算数)、乙路产业研判(判采信倾向这个受控四值的迁移,不判自由文本)、丙路券商 行动(等长窗口的每股收益预测中位数与覆盖机构数)、丁路公司事件(恒定缺失,方向字段 九成五为空,照样出一个缺失让卡上看得见缺的是它)。 合成四条规则都落成了断言:每路必须带截止日(没有就降为缺失)、负向优先(转弱最多到 无法判断,只有两条路径允许落到逻辑存疑)、缺失既不算负面也不算正面(剔出合成但记名)、 慢信号不能单独定态。另有进出不对称:进入逻辑存疑即刻成立,退出要连续三个计划日不再 存疑;漏判一次证据走弱的代价比多留一次人工复核大得多。 作用于候选卡是单调的:逻辑强化只能提前卡内序、永远不升判决;逻辑存疑只能改分流通道、 不能把仅展示变成可执行。唯一的新语义是候选加逻辑存疑——可交易口径成立而研究证据在 走弱,正是杀逻辑与杀估值那条边界,处置是强制人工确认。 本次只写不接进计划装配:乙路依赖行业观点快照攒够几天才有迁移可判。 测试 59 例通过,不连库。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:09:15 +08:00
"""逻辑状态四态:判断支撑一只票的研究证据还在不在(纯函数,不连库、不读配置)。
## 它回答什么,不回答什么
它不判断"这条产业逻辑对不对"那是研究员的活系统做不了也不该做
它只看支撑逻辑的证据还在不在有没有在走弱预测在不在下修覆盖在不在收缩
有没有出现方向相反的论断产业研判的采信倾向有没有翻过来
与候选卡的三态是正交的两维不合并候选卡回答"今天要不要买"是当日无状态重算的
入场判决这里回答"支撑它的研究证据还在不在"是跨日的持有期跟踪两者不能合并还有
一个硬理由持仓票在候选筛选里被整行剔掉且剔在判决检查之前所以候选卡三态对持仓
天然无效而持仓恰恰是逻辑四态唯一真正有用的地方
## 四路证据
甲路 研报论断 方向受控必填利好利空中性三选一缺方向的断言落库时直接丢弃
行业级环节级公司级三种粒度都有随研报入库即时中速
乙路 产业研判 主题级判据用采信倾向这个四选一的受控枚举偏多偏空中性证据
不足的迁移不用那段自由文本"上修还是分化"会退化成中文匹配慢速
丙路 券商行动 个股级周度同财年同预测期的每股收益预测中位数变化与覆盖机构数变化
看的是券商的行动不是言辞券商极少明说不看好某个行业中速
丁路 公司事件 目前不可用全库事件断言里九成五的方向字段为空有值的也是动作词
减持增持预增不是利好利空的语义判断这一路照样出一个"缺失"
让卡上看得见缺的是它而不是静悄悄地不存在
## 每路先归一成一个信号
向好平稳转弱缺失另加一个分歧分歧是"这一路自己内部方向相反"设计里把它
单列为证据矛盾的一个来源所以它不能被压进那四个里的任何一个
一条硬要求每路必须带自己的截止日没有截止日的证据一律按缺失处理因为"这条证据
是哪天的"决定了它还算不算数,不知道就不能采信。
## 合成的四条规则
每路各自归一各带截止日与覆盖计数
负向优先任何一路转弱整体最多到无法判断只有两条路径允许整体落到逻辑存疑
DOUBT_TRIGGERS
缺失既不是负面也不是正面证据从合成里剔除并记明缺哪一路缺省态永远是
无法判断加证据不足从来没有行业观点券商也算不出来的票不算逻辑成立
慢信号定底色中速定当前态日度事件只作一次性复核触发不能单独定态抖动不靠
平滑系数靠三件事等长窗口本身就是低通状态迁移要求连续若干计划日同向
退出负态比进入负态慢settle
## 阈值
全部由调用方传入一次定死每个在台账里写明与哪个既有口径同源绝不按复盘收益读数
回调那是在历史样本上挑参数本模块只给默认值不读配置文件
离线单测见 test_logic_state.py不连库
"""
from __future__ import annotations
import datetime as dt
# ---- 四态 ----
STATE_STRONG = "逻辑强化"
STATE_HOLD = "逻辑成立"
STATE_UNKNOWN = "无法判断"
STATE_DOUBT = "逻辑存疑"
# 无法判断的两个子因,动作不同:证据矛盾进人工复核清单(提示"需要裁决"并列出矛盾两路的
# 日期与出处);证据不足不进清单,只在卡上标注缺哪一路(提示"需要补材料")。
WHY_THIN = "证据不足"
WHY_CONFLICT = "证据矛盾"
# ---- 每路的信号 ----
SIG_UP = "向好"
SIG_FLAT = "平稳"
SIG_DOWN = "转弱"
SIG_NONE = "缺失"
SIG_SPLIT = "分歧"
# ---- 四路 ----
PATH_CLAIM = "研报论断"
PATH_JUDGE = "产业研判"
PATH_BROKER = "券商行动"
PATH_EVENT = "公司事件"
# 每路的节奏,合成规则四要用。慢信号不能单独定态。
SPEED = {PATH_JUDGE: "", PATH_CLAIM: "", PATH_BROKER: "", PATH_EVENT: ""}
# 只有这两条路径的转弱允许整体落到逻辑存疑,别的转弱最多到无法判断。
DOUBT_TRIGGERS = {
PATH_BROKER: "每股收益预测下修且覆盖机构收缩,两个条件同时满足",
PATH_CLAIM: "论断方向跨期翻转(先前利好、最新一条转利空)",
PATH_JUDGE: "采信倾向从偏多迁移到偏空,且材料指纹变过",
}
# 采信倾向的受控四值(数据基座抽取端的提示词写死的,见 backend/workers/tasks.py
LEAN_BULL = "偏多"
LEAN_BEAR = "偏空"
LEAN_NEUTRAL = "中性"
LEAN_THIN = "证据不足"
def signal(path: str, sig: str, *, as_of: str | None = None, coverage: int = 0,
why: str = "", hard: bool = False, refs=None) -> dict:
"""一路证据归一后的形状。
hard 表示这一路的转弱达到了逻辑存疑的进入条件 DOUBT_TRIGGERS转弱而 hard
为假整体最多到无法判断没有截止日的证据一律降为缺失不知道是哪天的证据
就不知道它还算不算数
"""
if sig != SIG_NONE and not as_of:
return {"path": path, "signal": SIG_NONE, "as_of": None, "coverage": 0,
"why": "这一路没有截止日,不能采信", "hard": False, "refs": []}
return {"path": path, "signal": sig, "as_of": as_of, "coverage": int(coverage or 0),
"why": why, "hard": bool(hard and sig == SIG_DOWN), "refs": list(refs or [])}
# ============================================================================
# 甲路:研报因果论断
# ============================================================================
def from_claims(claims, ds: str, *, fresh_days: int = 30, stale_days: int = 90) -> dict:
"""把一只票的因果论断归一成一路信号。
两个窗口各管一件事
fresh_days 新材料窗口向好要求这个窗口里有新的利好论断强化说的是"变好了"
而不是"现在是好的"只有新材料才算变化默认 30 与候选卡确认线的
三十个交易日在同一量级这里取自然日因为披露日本来就是自然日
stale_days 还算数的上界全部论断都超过它这一路按缺失处理陈旧等同于没有
不是负面证据规则三默认 90 与候选卡的论断陈旧线同源
跨期方向翻转是近似判定按披露日排序最新一条转利空而在它之前的论断以利好为主
就算翻转真正的跨期改口判定在数据基座的融合层那里能对上同一对主客体这里只有
按票聚起来的论断对不上主客体所以这是个偏宽的近似它会把"讲的不是同一件事的
两条论断"也算成翻转。之所以仍然用它:翻转只是把状态压到逻辑存疑,而逻辑存疑的动作
是出减持提议强制交人裁决绝不自动卖误判的代价是多一次人工复核
"""
rows = [c for c in (claims or []) if isinstance(c, dict) and c.get("disclosure_date")]
if not rows:
return signal(PATH_CLAIM, SIG_NONE, why="没有研报论断")
rows.sort(key=lambda c: c["disclosure_date"])
latest = rows[-1]["disclosure_date"]
age = _gap(latest, ds)
if age is None:
return signal(PATH_CLAIM, SIG_NONE, why="论断的披露日认不出来")
if age > int(stale_days):
return signal(PATH_CLAIM, SIG_NONE, coverage=len(rows),
why=f"{len(rows)} 条论断最新一条已过 {age} 天,按缺失处理")
fresh = [c for c in rows if (_gap(c["disclosure_date"], ds) or 10 ** 6) <= int(fresh_days)]
up = [c for c in fresh if c.get("direction") == "利好"]
down = [c for c in fresh if c.get("direction") == "利空"]
refs = _refs(fresh or rows)
if up and down:
return signal(PATH_CLAIM, SIG_SPLIT, as_of=latest, coverage=len(fresh), refs=refs,
why=f"{fresh_days} 天内同时有 {len(up)} 条利好与 {len(down)} 条利空论断,方向分歧")
if down:
flipped = _flipped(rows)
why = (f"{fresh_days} 天内有 {len(down)} 条利空论断"
+ (",且在此之前的论断以利好为主,方向跨期翻转" if flipped else ""))
return signal(PATH_CLAIM, SIG_DOWN, as_of=latest, coverage=len(fresh),
why=why, hard=flipped, refs=refs)
if up:
return signal(PATH_CLAIM, SIG_UP, as_of=latest, coverage=len(fresh), refs=refs,
why=f"{fresh_days} 天内有 {len(up)} 条新的利好论断")
return signal(PATH_CLAIM, SIG_FLAT, as_of=latest, coverage=len(rows), refs=_refs(rows),
why=f"{len(rows)} 条论断都还在 {stale_days} 天之内,但 {fresh_days} 天内没有新材料")
def _flipped(rows_sorted) -> bool:
"""最新一条转利空,而在它之前(严格早于它的披露日)的论断以利好为主。"""
last = rows_sorted[-1]
if last.get("direction") != "利空":
return False
before = [c for c in rows_sorted if c["disclosure_date"] < last["disclosure_date"]]
if not before:
return False
return sum(1 for c in before if c.get("direction") == "利好") > \
sum(1 for c in before if c.get("direction") == "利空")
def _refs(rows, limit: int = 3) -> list:
out = []
for c in rows[-limit:][::-1]:
out.append({"date": c.get("disclosure_date"), "doc": c.get("doc_title"),
"id": c.get("claim_id"), "direction": c.get("direction"),
"text": c.get("mechanism")})
return out
# ============================================================================
# 乙路:产业研判
# ============================================================================
def from_judgement(cur, prev=None, *, stale_days: int = 60) -> dict:
"""把一个主题的产业研判快照归一成一路信号。cur 与 prev 是 judgement.build_rows 的行。
判的是采信倾向这个受控枚举的迁移不是它当前的取值理由是强化说的是"变好了"
一个一直偏多的主题每天都算向好的话强化会变得到处都是也就不再说明任何事
所以稳定偏多归平稳它是有效的非负面证据够撑住逻辑成立但撑不起逻辑强化
只有材料指纹变过才算一次迁移指纹没变而日子变老只累加陈旧天数超过 stale_days
按缺失处理因为产业研判是人工点按钮触发的长期不变说明没人重新看过它
不是"看过了没变"
"""
if not cur:
return signal(PATH_JUDGE, SIG_NONE, why="这个环节对不上任何产业研判主题")
as_of = _s(cur.get("review_date")) or _s(cur.get("plan_date"))
cov = int(cur.get("n_materials") or 0)
if cur.get("verified") == 0:
return signal(PATH_JUDGE, SIG_NONE, coverage=cov, why="产业研判的自我校验未通过")
lean = _s(cur.get("leaning"))
if lean == LEAN_THIN:
return signal(PATH_JUDGE, SIG_NONE, coverage=cov, why="产业研判自己给的是证据不足")
stale = int(cur.get("stale_days") or 0)
migrated = cur.get("migrated")
if migrated != 1 and stale > int(stale_days):
return signal(PATH_JUDGE, SIG_NONE, coverage=cov,
why=f"产业研判的材料已 {stale} 天没变过,按缺失处理")
prev_lean = _s(cur.get("leaning_prev")) or (_s(prev.get("leaning")) if prev else None)
refs = [{"theme": _s(cur.get("subject_name")) or _s(cur.get("cluster_key")),
"leaning": lean, "leaning_prev": prev_lean, "date": as_of,
"n_bull": cur.get("n_bull"), "n_bear": cur.get("n_bear")}]
if migrated == 1 and prev_lean and prev_lean != lean:
if prev_lean == LEAN_BULL and lean == LEAN_BEAR:
return signal(PATH_JUDGE, SIG_DOWN, as_of=as_of, coverage=cov, hard=True, refs=refs,
why="产业研判的采信倾向从偏多迁移到偏空")
if lean == LEAN_BEAR:
return signal(PATH_JUDGE, SIG_DOWN, as_of=as_of, coverage=cov, refs=refs,
why=f"产业研判的采信倾向从{prev_lean}迁移到偏空")
if lean == LEAN_BULL:
return signal(PATH_JUDGE, SIG_UP, as_of=as_of, coverage=cov, refs=refs,
why=f"产业研判的采信倾向从{prev_lean}迁移到偏多")
return signal(PATH_JUDGE, SIG_FLAT, as_of=as_of, coverage=cov, refs=refs,
why=f"产业研判的采信倾向从{prev_lean}迁移到{lean},不是多空方向的变化")
if lean == LEAN_BEAR:
return signal(PATH_JUDGE, SIG_DOWN, as_of=as_of, coverage=cov, refs=refs,
why="产业研判的采信倾向是偏空")
return signal(PATH_JUDGE, SIG_FLAT, as_of=as_of, coverage=cov, refs=refs,
why=f"产业研判的采信倾向是{lean},与上一版相同")
# ============================================================================
# 丙路:券商行动
# ============================================================================
def from_broker(now, prev, *, as_of: str | None = None, min_firms: int = 2,
eps_drop: float = 0.05, eps_rise: float = 0.05,
cover_drop: float = 0.30) -> dict:
"""把同一财年同一预测期的每股收益预测中位数与覆盖机构数,归一成一路信号。
now prev 是两个等长窗口的读数各是 {"eps": 中位数, "firms": 机构数}
两个窗口必须等长窗口不等长会让八成的票假显示覆盖收缩实测 135 天对近 45
907 只票误报等长本身也是抗抖动的低通见合成规则四
每股收益从正转负或从负转正时变化率没有意义单独标记为转亏或扭亏而不算百分比
转亏方向明确是坏消息但它不满足逻辑存疑那个双条件所以只是转弱不带 hard
"""
n0, n1 = int((prev or {}).get("firms") or 0), int((now or {}).get("firms") or 0)
if n0 < int(min_firms) or n1 < int(min_firms):
return signal(PATH_BROKER, SIG_NONE, coverage=min(n0, n1),
why=f"两个窗口里能算的机构不足 {min_firms} 家(前 {n0} 家、后 {n1} 家)")
e0, e1 = (prev or {}).get("eps"), (now or {}).get("eps")
if e0 is None or e1 is None:
return signal(PATH_BROKER, SIG_NONE, coverage=min(n0, n1), why="每股收益预测中位数缺失")
shrink = (n0 - n1) / n0 if n0 else 0.0
refs = [{"eps_prev": e0, "eps_now": e1, "firms_prev": n0, "firms_now": n1, "date": as_of}]
if (e0 > 0) != (e1 > 0):
turned = e1 <= 0
return signal(PATH_BROKER, SIG_DOWN if turned else SIG_UP, as_of=as_of,
coverage=n1, refs=refs,
why=("每股收益预测由盈转亏,变化率不适用" if turned
else "每股收益预测由亏转盈,变化率不适用"))
chg = (e1 - e0) / abs(e0) if e0 else 0.0
if chg <= -float(eps_drop):
hard = shrink > float(cover_drop)
why = f"每股收益预测中位数下修 {abs(chg):.0%}"
if hard:
why += f",同时覆盖机构从 {n0} 家收缩到 {n1} 家(减 {shrink:.0%}"
return signal(PATH_BROKER, SIG_DOWN, as_of=as_of, coverage=n1, hard=hard,
why=why, refs=refs)
if chg >= float(eps_rise):
return signal(PATH_BROKER, SIG_UP, as_of=as_of, coverage=n1, refs=refs,
why=f"每股收益预测中位数上修 {chg:.0%}")
return signal(PATH_BROKER, SIG_FLAT, as_of=as_of, coverage=n1, refs=refs,
why=f"每股收益预测中位数变化 {chg:+.0%},在阈值之内")
# ============================================================================
# 丁路:公司事件(目前不可用)
# ============================================================================
def from_events(*_a, **_k) -> dict:
"""这一路目前不可用,恒定返回缺失。
照样出一个缺失而不是干脆不实现是为了让卡上看得见缺的是它缺失要分得清
"无数据源""读不到"静悄悄地不存在会让人以为系统判过了
不可用有两个结构性原因都不是接一下就能解决的一是方向缺失全库三十八万条
事件断言里三十六万五千条的方向字段为空占九成五有值的也是动作词减持增持
预增预减回购授予不是利好利空的语义判断二是粒度错位十类事件闭集全部是
公司级的没有一类是行业级催化而事件谓词的客体类型受控集只有公司与实体抽取时
主体也写死成公司类型所以行业级催化在结构上不可能从这一路出来它走甲路
"""
return signal(PATH_EVENT, SIG_NONE, why="公司事件这一路目前无数据源(方向字段九成五为空)")
# ============================================================================
# 合成
# ============================================================================
def compose(paths, *, strong_needs=(PATH_CLAIM, PATH_JUDGE)) -> dict:
"""把各路信号合成一个状态。返回 state / why / paths / usable / missing / reasons。
strong_needs "逻辑强化必须包含其中至少一路"的名单默认是甲路与乙路
强化不能只靠券商预测上修单独成立那只是卖方在调数字不是产业逻辑变好了
"""
rows = [p for p in (paths or []) if isinstance(p, dict) and p.get("path")]
usable = [p for p in rows if p["signal"] != SIG_NONE]
missing = [p for p in rows if p["signal"] == SIG_NONE]
miss_note = [f"{p['path']}{p['why']}" for p in missing]
if not usable:
return _out(STATE_UNKNOWN, WHY_THIN, rows, usable, missing,
["四路证据一路都不可用"] + miss_note)
ups = [p for p in usable if p["signal"] == SIG_UP]
downs = [p for p in usable if p["signal"] == SIG_DOWN]
splits = [p for p in usable if p["signal"] == SIG_SPLIT]
# 规则二,负向优先。分歧与"一路向好另一路转弱"都是证据矛盾,动作是交人裁决。
if splits or (ups and downs):
rs = [f"{p['path']}{p['as_of']}{p['why']}" for p in splits + ups + downs]
return _out(STATE_UNKNOWN, WHY_CONFLICT, rows, usable, missing,
["不同证据指向相反,需要裁决"] + rs + miss_note)
if downs:
hard = [p for p in downs if p.get("hard")]
rs = [f"{p['path']}{p['as_of']}{p['why']}" for p in downs]
if hard:
return _out(STATE_DOUBT, None, rows, usable, missing,
[f"{p['path']}的转弱达到进入条件:{DOUBT_TRIGGERS.get(p['path'], '')}"
for p in hard] + rs + miss_note)
return _out(STATE_UNKNOWN, WHY_CONFLICT, rows, usable, missing,
["有证据在走弱,但没到逻辑存疑的进入条件"] + rs + miss_note)
# 规则四,慢信号不能单独定态。
if all(SPEED.get(p["path"]) == "" for p in usable):
return _out(STATE_UNKNOWN, WHY_THIN, rows, usable, missing,
["只剩产业研判这一路慢信号,不足以单独定态"] + miss_note)
if len(ups) >= 2 and any(p["path"] in strong_needs for p in ups):
rs = [f"{p['path']}{p['as_of']}{p['why']}" for p in ups]
return _out(STATE_STRONG, None, rows, usable, missing, rs + miss_note)
rs = [f"{p['path']}{p['as_of']}{p['why']}" for p in usable]
return _out(STATE_HOLD, None, rows, usable, missing, rs + miss_note)
def _out(state, why, rows, usable, missing, reasons) -> dict:
return {"state": state, "why": why, "paths": rows,
"usable": [p["path"] for p in usable], "missing": [p["path"] for p in missing],
"reasons": reasons,
"as_of": max([p["as_of"] for p in usable if p.get("as_of")], default=None)}
def settle(prev_state, raw_state, recent_raw, *, confirm_days: int = 2,
exit_doubt_days: int = 3) -> tuple:
"""状态迁移的抗抖动与进出不对称。返回(落定的状态,一句话说明)。
recent_raw 是含今天在内最近若干个计划日的原始合成状态最新的排在最后
进入逻辑存疑即刻成立退出要求连续 exit_doubt_days 个计划日都不再存疑
这个不对称是有意的漏判一次证据走弱的代价比多留一次人工复核大得多
其余方向的迁移要求连续 confirm_days 个计划日同向避免一天一变
"""
seq = [s for s in (recent_raw or []) if s]
if raw_state == STATE_DOUBT:
return STATE_DOUBT, "进入逻辑存疑即刻成立"
if prev_state == STATE_DOUBT:
tail = seq[-int(exit_doubt_days):]
if len(tail) >= int(exit_doubt_days) and all(s != STATE_DOUBT for s in tail):
return raw_state, f"连续 {exit_doubt_days} 个计划日不再存疑,退出逻辑存疑"
got = sum(1 for s in tail if s != STATE_DOUBT)
return STATE_DOUBT, f"退出逻辑存疑要连续 {exit_doubt_days} 个计划日不存疑,现在 {got}"
if prev_state is None or raw_state == prev_state:
return raw_state, "维持"
tail = seq[-int(confirm_days):]
if len(tail) >= int(confirm_days) and all(s == raw_state for s in tail):
return raw_state, f"连续 {confirm_days} 个计划日同向,迁移到{raw_state}"
return prev_state, f"迁移要连续 {confirm_days} 个计划日同向,今天先维持{prev_state}"
def apply_to_card(verdict: str, state: str) -> dict:
"""逻辑状态怎么作用于候选卡的判决。收敛规则是单调的,两条都不许越界:
逻辑强化只能把卡内序提前在提议卡上多写一行永远不能把判决往上升一档
逻辑存疑只能改分流通道不能把仅展示变成可执行
真正需要新语义的只有一格候选加逻辑存疑含义是可交易口径成立而研究证据在走弱
这正是杀逻辑与杀估值那条边界处置是强制人工确认复用现有的强制确认字段与关注
判决走同一条队列页面文案区分来源
"""
out = {"verdict": verdict, "rank_bonus": 0, "force_confirm": False,
"pause_add": False, "note": ""}
if state == STATE_STRONG:
out["rank_bonus"] = 1
out["note"] = "研究证据在走强,卡内序提前;判决不变"
elif state == STATE_DOUBT:
out["pause_add"] = True
if verdict == "候选":
out["force_confirm"] = True
out["note"] = "可交易口径成立而研究证据在走弱,强制人工确认;停增持侧自主动作"
else:
out["note"] = "研究证据在走弱,停增持侧自主动作;判决不变"
elif state == STATE_UNKNOWN:
out["note"] = "研究证据不足以定论;判决不变"
return out
# ---- 小工具 ----
def _s(v):
if v is None:
return None
s = str(v).strip()
return s or None
def _gap(a: str, b: str):
"""a 到 b 的自然日数;认不出返回 None。"""
try:
return (dt.date.fromisoformat(str(b)[:10]) - dt.date.fromisoformat(str(a)[:10])).days
except (ValueError, TypeError):
return None