akg-factor-bridge/judgement_coverage.py

646 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""产业研判与因果论断的覆盖率读数(只读,不写任何库、不落任何文件)。
## 为什么要有它
《主观量化系统方案_2026-09-03》第四之五之三节把逻辑状态四态定了稿其中乙路是产业研判。
接之前必须先量出能对上多少:产业研判只有八个主题,投影表里有一千九百多个环节,而候选卡是按
环节判的;两边的命名也不在一个体系里(产业研判按股票池主题聚簇,候选卡按环节名)。
不先量就接,结果是大面积为空。本脚本出四组读数,末尾给一句结论:产业研判这一路今天
能覆盖多少只票。
## 四组读数
甲 产业研判覆盖 —— 主题簇、环节簇、概念簇、个股评析各有多少个,各自最新一条的生成日期
与距今天数,方向取值分布,自我校验未通过的条数;主题簇只有几个,逐条列出。
乙 命名对齐 —— 产业研判的主题名里有多少个与投影表里的环节名逐字相同,对不上的逐个列出
并给出包含关系的候选环节名(这决定要不要做主题与环节的对照表)。
环节簇评析的名字也照同样口径对一遍。
丙 候选卡侧可达 —— 当日被传导指向的环节里有多少个能对上任意一条产业研判;当日档位表里的票,
按当日指向的环节算能对上多少只,按投影表全部所属环节算又能对上多少只。
丁 因果论断覆盖 —— 当日档位表的票里有多少只有因果论断,论断的披露日最新、中位、最老是哪天,
距数据日超过三十、六十、九十、一百八十、三百六十五天的各占多少。
## 纪律
全部是 SELECT没有任何写操作也不生成文件只往终端打印。
每一路读不到都打印一行原因然后继续,任何一组失败都不影响其余组。
本脚本不接进常规链路,也不注册到 run.py 的子命令里:它是一次性的决策读数,跑法见下。
## 跑法【桥机 192.168.16.155 · ~/akg-factor-bridge】
docker compose exec -T akg-factor-bridge python judgement_coverage.py
docker compose exec -T akg-factor-bridge python judgement_coverage.py --date 2026-09-02
不传日期时,数据日取平台因子表 t_factor_akg_score 的最新一天(与出计划的口径同源);
取不到时退回基座传导视图的最新扫描日。
## 已知口径与局限(读数时要一起看)
一,投影表每天 06:10 删后插,只存当前态,所以环节名与成员集合都是"今天的";跑本脚本
避开 06:10 那个窗口。
二,桥的只读视图 v_factor_segment_members 暴露的是投影后的环节名 segment_name而传导扫描
与因果论断视图连接用的是图上原名 COALESCE(orig_segment_name, segment_name)。改派重排过的
环节这两个名字会不同。脚本会额外试读一次投影底表拿原名,读不到就只用视图名并打印一行说明。
三,因果论断视图里客体是环节的论断会按成员展开成多行,所以论断条数一律按 claim_id 去重计。
四,研判结论表以簇键唯一,重评时整行覆盖,库里只有每个簇的最新一版,没有历史版本,
所以"距今天数"读的是最新一版的生成日期,不是这个簇第一次生成的日期。
"""
from __future__ import annotations # 注解不在定义时求值:开发机的 Python 3.9 也能导入本模块
import argparse
import datetime as dt
import unicodedata
import common
import db
# 论断披露日距数据日的分桶边界(天)。这里只是把分布摊开给人看,不是判定阈值,
# 四态合成真正要用的陈旧天数在方案第四之五之三节里一次定死,不由本脚本决定。
_AGE_BUCKETS = (30, 60, 90, 180, 365)
# ---------------------------------------------------------------- 打印与表格
def say(msg: str = "") -> None:
print(msg)
def sec(title: str) -> None:
print(f"\n{'=' * 76}\n{title}\n{'=' * 76}")
def _w(s) -> int:
"""终端显示宽度:中日韩宽字符按两列算,其余按一列算。"""
return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
for ch in str(s))
def _pad(s, width: int, right: bool = False) -> str:
gap = max(0, width - _w(s))
return (" " * gap + str(s)) if right else (str(s) + " " * gap)
def table(headers, rows, right_cols=()) -> None:
"""打印一张定宽文字表。rows 是二维序列None 显示为空。"""
body = [["" if c is None else str(c) for c in r] for r in rows]
if not body:
say("(无行)")
return
n = len(headers)
widths = [max([_w(headers[i])] + [_w(r[i]) for r in body]) for i in range(n)]
say(" ".join(_pad(headers[i], widths[i], i in right_cols) for i in range(n)))
say(" ".join("" * widths[i] for i in range(n)))
for r in body:
say(" ".join(_pad(r[i], widths[i], i in right_cols) for i in range(n)))
def _pct(part: int, whole: int) -> str:
return "" if not whole else f"{part / whole:.1%}"
# ---------------------------------------------------------------- 只读取数
def _rows(df) -> list:
"""把 db.read_* 的返回统一成字典列表;空表返回空列表。"""
if df is None:
return []
if getattr(df, "empty", False):
return []
if hasattr(df, "to_dict"):
return list(df.to_dict("records"))
return [dict(r) for r in df]
def read_pg(sql: str, params=None, what: str = "") -> list:
"""只读一条 PG 查询。读不到打印一行原因并返回空列表,不抛出。"""
try:
return _rows(db.read_pg(sql, params))
except Exception as e: # noqa: BLE001 —— 单路失败不拖累其余读数
say(f" (读不到{what},本项留空: {e!r}")
return []
def read_factor(sql: str, params=None, what: str = "") -> list:
"""只读一条平台因子库查询。读不到打印一行原因并返回空列表,不抛出。"""
try:
return _rows(db.read_mysql("factor", sql, params))
except Exception as e: # noqa: BLE001
say(f" (读不到{what},本项留空: {e!r}")
return []
def _ymd(v):
"""各表的日期列形态不一,统一成 ISO 日期串;认不出返回 None。"""
if v is None or (isinstance(v, float) and v != v):
return None
if isinstance(v, dt.datetime):
return v.date().isoformat()
if isinstance(v, dt.date):
return v.isoformat()
s = str(v).strip()
if len(s) == 8 and s.isdigit():
return f"{s[:4]}-{s[4:6]}-{s[6:]}"
if len(s) >= 10 and s[4] == "-" and s[7] == "-" and s[:4].isdigit():
return s[:10]
return None
def _days_between(d_from, d_to):
"""两个 ISO 日期串相差多少自然日;任一为空返回 None。"""
if not d_from or not d_to:
return None
try:
return (dt.date.fromisoformat(d_to) - dt.date.fromisoformat(d_from)).days
except ValueError:
return None
def _s(v):
if v is None or (isinstance(v, float) and v != v):
return None
s = str(v).strip()
return s or None
def _median(vals: list):
xs = sorted(v for v in vals if v is not None)
return xs[len(xs) // 2] if xs else None
def _tri(v):
"""三态布尔真、假、说不上空值。不能直接拿列值判真假——pandas 读回来的布尔列是
numpy 的布尔,与 Python 的 True / False 不是同一个对象;空值是 NaN而 NaN 本身为真。"""
if v is None or (isinstance(v, float) and v != v):
return None
return bool(v)
def pick_date(arg):
"""数据日:优先命令行,其次平台因子表最新一天,再次基座传导视图最新扫描日。"""
if arg:
return arg.strip()
rows = read_factor("SELECT MAX(trade_date) AS d FROM t_factor_akg_score",
what="平台因子表 t_factor_akg_score 的最新日期")
d = _ymd(rows[0]["d"]) if rows else None
if d:
return d
say(" (退回基座传导视图取最新扫描日)")
rows = read_pg("SELECT MAX(scan_date) AS d FROM v_factor_transmission",
what="传导视图 v_factor_transmission 的最新扫描日")
return _ymd(rows[0]["d"]) if rows else None
# ---------------------------------------------------------------- 数据装载
def load_judgements() -> list:
"""研判结论视图全表列子集。scope 分产业研判、个股评析、环节评析、概念评析。
这里不走取数层的 sources.judgement_rows那个函数是给日频快照用的只取配置里那两类
scope并且会跳过簇键或主题名为空的行。覆盖率读数要的恰恰是"库里到底有什么"
少数一行都会让结论偏乐观,所以本脚本自己读全量、不做任何过滤。
"""
return read_pg(
"SELECT scope, subject_name, segment_name, ts_code, leaning, verified, "
"review_date, n_materials, n_bull, n_bear, input_version, cluster_key "
"FROM v_factor_judgement",
what="研判结论视图 v_factor_judgement视图未建时属正常")
def load_segment_names() -> tuple:
"""投影表里的环节名与各自的上市成员数。
返回 (视图名到成员数的字典, 图上原名集合)。原名从投影底表试读,只读账号没有底表权限时
返回空集合并打印一行说明——此时命名对齐只按视图名判,改派重排过的环节可能被少算。
"""
rows = read_pg(
"SELECT segment_name, count(DISTINCT ts_code) AS n_members "
"FROM v_factor_segment_members "
"WHERE ts_code IS NOT NULL AND ts_code <> '' GROUP BY segment_name",
what="环节投影视图 v_factor_segment_members")
by_name = {}
for r in rows:
name = _s(r.get("segment_name"))
if name:
by_name[name] = int(r.get("n_members") or 0)
orig = set()
try:
raw = _rows(db.read_pg(
"SELECT DISTINCT COALESCE(orig_segment_name, segment_name) AS seg "
"FROM segment_members_projection"))
orig = {_s(r.get("seg")) for r in raw if _s(r.get("seg"))}
except Exception as e: # noqa: BLE001 —— 底表没权限是常态,视图名已经够用
say(f" (读不到投影底表的图上原名,命名对齐只按视图里的环节名判: {e!r}")
return by_name, orig
def load_pointed(ds: str) -> tuple:
"""当日被传导指向的环节,以及每只票挂在哪些被指向环节上。
两张视图合起来才是完整的被指向成员v_factor_transmission 摊平的是未动名单,
v_factor_transmission_moved 出的是已启动成员。返回 (环节到源数的字典, 票到环节集合的字典)。
"""
seg_sources, by_code = {}, {}
for sql, what in (
("SELECT ts_code, target, n_sources FROM v_factor_transmission "
"WHERE scan_date = %s", "传导视图 v_factor_transmission 当日行"),
("SELECT ts_code, target, n_sources FROM v_factor_transmission_moved "
"WHERE scan_date = %s", "已动成员视图 v_factor_transmission_moved 当日行")):
for r in read_pg(sql, (ds,), what=what):
seg = _s(r.get("target"))
if not seg:
continue
try:
n = int(r.get("n_sources") or 0)
except (TypeError, ValueError):
n = 0
seg_sources[seg] = max(seg_sources.get(seg, 0), n)
code = _s(r.get("ts_code"))
if code:
by_code.setdefault(common.to_prefix(code), set()).add(seg)
return seg_sources, by_code
def load_panel(ds: str) -> tuple:
"""当日档位表:主榜与观察档的票。返回 (主榜集合, 观察档集合, 档位表覆盖只数)。
分界与 plan.py 一致:主榜分不低于 150其余是观察档。档位表覆盖只数单独从
t_factor_akg_gate 数,与计划里的 gate_covered 同源。
"""
main, obs = set(), set()
for r in read_factor(
"SELECT stock_code, factor_value FROM t_factor_akg_score WHERE trade_date = %s",
(ds,), what=f"平台因子表 t_factor_akg_score 的 {ds} 截面"):
code = _s(r.get("stock_code"))
if not code:
continue
try:
v = float(r.get("factor_value"))
except (TypeError, ValueError):
continue
(main if v >= 150.0 else obs).add(common.to_prefix(code))
gate_rows = read_factor(
"SELECT count(*) AS n FROM t_factor_akg_gate WHERE trade_date = %s",
(ds,), what=f"平台因子表 t_factor_akg_gate 的 {ds} 截面")
gate_n = int(gate_rows[0]["n"]) if gate_rows else 0
return main, obs, gate_n
def load_member_segments(codes: set) -> dict:
"""投影表里每只票所属的全部环节(不限于当日被指向)。只留给定的票。"""
out = {}
for r in read_pg(
"SELECT segment_name, ts_code FROM v_factor_segment_members "
"WHERE ts_code IS NOT NULL AND ts_code <> ''",
what="环节投影视图的成员关系"):
code = _s(r.get("ts_code"))
seg = _s(r.get("segment_name"))
if not code or not seg:
continue
k = common.to_prefix(code)
if k in codes:
out.setdefault(k, set()).add(seg)
return out
def load_logic(ds: str) -> list:
"""因果论断视图按票聚合:论断条数、最新与最老披露日、经环节展开的条数、两类标记。
论断条数一律按 claim_id 去重:客体是环节的论断在视图里会按成员展开成多行。
"""
return read_pg(
"SELECT ts_code, "
"count(DISTINCT claim_id) AS n_claims, "
"max(disclosure_date) AS d_max, min(disclosure_date) AS d_min, "
"count(DISTINCT claim_id) FILTER (WHERE link_method = 'segment') AS n_via_segment, "
"count(DISTINCT claim_id) FILTER (WHERE disputed) AS n_disputed, "
"count(DISTINCT claim_id) FILTER (WHERE review_flag IS NOT NULL) AS n_flagged "
"FROM v_factor_logic WHERE disclosure_date <= %s GROUP BY ts_code",
(ds,), what="因果论断视图 v_factor_logic视图未建时属正常")
# ---------------------------------------------------------------- 甲
_SCOPE_LABEL = {"industry": "产业研判(主题簇)", "segment": "环节评析(环节簇)",
"concept": "概念评析(概念簇)", "company": "个股评析(公司簇)",
"other": "其他簇键"}
def part_jia(judg: list, today: str) -> None:
sec("甲 · 产业研判覆盖:各类簇各有多少、最新一条多久没动")
if not judg:
say("研判结论视图没有出行:视图未建、无权限,或表里还没有跑完第二轮的簇。本组读数留空。")
return
groups = {}
for r in judg:
groups.setdefault(_s(r.get("scope")) or "other", []).append(r)
rows = []
for scope in ("industry", "segment", "concept", "company", "other"):
g = groups.get(scope)
if not g:
rows.append([_SCOPE_LABEL[scope], 0, "", "", "", ""])
continue
dates = [d for d in (_ymd(r.get("review_date")) for r in g) if d]
newest, oldest = (max(dates), min(dates)) if dates else (None, None)
gap = _days_between(newest, today)
n_unverified = sum(1 for r in g if _tri(r.get("verified")) is False)
rows.append([_SCOPE_LABEL[scope], len(g), newest or "", oldest or "",
"" if gap is None else f"{gap}", n_unverified])
table(["簇的类别", "簇数", "最新生成日", "最老生成日", "最新一条距今", "自我校验未通过"],
rows, right_cols=(1, 5))
lean = {}
for r in judg:
scope = _s(r.get("scope")) or "other"
lean.setdefault(scope, {})
key = _s(r.get("leaning")) or "(方向为空)"
lean[scope][key] = lean[scope].get(key, 0) + 1
say("\n方向取值分布(四态合成的乙路判据用的就是这个受控枚举):")
lr = []
for scope in ("industry", "segment", "concept", "company", "other"):
if scope in lean:
for k, v in sorted(lean[scope].items(), key=lambda kv: -kv[1]):
lr.append([_SCOPE_LABEL[scope], k, v])
table(["簇的类别", "方向", "簇数"], lr, right_cols=(2,))
ind = groups.get("industry") or []
if ind:
say(f"\n产业研判逐条清单(共 {len(ind)} 个主题,乙路今天全部的材料就是这几行):")
ir = []
for r in sorted(ind, key=lambda x: _ymd(x.get("review_date")) or "", reverse=True):
d = _ymd(r.get("review_date"))
gap = _days_between(d, today)
ir.append([_s(r.get("subject_name")) or "(无名)",
_s(r.get("leaning")) or "",
r.get("n_bull"), r.get("n_bear"), r.get("n_materials"),
{True: "", False: "", None: "未知"}[_tri(r.get("verified"))],
d or "", "" if gap is None else f"{gap}",
(_s(r.get("input_version")) or "")[:12]])
table(["主题名", "方向", "多头条数", "空头条数", "材料条数", "自我校验通过",
"生成日", "距今", "材料指纹"], ir, right_cols=(2, 3, 4, 7))
# ---------------------------------------------------------------- 乙
def _substr_hints(name: str, seg_names, limit: int = 3) -> str:
"""给对不上的名字找包含关系的候选环节名(一方是另一方的子串),最多给几个。"""
hits = [s for s in seg_names if s != name and (name in s or s in name)]
hits.sort(key=len)
if not hits:
return "(无包含关系的候选)"
tail = "" if len(hits) > limit else ""
return "".join(hits[:limit]) + tail
def part_yi(judg: list, seg_members: dict, seg_orig: set) -> set:
"""返回能被逐字对上的环节名集合(主题名命中的加上环节簇名命中的)。"""
sec("乙 · 命名对齐:产业研判的主题名与投影表里的环节名对不对得上")
all_seg = set(seg_members) | set(seg_orig)
if not all_seg:
say("投影表里一个环节名都没读到,命名对齐无法判定。本组读数留空。")
return set()
say(f"投影表里的环节名:视图名 {len(seg_members)}"
+ (f",加上图上原名后去重 {len(all_seg)} 个。" if seg_orig else "(图上原名未读到)。"))
if not judg:
say("研判结论视图没有出行,没有主题名可对。本组读数留空。")
return set()
matched = set()
for scope, label in (("industry", "主题名"), ("segment", "环节簇名")):
rows = [r for r in judg if (_s(r.get("scope")) or "") == scope]
if not rows:
say(f"\n{label}:一条都没有,跳过。")
continue
out, hit = [], 0
for r in sorted(rows, key=lambda x: _ymd(x.get("review_date")) or "", reverse=True):
name = _s(r.get("segment_name")) if scope == "segment" else None
name = name or _s(r.get("subject_name")) or "(无名)"
ok = name in all_seg
if ok:
hit += 1
matched.add(name)
out.append([name, "" if ok else "",
seg_members.get(name, "") if ok else "",
_ymd(r.get("review_date")) or "",
"" if ok else _substr_hints(name, all_seg)])
say(f"\n{label}{len(rows)} 个,与环节名逐字相同 {hit}"
f"{_pct(hit, len(rows))}),对不上 {len(rows) - hit} 个:")
table([label, "逐字命中环节名", "该环节上市成员数", "生成日", "包含关系的候选环节名"],
out, right_cols=(2,))
say("\n读法:逐字命中列全是否,就意味着乙路按名字直连一只票也接不上,"
"要接必须先做主题与环节的对照表;包含关系那一列是起草对照表时的线索,不是自动匹配的依据。")
return matched
# ---------------------------------------------------------------- 丙
def part_bing(judg: list, seg_sources: dict, code_pointed: dict,
main: set, obs: set, gate_n: int, member_segs: dict) -> tuple:
"""返回 (能对上研判的票集合, 按投影环节能对上的票集合)。"""
sec("丙 · 候选卡侧的可达性:当日被指向的环节与档位表的票,有多少能对上产业研判")
theme_names = {_s(r.get("subject_name")) for r in judg
if (_s(r.get("scope")) or "") == "industry"}
seg_cluster_names = {_s(r.get("segment_name")) or _s(r.get("subject_name"))
for r in judg if (_s(r.get("scope")) or "") == "segment"}
theme_names.discard(None)
seg_cluster_names.discard(None)
reach = theme_names | seg_cluster_names
say(f"能被对上的名字一共 {len(reach)} 个:同名主题 {len(theme_names)} 个,"
f"环节簇 {len(seg_cluster_names)} 个。")
if not seg_sources:
say("当日没有读到任何被传导指向的环节,本组前半留空。")
else:
by_seg = sum(1 for s in seg_sources if s in seg_cluster_names)
by_theme = sum(1 for s in seg_sources if s in theme_names)
both = sum(1 for s in seg_sources if s in reach)
say(f"\n当日被传导指向的环节 {len(seg_sources)} 个:按环节簇对上 {by_seg} 个,"
f"按同名主题对上 {by_theme} 个,去重合计 {both} 个({_pct(both, len(seg_sources))})。")
top = sorted(seg_sources.items(), key=lambda kv: (-kv[1], kv[0]))[:20]
rows = [[seg, n,
"环节簇" if seg in seg_cluster_names else
("同名主题" if seg in theme_names else "对不上")]
for seg, n in top]
say("\n被指向最强的二十个环节(源数从高到低):")
table(["被指向的环节", "源数", "对上哪一路研判"], rows, right_cols=(1,))
panel = main | obs
say(f"\n当日档位表:主榜 {len(main)} 只,观察档 {len(obs)} 只,合计 {len(panel)} 只;"
f"档位表覆盖 {gate_n} 只。")
if not panel:
say("档位表当日没有票,本组后半留空。")
return set(), set()
hit_pointed = {k for k in panel if (code_pointed.get(k) or set()) & reach}
hit_member = {k for k in panel if (member_segs.get(k) or set()) & reach}
n_pointed = sum(1 for k in panel if code_pointed.get(k))
n_member = sum(1 for k in panel if member_segs.get(k))
rows = [
["当日被传导指向(卡上的主题就是它)", n_pointed, _pct(n_pointed, len(panel)),
len(hit_pointed), _pct(len(hit_pointed), len(panel))],
["投影表里的全部所属环节(不限当日)", n_member, _pct(n_member, len(panel)),
len(hit_member), _pct(len(hit_member), len(panel))],
]
say("\n档位表的票能不能对上研判,按两种口径各算一遍:")
table(["算所在环节的口径", "有所在环节的票数", "占档位表", "能对上研判的票数", "占档位表"],
rows, right_cols=(1, 2, 3, 4))
say("两种口径的差别:上一行是候选卡当天真正用的(卡上的主题来自当日传导指向),"
"下一行是把票在投影表里挂着的环节全算上,是乙路理论上的天花板。")
return hit_pointed, hit_member
# ---------------------------------------------------------------- 丁
def part_ding(logic: list, main: set, obs: set, ds: str) -> set:
"""返回档位表里有因果论断的票集合。"""
sec("丁 · 因果论断覆盖:档位表的票有多少只有论断、论断有多旧")
panel = main | obs
if not logic:
say("因果论断视图没有出行:视图未建、无权限,或披露日不晚于数据日的论断为零。本组读数留空。")
return set()
by_code = {}
for r in logic:
code = _s(r.get("ts_code"))
if code:
by_code[common.to_prefix(code)] = r
say(f"因果论断视图里一共有 {len(by_code)} 只票带论断(披露日不晚于数据日 {ds})。")
if not panel:
say("档位表当日没有票,只能给出全库读数。")
return set()
have = {k for k in panel if k in by_code}
have_main = {k for k in main if k in by_code}
rows = [["主榜", len(main), len(have_main), _pct(len(have_main), len(main))],
["观察档", len(obs), len(have) - len(have_main),
_pct(len(have) - len(have_main), len(obs))],
["合计", len(panel), len(have), _pct(len(have), len(panel))]]
table(["档位", "票数", "有因果论断", "覆盖率"], rows, right_cols=(1, 2, 3))
if not have:
say("档位表里一只票都没有论断,披露日分布无从谈起。")
return set()
n_claims, n_via_seg, n_disputed, n_flagged = 0, 0, 0, 0
per_stock, newest_dates = [], []
for k in have:
r = by_code[k]
try:
n_claims += int(r.get("n_claims") or 0)
per_stock.append(int(r.get("n_claims") or 0))
n_via_seg += int(r.get("n_via_segment") or 0)
n_disputed += int(r.get("n_disputed") or 0)
n_flagged += int(r.get("n_flagged") or 0)
except (TypeError, ValueError):
pass
d = _ymd(r.get("d_max"))
if d:
newest_dates.append(d)
say(f"\n{len(have)} 只票身上一共 {n_claims} 条论断(按论断编号去重),"
f"每票中位数 {_median(per_stock)} 条,最多 {max(per_stock) if per_stock else 0} 条;"
f"其中 {n_via_seg} 条是客体为环节、按成员展开挂上来的,不是直接讲这家公司的。")
say(f"标记:处于未结的多空分歧复核 {n_disputed} 条;逻辑评析标了疑似误抽 {n_flagged} 条。"
"这两类在接进四态之前要先决定怎么处置。")
if not newest_dates:
say("论断的披露日全部认不出来,日期分布留空。")
return have
ages = [a for a in (_days_between(d, ds) for d in newest_dates) if a is not None]
say(f"\n每只票最新一条论断的披露日:最新 {max(newest_dates)}"
f"中位 {_median(newest_dates)},最老 {min(newest_dates)}"
f"(距数据日 {ds} 分别是 {min(ages) if ages else ''}{_median(ages)}"
f"{max(ages) if ages else ''} 天)。")
if ages:
rows, prev = [], 0
for b in _AGE_BUCKETS:
n = sum(1 for a in ages if prev < a <= b)
rows.append([f"{prev + 1}{b}", n, _pct(n, len(ages)),
sum(1 for a in ages if a > b),
_pct(sum(1 for a in ages if a > b), len(ages))])
prev = b
n_last = sum(1 for a in ages if a > _AGE_BUCKETS[-1])
rows.append([f"超过 {_AGE_BUCKETS[-1]}", n_last, _pct(n_last, len(ages)), 0, "0.0%"])
say("\n按距数据日的天数分桶(末两列是超过本桶上界的累计,看陈旧面有多大):")
table(["距数据日", "票数", "占比", "超过本桶上界的票数", "占比"],
rows, right_cols=(1, 2, 3, 4))
return have
# ---------------------------------------------------------------- 结论
def conclusion(main: set, obs: set, hit_pointed: set, hit_member: set,
have_logic: set, judg: list, today: str) -> None:
sec("结论")
panel = main | obs
if not panel:
say("当日档位表没有票,给不出覆盖结论;先确认那一天的因子表已经构建。")
return
ind = [r for r in judg if (_s(r.get("scope")) or "") == "industry"]
dates = [d for d in (_ymd(r.get("review_date")) for r in ind) if d]
gap = _days_between(max(dates), today) if dates else None
say(f"产业研判这一路今天只有 {len(ind)} 个主题,最新一条生成于 "
f"{max(dates) if dates else '未知'}"
f"{'' if gap is None else f',距今 {gap}'}")
say(f"当日档位表 {len(panel)} 只票里,按候选卡当天用的口径(当日被传导指向的环节)"
f"能对上产业研判的有 {len(hit_pointed)} 只,占 {_pct(len(hit_pointed), len(panel))}"
f"把投影表里挂着的环节全算上也只有 {len(hit_member)} 只,"
f"{_pct(len(hit_member), len(panel))}——这是乙路的天花板。")
diff = len(have_logic) - len(hit_pointed)
say(f"同一批票里有因果论断的是 {len(have_logic)} 只,占 {_pct(len(have_logic), len(panel))}"
+ (f"甲路比乙路多覆盖 {diff} 只。" if diff > 0 else
(f"乙路比甲路多覆盖 {-diff} 只。" if diff < 0 else "两路覆盖的只数一样多。")))
if len(hit_pointed) == 0:
say("\n判断:乙路现在按名字直连一只票都接不上,接进四态合成等于全票都落到"
"无法判断加证据不足。要么先做主题与环节的对照表再接,要么这一轮先只接甲路,"
"乙路等主题数上来、对照表建好再说。")
elif len(hit_pointed) * 10 < len(panel):
say("\n判断:乙路能覆盖的不到档位表的一成,接进去的话绝大多数票仍然是证据不足。"
"可以接,但要在卡上明写缺的是哪一路,不要让读的人误以为是系统判过了。")
else:
say("\n判断:乙路的覆盖面够得上接。仍要在卡上分开标注缺失与矛盾两个子因,"
"缺失不当成负面证据。")
say("\n本脚本只读不写,跑完不留任何文件。以上读数随投影表每天 06:10 重建而变,"
"拍板前建议连着两个交易日各跑一次看是否稳定。")
# ---------------------------------------------------------------- 入口
def run(date=None) -> int:
today = dt.date.today().isoformat()
say("产业研判与因果论断覆盖率读数(只读,不写任何库、不落任何文件)")
ds = pick_date(date)
if not ds:
say("取不到数据日:平台因子表与基座传导视图都读不到。先确认连接与当日构建,再跑本脚本。")
return 1
say(f"数据日 {ds};脚本运行日 {today}(容器走国际时间,与北京时间可能差一天,"
f"两个日期都打出来是为了让距今天数有据可查)。")
judg = load_judgements()
seg_members, seg_orig = load_segment_names()
seg_sources, code_pointed = load_pointed(ds)
main, obs, gate_n = load_panel(ds)
member_segs = load_member_segments(main | obs) if (main or obs) else {}
logic = load_logic(ds)
hit_pointed, hit_member, have_logic = set(), set(), set()
for name, fn in (
("", lambda: part_jia(judg, today)),
("", lambda: part_yi(judg, seg_members, seg_orig)),
("", lambda: part_bing(judg, seg_sources, code_pointed, main, obs,
gate_n, member_segs)),
("", lambda: part_ding(logic, main, obs, ds))):
try:
r = fn()
if name == "" and isinstance(r, tuple):
hit_pointed, hit_member = r
elif name == "" and isinstance(r, set):
have_logic = r
except Exception as e: # noqa: BLE001 —— 单组失败不拖累其余读数
say(f"\n{name}组读数失败(其余照出): {e!r}")
try:
conclusion(main, obs, hit_pointed, hit_member, have_logic, judg, today)
except Exception as e: # noqa: BLE001
say(f"\n结论一节失败: {e!r}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(
description="产业研判与因果论断的覆盖率读数(只读)")
ap.add_argument("--date", default=None,
help="数据日 YYYY-MM-DD不传时取平台因子表最新一天")
a = ap.parse_args()
return run(a.date)
if __name__ == "__main__":
raise SystemExit(main())