Files
sanguo_vnpy_v2/sanguo_factor/version_registry.py
T

317 lines
15 KiB
Python

# sanguo_factor/version_registry.py
"""因子版本注册表(config/factor_registry.yaml,随代码走)——流水线 spec §4.2 决议 D/E.
同一假设换实现(窗口/口径/标准化)=升版本;换数据或经济逻辑=新因子.
版本快照=参数+代码 commit+生效时间窗;回测/归因按日期解析当时生效版本
(pit_view 点时纪律延伸到因子定义本身).
状态机: incubating(孵化) → assessable(可判) → promoted(晋级)
→ decaying(衰减预警) → retired(退役) → graveyard(墓园).
墓园只收「评过且判死」且必须留死因——可检索防重复造轮(决议 E).
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
from typing import Any
import yaml
VALID_STATUSES = frozenset(
{"incubating", "assessable", "promoted", "decaying", "retired", "graveyard"})
# 条目级 origin=稳定身份戳(P3-11 消费方 decomposer_new_names 条目级优先;
# #91① 写入方): decomposer=分解产物(进 IC 闸新因子视野), manual=人工改判
# (显式退出闸视野). 建条即落;同名幂等返回不覆盖——改判=手工改 yaml.
VALID_ORIGINS = frozenset({"decomposer", "manual"})
# 内网 Gitea 缺省地址(B' 双机: NAS=内网 IP, VPS=FRP 127.0.0.1:13000)——
# 单源常量,data_gap_check 等同仓消费方 import 此处,禁再抄字面量(P3-20).
GITEA_BASE_URL = "http://192.168.2.154:3000"
# 合法迁移:普通链路逐级+跳过 assessable 的快车道不存在;graveyard 终态不可复活.
_VALID_TRANSITIONS: dict[str, frozenset[str]] = {
"incubating": frozenset({"assessable", "graveyard"}),
"assessable": frozenset({"promoted", "graveyard"}),
"promoted": frozenset({"decaying", "retired", "graveyard"}),
"decaying": frozenset({"promoted", "retired", "graveyard"}), # 衰减可恢复
"retired": frozenset({"graveyard"}),
"graveyard": frozenset(), # 墓园不可复活
}
def load_registry(path: str) -> dict[str, Any]:
"""读注册表;文件不存在→空表(首跑自举)."""
if not os.path.exists(path):
return {"factors": {}}
with open(path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
if not isinstance(raw, dict) or not isinstance(raw.get("factors", {}), dict):
raise ValueError(f"注册表结构不合法(须为 {{factors: {{...}}}}): {path}")
raw.setdefault("factors", {})
return raw
def save_registry(path: str, registry: dict[str, Any]) -> None:
"""原子写:tmp+os.replace(daily_section merge 同款纪律)."""
d = os.path.dirname(os.path.abspath(path))
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(suffix=".tmp", dir=d)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
yaml.safe_dump(registry, f, allow_unicode=True, sort_keys=False)
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.remove(tmp)
def upsert_factor(registry: dict[str, Any], name: str, hypothesis: str,
status: str = "incubating",
origin: str | None = None,
description: str | None = None) -> dict[str, Any]:
"""新建因子条目;同名同假设幂等,同名异假设拒绝(换假设=开新因子,决议 D).
origin=条目级身份戳(#91①): 仅建条时落盘,幂等返回不覆盖;合法值见
VALID_ORIGINS. 消费方=monthly_batch.decomposer_new_names(条目级优先).
description=条目级一句话自述(2026-10-09 补丁): 同仅建条落盘纪律,
改描述走升版本.
"""
factors = registry["factors"]
if name in factors:
entry = factors[name]
if entry.get("hypothesis") != hypothesis:
raise ValueError(f"因子 {name} 假设不同: 已绑定 {entry.get('hypothesis')},传入 {hypothesis}——换假设应开新因子")
return entry
if status not in VALID_STATUSES:
raise ValueError(f"非法状态: {status}")
if origin is not None and origin not in VALID_ORIGINS:
raise ValueError(f"非法 origin: {origin} (合法: {sorted(VALID_ORIGINS)})")
entry = {"name": name, "hypothesis": hypothesis, "status": status,
"versions": []}
if origin is not None:
entry["origin"] = origin
if description is not None:
entry["description"] = description
factors[name] = entry
return entry
def transition(registry: dict[str, Any], name: str, new_status: str,
**extra: Any) -> dict[str, Any]:
"""状态迁移(状态机校验);入 graveyard 必须带 cause(死因)与 evidence_run(证据批)."""
entry = registry["factors"].get(name)
if entry is None:
raise KeyError(f"因子未注册: {name}")
if new_status not in VALID_STATUSES:
raise ValueError(f"非法状态: {new_status}")
allowed = _VALID_TRANSITIONS[entry["status"]]
if new_status not in allowed:
if entry["status"] == "graveyard":
raise ValueError(f"墓园不可迁出(防复活): {name}")
raise ValueError(f"非法状态迁移: {entry['status']} → {new_status}")
if new_status == "graveyard" and not extra.get("cause"):
raise ValueError("入墓园必须带 cause(死因,可检索防重复造轮)")
new_entry = {**entry, "status": new_status, **extra}
registry["factors"][name] = new_entry
return new_entry
def add_version(registry: dict[str, Any], name: str, commit: str,
params: dict[str, Any], effective_from: str) -> dict[str, Any]:
"""追加版本快照:上一版本的 effective_to 补为本版 effective_from."""
entry = registry["factors"].get(name)
if entry is None:
raise KeyError(f"因子未注册: {name}")
versions = [dict(v) for v in entry.get("versions", [])]
if versions:
versions[-1]["effective_to"] = effective_from
versions.append({"v": len(versions) + 1, "commit": commit,
"params": params, "effective_from": effective_from,
"effective_to": None})
new_entry = {**entry, "versions": versions}
registry["factors"][name] = new_entry
return new_entry
def effective_version(entry: dict[str, Any], on_date: str) -> dict[str, Any] | None:
"""按日期解析当时生效版本(回测/归因点时纪律)."""
for v in reversed(entry.get("versions", [])):
if v["effective_from"] <= on_date and (
v["effective_to"] is None or on_date < v["effective_to"]):
return v
return None
# sanguo_factor/version_registry.py 末尾追加(决议 J1:转移即事件)
def append_event(events_path: str, entry: dict[str, Any], old_status: str,
new_status: str, **extra: Any) -> dict[str, Any]:
"""转移事件 append-only JSONL——审计与下游跟进的原料(决议 J1)."""
from datetime import datetime
ev = {"ts": datetime.now().isoformat(timespec="seconds"),
"name": entry["name"], "old": old_status, "new": new_status, **extra}
os.makedirs(os.path.dirname(os.path.abspath(events_path)), exist_ok=True)
with open(events_path, "a", encoding="utf-8") as f:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
return ev
def _append_writeback_missed(events_path: str, hyp_id: str, name: str) -> None:
"""死亡回写未命中附记(P2-10):非转移事件,带 event 键与 old/new 转移事件区分."""
from datetime import datetime
ev = {"ts": datetime.now().isoformat(timespec="seconds"),
"event": "death_writeback_missed", "name": name, "hyp_id": hyp_id}
os.makedirs(os.path.dirname(os.path.abspath(events_path)), exist_ok=True)
with open(events_path, "a", encoding="utf-8") as f:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
def writeback_card_death(pipeline_db: str | None, entry: dict[str, Any],
cause: str | None,
events_path: str = "config/registry_events.jsonl") -> bool:
"""D5: 因子入墓园→回写假设卡片(graveyard+死因+因子名).
卡 id=entry.hypothesis 以 hyp- 开头才回写(旧 H- 编号非卡片,静默跳过);
pipeline_db 缺省走 env SANGUO_PIPELINE_DB→data/pipeline.db.失败不阻断
转移(只 stderr 告警)——注册表转移是正主,卡片回写是附属.
P2-10: 回写未命中不再零痕迹——库不在/卡不在库均 stderr+events 附记
death_writeback_missed(含卡 id+因子名);相对路径警告后 os.path.abspath
绝对化(不拒跑,落库位置明确);库不存在跳过回写不建空库
(pipeline_store._connect 首触自举会静默 CREATE,预检挡在连接前).
"""
hyp_id = str(entry.get("hypothesis") or "")
if not hyp_id.startswith("hyp-"):
return False
if pipeline_db is None:
pipeline_db = os.environ.get(
"SANGUO_PIPELINE_DB", os.path.join("data", "pipeline.db"))
if not os.path.isabs(pipeline_db):
abs_db = os.path.abspath(pipeline_db)
print(f"[registry] ⚠️ pipeline_db 相对路径 {pipeline_db} → 已绝对化 {abs_db}"
"(防错误 cwd 落错库)", file=sys.stderr)
pipeline_db = abs_db
name = str(entry.get("name") or "")
if not os.path.exists(pipeline_db):
print(f"[registry] ⚠️ 卡片库不存在: {pipeline_db},死亡回写跳过(不建空库)",
file=sys.stderr)
_append_writeback_missed(events_path, hyp_id, name)
return False
try:
from sanguo_portfolio import pipeline_store
result = pipeline_store.update_hypothesis_state(
pipeline_db, hyp_id, "graveyard",
death_reason=f"因子 {name} 入墓园: {cause or '未注死因'}",
factor_id=name)
except ValueError as e: # 卡片已终态(graveyard/promoted)等非法迁移
print(f"[registry] ⚠️ 卡片 {hyp_id} 已终态,回写跳过: {e}", file=sys.stderr)
return False
except Exception as e: # noqa: BLE001 回写失败不拖垮转移
print(f"[registry] ⚠️ 卡片回写失败({hyp_id}): {e}", file=sys.stderr)
return False
if result is None: # 卡不在库(update_hypothesis_state None 语义,P2-5 分因)
print(f"[registry] ⚠️ 卡片库无 {hyp_id},因子 {name} 死亡回写未命中,已附记 events",
file=sys.stderr)
_append_writeback_missed(events_path, hyp_id, name)
return False
return result
def render_promotion_issue(name: str, entry: dict[str, Any]) -> tuple[str, str]:
"""晋级转移→下游跟进 issue 模板(J1:衔接断裂的治法,模板必落盘)."""
title = f"[晋升] {name} → promoted:合成层跟进+策略生成待办"
body = (
f"因子 **{name}**(假设 {entry.get('hypothesis')})已晋级 promoted"
f"(promoted_at={entry.get('promoted_at')},晋级基准 t={entry.get('promotion_t')})。\n\n"
"下游待办(决议 J1,转移即事件):\n"
"1. 合成层:评估纳入/版本升级(等权宪法;升版本走影子对照+口令)\n"
"2. 策略工厂:factor_topn 模板型策略生成候选(走孵化梯)\n"
"证据: config/registry_events.jsonl + 月度批评报告\n")
return title, body
def open_gitea_issue(title: str, body: str, token: str,
base_url: str | None = None,
repo: str = "sanguo/sanguo_vnpy_v2") -> int | None:
"""Gitea API 开 issue(部署侧 env SANGUO_GITEA_TOKEN);失败返 None 不抛(降级=模板已落盘).
base_url 缺省取 env SANGUO_GITEA_BASE_URL(B' 双机: NAS=内网 IP,
VPS=FRP 127.0.0.1:13000)——与 strategy_registry.open_gitea_issue 同款.
"""
import urllib.request
if base_url is None:
base_url = os.environ.get("SANGUO_GITEA_BASE_URL", GITEA_BASE_URL)
url = f"{base_url}/api/v1/repos/{repo}/issues"
payload = json.dumps({"title": title, "body": body}).encode("utf-8")
req = urllib.request.Request(
url, data=payload, method="POST",
headers={"Authorization": f"token {token}",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))["number"]
except Exception:
return None
def main(argv: list[str] | None = None) -> int:
"""注册表 CLI(J1): `python -m sanguo_factor.version_registry transition NAME STATUS`."""
import argparse
ap = argparse.ArgumentParser(description="因子注册表 CLI(转移即事件,决议 J1)")
ap.add_argument("cmd", choices=["transition"])
ap.add_argument("name")
ap.add_argument("status")
ap.add_argument("--registry", default="config/factor_registry.yaml")
ap.add_argument("--events", default="config/registry_events.jsonl")
ap.add_argument("--cause", default=None)
ap.add_argument("--promoted-at", default=None)
ap.add_argument("--promotion-t", type=float, default=None)
ap.add_argument("--evidence-run", default=None)
ap.add_argument("--issue-dir", default="reports/registry_events")
ap.add_argument("--pipeline-db", default=None,
help="卡片回写库(D5;缺省 env SANGUO_PIPELINE_DB→data/pipeline.db)")
ap.add_argument("--gitea-open", action="store_true",
help="晋级时尝试调 Gitea API 开 issue(需 env SANGUO_GITEA_TOKEN)")
args = ap.parse_args(argv)
reg = load_registry(args.registry)
if args.name not in reg["factors"]:
print(f"[registry] 因子未注册: {args.name}", file=sys.stderr)
return 1
old = reg["factors"][args.name]["status"]
extra = {k: v for k, v in (("cause", args.cause),
("promoted_at", args.promoted_at),
("promotion_t", args.promotion_t),
("evidence_run", args.evidence_run))
if v is not None}
try:
entry = transition(reg, args.name, args.status, **extra)
except ValueError as e:
print(f"[registry] 拒绝: {e}", file=sys.stderr)
return 1
save_registry(args.registry, reg)
ev = append_event(args.events, entry, old, args.status)
print(f"[registry] {args.name}: {old} → {args.status} (event ts={ev['ts']})")
if args.status == "graveyard":
writeback_card_death(args.pipeline_db, entry, args.cause,
events_path=args.events)
if args.status == "promoted":
title, body = render_promotion_issue(args.name, entry)
os.makedirs(args.issue_dir, exist_ok=True)
p = os.path.join(args.issue_dir, f"{args.name}_promoted_issue.md")
with open(p, "w", encoding="utf-8") as f:
f.write(f"# {title}\n\n{body}\n")
print(f"[registry] issue 模板已落盘: {p}")
if args.gitea_open:
token = os.environ.get("SANGUO_GITEA_TOKEN")
if not token:
print("[registry] SANGUO_GITEA_TOKEN 未配置,跳过 API(模板已落盘)")
else:
n = open_gitea_issue(title, body, token)
print(f"[registry] Gitea issue #{n}" if n
else "[registry] Gitea API 失败(模板已落盘,人工贴)")
return 0
if __name__ == "__main__":
raise SystemExit(main())