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 不算进来