diff --git a/frontend/src/views/pipeline/FactorDetail.vue b/frontend/src/views/pipeline/FactorDetail.vue index fd8288c0..32a7fefc 100644 --- a/frontend/src/views/pipeline/FactorDetail.vue +++ b/frontend/src/views/pipeline/FactorDetail.vue @@ -148,7 +148,7 @@ function goFactor(n: string): void { {{ detail.factor.icStats?.icAll != null ? detail.factor.icStats.icAll.toFixed(4) : '—' }}
正IC占比ⓘ {{ detail.factor.icStats?.positiveRatio != null ? `${(detail.factor.icStats.positiveRatio * 100).toFixed(0)}%` : '—' }}
-
末次月度批{{ detail.factor.lastEvalDate || '—' }}
+
末次评估批{{ detail.factor.lastEvalDate || '—' }}
版本v{{ detail.factor.version }}
{{ yearLine }}
diff --git a/sanguo_api/routes_pipeline.py b/sanguo_api/routes_pipeline.py index b779f6ff..4e388920 100644 --- a/sanguo_api/routes_pipeline.py +++ b/sanguo_api/routes_pipeline.py @@ -543,8 +543,9 @@ def factors() -> dict: _factor_registry_path(), os.path.join("config", "factor_registry.yaml"))) points, last_eval = _eval_latest_points() # 全史 t(=全期合成 t)从最新月报 factor_stats 一次性接真(spec §4.2 承诺 - # factors 端点升级;单次读取,勿循环内重读——codex review HIGH) - reports = _monthly_reports() + # factors 端点升级;单次读取,勿循环内重读——codex review HIGH); + # ∪daily/ 取新(2026-10-11 补丁) + reports = _monthly_reports(include_daily=True) full_t: dict[str, Any] = ( {n: (s or {}).get("tAll") for n, s in (reports[0][2].get("factor_stats") or {}).items()} @@ -599,26 +600,37 @@ def factors_graveyard() -> dict: _CROSS_DIFF_MAX = 0.05 # 双机对拍分歧阈(起步默认,首年校准;spec 对拍规则未定数) -def _monthly_reports() -> list[tuple[str, str, dict]]: +def _monthly_reports(include_daily: bool = False) -> list[tuple[str, str, dict]]: """monthly dir 全部报告 [(host, as_of, doc)];按 as_of 降序,坏文件跳过. 文件名={host}_{as_of}.json(monthly_review.main 落盘原文)。 + include_daily=True 时并入 daily/ 子目录件(2026-10-11 双层分工补丁: + 速览读最新——根层∪daily 取 as_of 新者);判定层(月末快照语义)不传参, + 仍只读根层,一行不动。 """ d = _monthly_dir() if not os.path.isdir(d): return [] out: list[tuple[str, str, dict]] = [] - for fn in os.listdir(d): - if not fn.endswith(".json"): - continue - host, _, as_of = fn[:-5].rpartition("_") - if not host or len(as_of) != 10 or as_of[4] != "-": - continue - try: - with open(os.path.join(d, fn), encoding="utf-8") as f: - out.append((host, as_of, json.load(f))) - except (OSError, json.JSONDecodeError): - continue + + def _scan(folder: str) -> None: + for fn in os.listdir(folder): + if not fn.endswith(".json"): + continue + host, _, as_of = fn[:-5].rpartition("_") + if not host or len(as_of) != 10 or as_of[4] != "-": + continue + try: + with open(os.path.join(folder, fn), encoding="utf-8") as f: + out.append((host, as_of, json.load(f))) + except (OSError, json.JSONDecodeError): + continue + + _scan(d) + if include_daily: + daily = os.path.join(d, "daily") + if os.path.isdir(daily): + _scan(daily) out.sort(key=lambda t: t[1], reverse=True) return out @@ -719,7 +731,7 @@ def factor_detail(name: str) -> dict: "rounds": job["rounds"], "hypId": hyp_id} break stats = None - reports = _monthly_reports() + reports = _monthly_reports(include_daily=True) if reports: stats = (reports[0][2].get("factor_stats") or {}).get(name) return {"factor": { diff --git a/tests/api/test_routes_pipeline_factors.py b/tests/api/test_routes_pipeline_factors.py index dcca268b..91cac15d 100644 --- a/tests/api/test_routes_pipeline_factors.py +++ b/tests/api/test_routes_pipeline_factors.py @@ -186,3 +186,64 @@ def test_factors_list_ic_full_t_from_stats(client, tmp_path, monkeypatch): items = client.get("/api/v1/pipeline/factors").json()["items"] row = next(i for i in items if i["id"] == name) assert row["icFullT"] is None + + +def test_factor_detail_ic_stats_prefers_newer_daily(client, tmp_path, monkeypatch): + """2026-10-11 双层分工补丁:icStats 读根层∪daily/ 取 as_of 新者—— + 根层 10-04 旧 schema 无 factor_stats,daily 件有真值→速览出真值.""" + import json as _json + name, _ = _seed_factor_card(client, name="fa_x") + rep_dir = tmp_path / "reports" / "factor_monthly" + rep_dir.mkdir(parents=True) + (rep_dir / "vps_2026-10-04.json").write_text( + _json.dumps({"as_of": "2026-10-04", "monthly_points": {}}), "utf-8") # 旧 schema + daily_dir = rep_dir / "daily" + daily_dir.mkdir() + (daily_dir / "vps_2026-10-09.json").write_text(_json.dumps({ + "as_of": "2026-10-09", + "factor_stats": {"fa_x": {"icAll": 0.03, "tAll": 2.5, + "positiveRatio": 0.8, + "byYear": {"2026": 0.03}}}}), "utf-8") + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) + got = client.get(f"/api/v1/pipeline/factors/{name}/detail").json()["factor"]["icStats"] + assert got and got["tAll"] == 2.5 # daily 件新者胜,不再被旧根层件挡住 + + +def test_factor_detail_ic_stats_root_newer_wins(client, tmp_path, monkeypatch): + """根层件更新时仍胜 daily(取 as_of 新者,不是 daily 优先).""" + import json as _json + name, _ = _seed_factor_card(client, name="fa_x") + rep_dir = tmp_path / "reports" / "factor_monthly" + rep_dir.mkdir(parents=True) + (rep_dir / "vps_2026-11-04.json").write_text(_json.dumps({ + "as_of": "2026-11-04", + "factor_stats": {"fa_x": {"icAll": 0.05, "tAll": 3.3, + "positiveRatio": 0.9, "byYear": {}}}}), "utf-8") + daily_dir = rep_dir / "daily" + daily_dir.mkdir() + (daily_dir / "vps_2026-10-20.json").write_text(_json.dumps({ + "as_of": "2026-10-20", + "factor_stats": {"fa_x": {"icAll": 0.01, "tAll": 1.1, + "positiveRatio": 0.5, "byYear": {}}}}), "utf-8") + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) + got = client.get(f"/api/v1/pipeline/factors/{name}/detail").json()["factor"]["icStats"] + assert got and got["tAll"] == 3.3 + + +def test_judgement_layer_ignores_daily_dir(client, tmp_path, monkeypatch): + """判定层不动:monthly-review 仍只读根层,daily 件不得泄漏进速览卡/对拍.""" + import json as _json + rep_dir = tmp_path / "reports" / "factor_monthly" + rep_dir.mkdir(parents=True) + (rep_dir / "vps_2026-10-04.json").write_text(_json.dumps({ + "as_of": "2026-10-04", "factors": {"fa_old": {"t": 1.0}}, + "collective": {"median": 2.1, "floor": 2.0}}), "utf-8") + daily_dir = rep_dir / "daily" + daily_dir.mkdir() + (daily_dir / "vps_2026-10-09.json").write_text(_json.dumps({ + "as_of": "2026-10-09", "factors": {"fa_new": {"t": 9.9}}, + "collective": {"median": 9.9, "floor": 2.0}}), "utf-8") + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) + got = client.get("/api/v1/pipeline/monthly-review").json() + assert got["runDate"] == "2026-10-04" # 仍是根层最新,不被 daily 顶掉 + assert got["factorsEvaluated"] == 1 # daily 的 fa_new 不算进来