317 lines
15 KiB
Python
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())
|