候选卡判决模块与离线单测;候选卡三张只读视图 SQL(独立文件);.git 文件解析版本号;坏信号集合桥内归一到 card.py

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-02 16:26:09 +08:00
parent e6fdb891e8
commit c540ea1b49
5 changed files with 379 additions and 2 deletions

145
card.py Normal file
View File

@ -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.stateaccum_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))

View File

@ -36,8 +36,9 @@ import db
import plan import plan
# 决策系统结论里算「形态恶化」的定性DROPPED 是它对掉出池子票的收尾标记) # 决策系统结论里算「形态恶化」的定性DROPPED 是它对掉出池子票的收尾标记)。
BAD_SIGNALS = {"SELL", "AVOID", "DROPPED"} # 集合定义在 card.py候选卡的硬风险用同一份桥内只此一处三处同源纪律见 card.py 模块说明。
from card import BAD_SIGNALS # noqa: E402,F401 —— 保留本名,调用方与单测不变
# ============================================================================ # ============================================================================

View File

@ -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=<owner> -e AKG_PG_PASSWORD=<pw> \
-- 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;

77
test_card.py Normal file
View File

@ -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()

51
version.py Normal file
View File

@ -0,0 +1,51 @@
"""代码版本号:解析挂载进容器的 .git 文件取短哈希,不需要 git 二进制。
## 为什么要有它
计划快照要带代码版本复盘时才知道某天的候选单是哪一版规则算出来的桥镜像是
python:3.11-slim没装 gitfreeze.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())