diff --git a/card.py b/card.py new file mode 100644 index 0000000..33bb38d --- /dev/null +++ b/card.py @@ -0,0 +1,145 @@ +"""候选卡:把一只票的各条证据线装配成判决与理由(纯函数,零 IO)。 + +## 为什么要有它 + +桥原来的产物是一个分数:主榜分 = 200 + 传导档位×20 + 组内分。分数排在最前的 +强传导档,实证里过去一个月是四档中最差的(五日超额 −2.04,见 +docs/主观选股改进方案_2026-09-02.md 第 1.8b 节)。方案把桥从"打分排序器"改成 +"候选卡装配器":分数与档位一律不动,另出一份带理由的候选单。规则只有三条硬门槛、 +一条确认、三条硬风险,全部是一个主观选股的人会用的判断,不算总分。 + + 门槛一 所在环节当日被传导指向(只认环节类目标) + 门槛二 成员当日已启动:数据日涨幅达到 CARD_START_PCT(默认 3%,与基座热点扫描同口径) + 门槛三 有券商覆盖、预期空间不低于负容忍线、非 ST 与退市族 + 确认 决策系统吸筹评分为"明确吸筹",且评分日龄不超过 CARD_ACCUM_MAX_AGE 个交易日 + 硬风险 决策系统昨夜给出卖出、回避或剔除信号;传导快照日与计划日不符;吸筹评分为高位派发 + + 判决 候选 = 三门槛全过、硬风险为空、确认成立 + 关注 = 硬风险为空,且(只差覆盖一项)或(门槛全过但无确认) + 仅展示 = 其余,卡上标明未过项 + +依据:方案 1.8d——"环节被指向、当日已涨 3% 以上、且明确吸筹"是纯可交易口径下唯一 +合并样本为正的规则(五日 +2.18、十日 +1.94),但它是顺风策略、不免疫环境;两个旋钮 +保持默认,等样本外复盘读数再拍,不在历史样本上挑参数(2026-09-02 拍板)。 + +## 边界 + +本模块不读库、不读配置,只吃调用方装配好的证据字典,输出判决字典。这样它能 +离线单测(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, + accum_max_age: int = 30, neg_tol: float = 0.0) -> 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.state);accum_score float; + accum_age int 评分日龄(交易日) + y_signal str 决策系统昨夜 signal_type + stale_snapshot bool 传导快照日与计划日不符 + 返回:verdict / reasons / missing / risk / 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 + + 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("无券商覆盖") + if not accum_state: + missing.append("无吸筹评分(未入池)") + elif accum_state.startswith("明确") and not accum_fresh: + missing.append(f"吸筹评分陈旧 {accum_age} 日" if isinstance(accum_age, int) + else "吸筹评分日龄未知") + if not gates["pointed"]: + missing.append("无传导") + + 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()) + only_missing_coverage = (gates["pointed"] and gates["started"] and gates["clean_name"] + and not gates["covered"]) + if risk: + verdict = VERDICT_SHOW + elif all_gates and confirm: + verdict = VERDICT_CANDIDATE + elif only_missing_coverage or (all_gates and not confirm): + verdict = VERDICT_WATCH + else: + verdict = VERDICT_SHOW + + failed = [k for k, ok in gates.items() if not ok] + return {"verdict": verdict, "reasons": reasons, "missing": missing, "risk": risk, + "gates": gates, "confirm": confirm, "failed_gates": failed} + + +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)) diff --git a/pool.py b/pool.py index a3d97af..26afbda 100644 --- a/pool.py +++ b/pool.py @@ -36,8 +36,9 @@ import db import plan -# 决策系统结论里算「形态恶化」的定性(DROPPED 是它对掉出池子票的收尾标记) -BAD_SIGNALS = {"SELL", "AVOID", "DROPPED"} +# 决策系统结论里算「形态恶化」的定性(DROPPED 是它对掉出池子票的收尾标记)。 +# 集合定义在 card.py(候选卡的硬风险用同一份),桥内只此一处;三处同源纪律见 card.py 模块说明。 +from card import BAD_SIGNALS # noqa: E402,F401 —— 保留本名,调用方与单测不变 # ============================================================================ diff --git a/sql/astock_kg_card_views.sql b/sql/astock_kg_card_views.sql new file mode 100644 index 0000000..87bf6a1 --- /dev/null +++ b/sql/astock_kg_card_views.sql @@ -0,0 +1,103 @@ +-- astock-kg 候选卡插槽视图:三张只读视图,供 akg-factor-bridge 的候选卡与复盘消费。 +-- 应用【桥机 155 · ~/project/akg-factor-bridge】: +-- docker compose exec -T akg-factor-bridge python run.py apply-views --file sql/astock_kg_card_views.sql --dry-run +-- docker compose exec -T -e AKG_PG_USER= -e AKG_PG_PASSWORD= \ +-- akg-factor-bridge python run.py apply-views --file sql/astock_kg_card_views.sql +-- +-- 与 sql/astock_kg_slot_views.sql 分开放:那份文件用 DROP + CREATE 且含 ALTER TABLE, +-- 整文件重跑会把生产在用的四张视图重删重建;本文件只用 CREATE OR REPLACE,反复应用无害。 +-- 基座零代码:只读现有表 transmission_candidates、segment_members_projection、 +-- segment_member_review、mkt_daily。应用时避开 06:10 的投影重建窗口。 +-- 出处:docs/主观选股改进方案_2026-09-02.md 第 2.2 节第二项。 + + +-- 一、已动成员视图 v_factor_transmission_moved +-- 被传导指向的环节(只认 Segment 目标;Concept 与 Company 目标的成员不在投影表里)的 +-- 投影成员,减去传导台账里的未动名单 quiet,再减去复核账本判定"不属于本环节"的成员 +-- (verdict = 'exclude',与基座 transmission.scan 在算已动之前的剔除口径一致)。 +-- 连接键用投影表的原名 COALESCE(orig_segment_name, segment_name):投影期有改派重排, +-- 传导扫描用的目标名是图上原名。 +-- 本视图只做集合差,不重算涨幅;"已动"的口径与基座 hotspot._movers_set 同源 +-- (数据日涨幅达到 3%),涨幅本身从 v_factor_stock_daily 取。 +-- 对账:按 (scan_date, target) 数本视图行数 + quiet 行数,与 transmission_candidates 的 +-- members_total 比;不等的环节多半是基座扫描时成员集有上限(cap=200)而投影表没有, +-- 差异分"截断"与"真差异"两类,只对后者要求为零。 +CREATE OR REPLACE VIEW v_factor_transmission_moved AS +WITH tc AS ( + SELECT t.scan_date, t.rank, t.target, t.mkt_trade_date, t.chain_fit, + t.members_total, t.moved, t.moved_ratio, + (SELECT count(DISTINCT p->>'source') + FROM jsonb_array_elements(COALESCE(t.paths, '[]'::jsonb)) p) AS n_sources, + t.quiet + FROM transmission_candidates t + WHERE t.target_type = 'Segment' +), +quiet AS ( + SELECT c.scan_date, c.target, q->>'ts_code' AS ts_code + FROM tc c + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(c.quiet, '[]'::jsonb)) q + WHERE q->>'ts_code' IS NOT NULL AND q->>'ts_code' <> '' +), +members AS ( + SELECT DISTINCT COALESCE(orig_segment_name, segment_name) AS seg, + ts_code, member_name, segment_name AS projected_segment + FROM segment_members_projection + WHERE ts_code IS NOT NULL AND ts_code <> '' +) +SELECT c.scan_date, c.target, c.rank, c.n_sources, c.chain_fit, + c.members_total, c.moved, c.moved_ratio, c.mkt_trade_date, + m.ts_code, m.member_name, m.projected_segment +FROM tc c +JOIN members m ON m.seg = c.target +LEFT JOIN quiet q + ON q.scan_date = c.scan_date AND q.target = c.target AND q.ts_code = m.ts_code +WHERE q.ts_code IS NULL + AND NOT EXISTS ( + SELECT 1 FROM segment_member_review r + WHERE r.ts_code = m.ts_code AND r.segment_name = c.target AND r.verdict = 'exclude'); + + +-- 二、个股日行情视图 v_factor_stock_daily +-- 候选卡"已启动"门槛(pct_change)与两条展示线(net_z 主力净额对自身二十日基线的 +-- 标准化偏离、heat_chg 热度较往前第五个交易日的变化)的数据来源,只留行情列。 +-- 吸筹不从这里取:基座落库的吸筹状态没有评分日,候选卡的吸筹三项只认决策系统结论表。 +CREATE OR REPLACE VIEW v_factor_stock_daily AS +SELECT trade_date, + code AS ts_code, + (metrics->>'pct_change')::numeric AS pct_change, + (metrics->>'net_z')::numeric AS net_z, + (metrics->>'heat_chg')::numeric AS heat_chg, + (metrics->>'heat')::numeric AS heat, + (metrics->>'net_amount')::numeric AS net_amount +FROM mkt_daily +WHERE kind = 'stock'; + + +-- 三、环节日行情视图 v_factor_segment_daily +-- 按投影表把个股日行情聚成环节行,只给原始值与成员明细集合;"主力净额前两名"、 +-- "涨停近似(涨幅超 9.8%)"这类派生判断在桥的 card.py 算,不写进基座视图。 +-- 投影表是当前态(每晨 06:10 删后插),历史日的成员集合带成员前视,复盘按冻结快照重建。 +CREATE OR REPLACE VIEW v_factor_segment_daily AS +WITH members AS ( + SELECT DISTINCT COALESCE(orig_segment_name, segment_name) AS seg, ts_code, member_name + FROM segment_members_projection + WHERE ts_code IS NOT NULL AND ts_code <> '' +) +SELECT s.trade_date, + p.seg AS segment_name, + count(*) AS members, + count(*) FILTER (WHERE s.pct_change >= 3) AS moved, + percentile_cont(0.5) WITHIN GROUP (ORDER BY s.pct_change) AS pct_median, + percentile_cont(0.5) WITHIN GROUP (ORDER BY s.heat_chg) AS heat_chg_median, + jsonb_agg(jsonb_build_object('ts_code', p.ts_code, 'name', p.member_name, + 'pct', s.pct_change, 'net_amount', s.net_amount) + ORDER BY s.pct_change DESC NULLS LAST) AS members_detail +FROM members p +JOIN ( + SELECT trade_date, code AS ts_code, + (metrics->>'pct_change')::numeric AS pct_change, + (metrics->>'heat_chg')::numeric AS heat_chg, + (metrics->>'net_amount')::numeric AS net_amount + FROM mkt_daily WHERE kind = 'stock' +) s ON s.ts_code = p.ts_code +GROUP BY s.trade_date, p.seg; diff --git a/test_card.py b/test_card.py new file mode 100644 index 0000000..7958615 --- /dev/null +++ b/test_card.py @@ -0,0 +1,77 @@ +"""card.judge() 纯逻辑单测(无需 DB、无需 pandas)。 + +覆盖:候选 / 关注(缺覆盖)/ 关注(缺确认)/ 仅展示(未启动)/ 仅展示(硬风险否决) +/ 缺失文案 / 评分陈旧 / 旋钮 / 卡内序 / 坏信号集合与 pool 同源。 + +跑法:python3 test_card.py 或 pytest test_card.py +""" +import card + +BASE = dict(pointed=True, theme="散热器件", n_sources=2, pct0=4.1, + covered=True, upside=0.25, risk_name=False, + accum_state="明确吸筹·量在价先", accum_score=78, accum_age=3, + y_signal="WATCH", stale_snapshot=False) + + +def t(name, cond): + assert cond, name + print(" ok", name) + + +def main(): + r = card.judge(dict(BASE)) + t("三门槛全过且明确吸筹 -> 候选", r["verdict"] == "候选" and r["confirm"] and not r["risk"]) + t("候选理由四条齐", len(r["reasons"]) == 4 and "已启动" in r["reasons"][1]) + + r = card.judge({**BASE, "covered": False, "upside": None}) + t("只差覆盖 -> 关注", r["verdict"] == "关注" and "无券商覆盖" in r["missing"]) + + r = card.judge({**BASE, "accum_state": "潜在吸筹·低位企稳"}) + t("门槛全过但潜在吸筹不算确认 -> 关注", r["verdict"] == "关注" and not r["confirm"]) + + r = card.judge({**BASE, "accum_state": ""}) + t("无评分 -> 关注且缺失文案", r["verdict"] == "关注" and "无吸筹评分(未入池)" in r["missing"]) + + r = card.judge({**BASE, "pct0": 1.2}) + t("未启动 -> 仅展示", r["verdict"] == "仅展示" and "started" in r["failed_gates"]) + + r = card.judge({**BASE, "pointed": False}) + t("未被指向 -> 仅展示且标无传导", r["verdict"] == "仅展示" and "无传导" in r["missing"]) + + r = card.judge({**BASE, "y_signal": "sell"}) + t("决策系统 SELL -> 硬风险否决为仅展示", r["verdict"] == "仅展示" and r["risk"]) + + r = card.judge({**BASE, "stale_snapshot": True}) + t("快照日不符 -> 硬风险", r["verdict"] == "仅展示" and "传导快照日与计划日不符" in r["risk"]) + + r = card.judge({**BASE, "accum_state": "⚠️ 高位派发"}) + t("高位派发 -> 硬风险", r["verdict"] == "仅展示" and "高位派发" in r["risk"]) + + r = card.judge({**BASE, "accum_age": 45}) + t("评分陈旧 -> 不确认、关注、缺失文案", r["verdict"] == "关注" and not r["confirm"] + and any("陈旧 45" in m for m in r["missing"])) + + r = card.judge({**BASE, "pct0": 2.5}, start_pct=2.0) + t("启动阈值旋钮生效", r["verdict"] == "候选") + r = card.judge({**BASE, "accum_age": 45}, accum_max_age=60) + t("评分日龄旋钮生效", r["verdict"] == "候选") + r = card.judge({**BASE, "upside": -0.05}, neg_tol=0.10) + t("负容忍线传入生效", r["verdict"] == "候选") + r = card.judge({**BASE, "upside": -0.05}) + t("负容忍默认 0:轻微为负不过覆盖门槛 -> 关注", r["verdict"] == "关注") + + r = card.judge({**BASE, "risk_name": True}) + t("ST 族 -> 仅展示", r["verdict"] == "仅展示" and "clean_name" in r["failed_gates"]) + + rows = [{"verdict": "关注", "pct0": 9.0}, {"verdict": "候选", "pct0": 3.5}, + {"verdict": "候选", "pct0": 7.2}, {"verdict": "仅展示", "pct0": None}] + rows.sort(key=card.sort_key) + t("卡内序:候选在前、同判决涨幅降序、缺行情垫底", + [x["pct0"] for x in rows] == [7.2, 3.5, 9.0, None]) + + t("坏信号集合口径", card.BAD_SIGNALS == {"SELL", "AVOID", "DROPPED"}) + print("ALL OK — 候选卡判决 / 关注两种 / 硬风险三种 / 缺失文案 / 旋钮 / 卡内序 全部通过") + + +if __name__ == "__main__": + main() diff --git a/version.py b/version.py new file mode 100644 index 0000000..2b51d39 --- /dev/null +++ b/version.py @@ -0,0 +1,51 @@ +"""代码版本号:解析挂载进容器的 .git 文件取短哈希,不需要 git 二进制。 + +## 为什么要有它 + +计划快照要带代码版本,复盘时才知道某天的候选单是哪一版规则算出来的。桥镜像是 +python:3.11-slim、没装 git,freeze.py 里调 git 命令的取法在容器里恒为 unknown +(155 上 data/frozen/2026-09-01/manifest.json 实测)。仓库根挂在 /app,.git 目录随之 +挂进来,直接读它就够了(2026-09-02 拍板:解析 .git 文件,不走宿主传环境变量)。 + +读法:.git/HEAD 是 "ref: refs/heads/<分支>" 就去读对应的 refs 文件;refs 文件不存在 +(打包过)就在 .git/packed-refs 里找那一行;HEAD 本身就是裸哈希(游离头)直接用。 +任何一步失败返回 "unknown",绝不抛错——版本号缺失不该让计划断产。 +""" +from __future__ import annotations + +from pathlib import Path + + +def _read(p: Path) -> str | None: + try: + return p.read_text(encoding="utf-8").strip() + except OSError: + return None + + +def git_short_rev(repo_root: str | Path | None = None, length: int = 7) -> str: + roots = [Path(repo_root)] if repo_root else [Path("/app"), Path(__file__).resolve().parent] + for root in roots: + git = root / ".git" + head = _read(git / "HEAD") + if not head: + continue + if head.startswith("ref:"): + ref = head.split(":", 1)[1].strip() + sha = _read(git / ref) + if not sha: + packed = _read(git / "packed-refs") or "" + for line in packed.splitlines(): + parts = line.split() + if len(parts) == 2 and parts[1] == ref: + sha = parts[0] + break + else: + sha = head + if sha and len(sha) >= length and all(c in "0123456789abcdef" for c in sha[:length]): + return sha[:length] + return "unknown" + + +if __name__ == "__main__": + print(git_short_rev())