From 3a938ecac8aa1c8a8fd5d5129a82cb1a88b47224 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 27 Sep 2026 18:06:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(portfolio):=20=E8=BF=90=E8=A1=8C=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=E7=A7=8D=E5=AD=90=E7=BC=BA=E5=A4=B1=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E8=87=AA=E5=8A=A8=20merge=E2=80=94=E2=80=94=E6=B2=BB?= =?UTF-8?q?=E6=8C=91=E6=88=98=E8=80=85=E5=8F=8C=E6=9C=BA=E6=BC=8F=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E7=BC=BA=E5=8F=A3=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-27 实证: 挑战者 szneul_eq_reb63 登记 config 种子后, 双机运行副本 (ensure_runtime_registry 首触才拷贝)永不知晓——周报 job 按运行副本点名 stage+paper_account_id, 挑战者被跳过=影子 A/B 评测腿(周报/孵化梯/毕业 gate)全盲。 治本: 首触整拷语义不变, 另加 _merge_seed_entries——种子有而运行副本缺的 条目(strategies/factors 双主顾)每次 touch 自动补入, 同名条目运行态为准 (防部署回滚转移结果), 无缺条目不写盘(幂等)。 config 种子挑战者 paper_account_id null→63(周报点名非空才算, 与双机手动补行一致)。 runbook 注册表行同步更语义(档随码走)。a 方案(双机手动补行)已先行落地救急。 Co-Authored-By: Claude Code --- config/strategy_registry.yaml | 2 +- docs/deployment/vps-production-runbook.md | 2 +- sanguo_portfolio/strategy_registry.py | 37 ++++++++++++- tests/portfolio/test_strategy_registry.py | 65 +++++++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/config/strategy_registry.yaml b/config/strategy_registry.yaml index 88943817..3bffbbd9 100644 --- a/config/strategy_registry.yaml +++ b/config/strategy_registry.yaml @@ -29,4 +29,4 @@ strategies: capital: {mode: 固定金额, value: 50000, at: '2026-09-28'} retired_cause: null backtest_run: null - paper_account_id: null + paper_account_id: 63 diff --git a/docs/deployment/vps-production-runbook.md b/docs/deployment/vps-production-runbook.md index e42398dc..82e358f8 100644 --- a/docs/deployment/vps-production-runbook.md +++ b/docs/deployment/vps-production-runbook.md @@ -278,7 +278,7 @@ ssh 49.232.102.198 "powershell -Command \"Get-Content C:\sanguo_bigqmt\qmt_relog | 研判卡回放 | CLI `PYTHONPATH=. python -m sanguo_factor.verdict_replay --as-of <同月度批评>`(月度批评链后跟一条,同机同 as-of;10-04 首班双机首跑) | 产物 `/verdict_replay.json`(派生件可重建);触发点普查+七档事后成绩(近似估算,spec §4.7 P3 第一刀);`GET /pipeline/research-card` 读月报+此件出卡 | | 研判裁决留痕 | env `SANGUO_VERDICT_RECORDS`(缺省 `data/verdict_records.jsonl`) | `POST /pipeline/research-card/verdict` 落档(月键一条,重复 409);七档全人工触点零自动执行(决议 C) | | 偏差日报(IS四分解) | CLI `PYTHONPATH=. python -m sanguo_portfolio.ishortfall_daily --as-of <今日>`(每日 20:45 后、周报 job 之后跟跑,避开 20:30 paper 回放与 21:00 xt_eod;价格源=`scripts/pipeline/is_price_source_vps.py` 桥 xtdata shim 分钟 bar+dbbardata 日线;首跑/部署后必跑其 `probe()` fail-loud 自检) | 产物 `data/ishortfall/.json`+pipeline_store `is_daily` 表(`GET /pipeline/attribution` IS 四行+价格移动参考行数据源=`latest_is()`);事件表 `order_events` 主库幂等自建(FIX 七态 append-only,老 backlog 委托入库就此闭环);回放=`--from 2026-09-14 --to <日>` 出首份真日报(幂等可重跑);⚠️回执缺供的'filled'终态单 cum 按快照兜不住→整腿按未全额计机会成本(identity_resid 非零可见,缺数非错算);spec §4.7 P3 第二刀 | -| 策略注册表 | **运行副本=`data/strategy_registry.yaml`+`data/strategy_events.jsonl`**(env `SANGUO_STRATEGY_REGISTRY`/`SANGUO_STRATEGY_EVENTS` 名不变);git 种子=`config/strategy_registry.yaml` **首触自举**(运行副本缺失时从种子拷贝) | promote.sh 每次 push 都 rsync `config/`——运行期转移(毕业/退回/延长)若写 config/ 会被下次部署静默回滚,故运行态一律落 `data/`(部署不覆盖);因子 verdict 端点同款:`data/factor_registry.yaml`+`data/registry_events.jsonl`(种子 `config/factor_registry.yaml`);**跨机同步=人工 git 收编**(运行副本改动抄回 config/ 种子后 commit;转移事件同时落 `reports/registry_events/` 毕业模板,防丢) | +| 策略注册表 | **运行副本=`data/strategy_registry.yaml`+`data/strategy_events.jsonl`**(env `SANGUO_STRATEGY_REGISTRY`/`SANGUO_STRATEGY_EVENTS` 名不变);git 种子=`config/strategy_registry.yaml` **首触自举+缺失条目自动 merge**(运行副本缺失时从种子拷贝;已存在时种子新登记条目由 `ensure_runtime_registry` 每次 touch 自动补入,**同名条目运行态为准**——2026-09-27 挑战者双机漏登记、周报点名跳过实证后治本) | promote.sh 每次 push 都 rsync `config/`——运行期转移(毕业/退回/延长)若写 config/ 会被下次部署静默回滚,故运行态一律落 `data/`(部署不覆盖;merge 只补缺、绝不覆盖同名运行态);因子 verdict 端点同款:`data/factor_registry.yaml`+`data/registry_events.jsonl`(种子 `config/factor_registry.yaml`,`factors` 容器同走 merge);**跨机同步=人工 git 收编**(运行副本改动抄回 config/ 种子后 commit;转移事件同时落 `reports/registry_events/` 毕业模板,防丢) | | 存量补录 | `PYTHONPATH=. python -m sanguo_portfolio.strategy_registry register --hypothesis --composite-version --backfilled [--paper-account-id ] [--stage ]` | 逐条补录在跑实例(gate 快照留空);**现在写 data/ 运行副本**(缺则从 config/ 种子自举;`--stage` 直落初档,如 `--stage live`);其余 CLI 子命令=`transition`(--stage/--gate-json/--capital-json/--cause)与 `extend-shadow`(--extra-weeks --note) | | 回测 gate 判定 | `PYTHONPATH=. python -m sanguo_portfolio.promotion_gate backtest --task-id --db <主库>`(或 `--sharpe X --max-dd Y`) | 打印 gate JSON,喂 `strategy_registry transition --gate-json` | | VPS 控制台 | schtask `sanguo-api`(开机自启,`run_api.ps1`→`run_web.py`→uvicorn 8000,vnpy.mysanguo.top 外网绑定) | B' 生产控制台;**09-25 勘误:本就常驻跑纯 API**(旧文「VPS 无常驻 web」过时);09-25 起挂前端 dist(`frontend/dist`,CI 只推 NAS 故 VPS 前端需手动 scp)+PASSPHRASE/GITEA_BASE_URL env 进 run_api.ps1;**改代码后须 `schtasks /end /tn sanguo-api && schtasks /run /tn sanguo-api` 重启加载**;env 优先级=SANGUO_DB_PATH env > config/backtest.yaml(yaml 里 db_path 是 NAS 容器路径,promote 种子直推,勿被误导) | diff --git a/sanguo_portfolio/strategy_registry.py b/sanguo_portfolio/strategy_registry.py index df6c0dac..6e6d6f63 100644 --- a/sanguo_portfolio/strategy_registry.py +++ b/sanguo_portfolio/strategy_registry.py @@ -54,8 +54,43 @@ def ensure_runtime_registry(path: str, seed_path: str = os.path.join( promote.sh 每次 push 都 rsync config/;运行期转移若写 config/ 会被下次部署 静默回滚——运行态一律落 data/ 首触从种子拷贝(M1 终审裁定). + 首触之外:种子新登记条目自动 merge 进运行副本,同名条目运行态为准(防部署 + 回滚转移结果)——治「config 登记了但运行副本永不知晓」缺口(2026-09-27 + 挑战者 szneul_eq_reb63 双机漏登记、周报点名跳过实证). """ - return ensure_runtime_file(path, seed_path) + ensure_runtime_file(path, seed_path) + _merge_seed_entries(path, seed_path) + return path + + +def _merge_seed_entries(path: str, seed_path: str) -> list[str]: + """种子有而运行副本缺的条目补入(strategies/factors 双主顾);同名不覆盖. + + 幂等:无缺条目不写盘。返回补入的条目名(空=无动作)。 + """ + if not os.path.exists(seed_path) or not os.path.exists(path): + return [] + with open(seed_path, encoding="utf-8") as f: + seed = yaml.safe_load(f) or {} + with open(path, encoding="utf-8") as f: + runtime = yaml.safe_load(f) or {} + if not isinstance(seed, dict) or not isinstance(runtime, dict): + return [] + added: list[str] = [] + for section in ("strategies", "factors"): + seed_sec = seed.get(section) + if not isinstance(seed_sec, dict): + continue + run_sec = runtime.setdefault(section, {}) + if not isinstance(run_sec, dict): + continue + for name, entry in seed_sec.items(): + if name not in run_sec and isinstance(entry, dict): + run_sec[name] = entry + added.append(name) + if added: + save_registry(path, runtime) + return added def load_registry(path: str) -> dict[str, Any]: diff --git a/tests/portfolio/test_strategy_registry.py b/tests/portfolio/test_strategy_registry.py index 6567e532..2231ced1 100644 --- a/tests/portfolio/test_strategy_registry.py +++ b/tests/portfolio/test_strategy_registry.py @@ -7,6 +7,7 @@ - 血统硬闸(J2 同构):hypothesis+composite_version 缺一不可 """ import os +from pathlib import Path import pytest import yaml @@ -192,3 +193,67 @@ def test_cli_register_stage(tmp_path, monkeypatch): "v1", "--stage", "live", "--registry", tmp_yaml]) assert rc == 0 assert load_registry(tmp_yaml)["strategies"]["x9"]["stage"] == "live" + + +# —— 种子缺失条目 merge(2026-09-27 挑战者双机漏登记实证,治「config 登记 +# 了但运行副本永不知晓」;首触整拷语义不变,同名条目运行态为准防部署回滚) —— + + +def _write_seed(tmp_path, strategies=None, factors=None): + seed = tmp_path / "seed.yaml" + doc = {} + if strategies is not None: + doc["strategies"] = strategies + if factors is not None: + doc["factors"] = factors + seed.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8") + return str(seed) + + +def test_ensure_runtime_first_touch_still_copies_seed(tmp_path): + seed = _write_seed(tmp_path, strategies={"a1": {"stage": "shadow"}}) + rt = str(tmp_path / "rt.yaml") + ensure_runtime_registry(rt, seed) + assert load_registry(rt)["strategies"]["a1"]["stage"] == "shadow" + + +def test_ensure_runtime_merges_seed_new_entry(tmp_path): + seed = _write_seed(tmp_path, strategies={ + "a1": {"stage": "shadow"}, "b2": {"stage": "shadow"}}) + rt = str(tmp_path / "rt.yaml") + save_registry(rt, {"strategies": {"a1": {"stage": "live"}}}) + ensure_runtime_registry(rt, seed) + got = load_registry(rt)["strategies"] + assert set(got) == {"a1", "b2"} # b2 补入,a1 保留 + + +def test_ensure_runtime_runtime_wins_on_same_name(tmp_path): + # 运行副本转移结果(live)不被种子旧档(shadow)回滚 + seed = _write_seed(tmp_path, strategies={"a1": {"stage": "shadow"}}) + rt = str(tmp_path / "rt.yaml") + save_registry(rt, {"strategies": {"a1": {"stage": "live", + "capital": {"value": 50000}}}}) + ensure_runtime_registry(rt, seed) + entry = load_registry(rt)["strategies"]["a1"] + assert entry["stage"] == "live" and entry["capital"]["value"] == 50000 + + +def test_ensure_runtime_merges_factor_section(tmp_path): + # factors 容器同款(version_registry 借用本件做自举) + seed = _write_seed(tmp_path, factors={"fa_x": {"status": "incubating"}}) + rt = str(tmp_path / "rt.yaml") + save_registry(rt, {"factors": {}}) + ensure_runtime_registry(rt, seed) + assert load_registry(rt)["factors"]["fa_x"]["status"] == "incubating" + + +def test_ensure_runtime_idempotent_no_rewrite(tmp_path): + seed = _write_seed(tmp_path, strategies={"a1": {"stage": "shadow"}}) + rt = str(tmp_path / "rt.yaml") + save_registry(rt, {"strategies": {"a1": {"stage": "live"}}}) + ensure_runtime_registry(rt, seed) + before = Path(rt).read_text(encoding="utf-8") + mtime_before = os.stat(rt).st_mtime_ns + ensure_runtime_registry(rt, seed) # 二跑无缺条目 + assert Path(rt).read_text(encoding="utf-8") == before + assert os.stat(rt).st_mtime_ns == mtime_before # 未触发写盘