diff --git a/sanguo_api/routes_pipeline.py b/sanguo_api/routes_pipeline.py index 9ce58335..b566b8d1 100644 --- a/sanguo_api/routes_pipeline.py +++ b/sanguo_api/routes_pipeline.py @@ -437,6 +437,13 @@ def factors() -> dict: reg = vr.load_registry(ensure_runtime_registry( _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() + full_t: dict[str, Any] = ( + {n: (s or {}).get("tAll") + for n, s in (reports[0][2].get("factor_stats") or {}).items()} + if reports else {}) items: list[dict[str, Any]] = [] for name, e in reg["factors"].items(): if e.get("status") == "graveyard": @@ -453,7 +460,7 @@ def factors() -> dict: "hypothesis": e.get("hypothesis"), "description": e.get("description") or "", "icRecentT": t, # 最近批(12M 滚动窗)即近窗 t - "icFullT": None, # eval_store 每批单窗 t,无全史批数据源,如实 null + "icFullT": full_t.get(name), # 全期合成 t(月报 factor_stats.tAll) "promotedAtT": e.get("promotion_t") or None, "similarity": e.get("similarity"), # 疑似换皮提示(注册时落) "decayMonths": 0, @@ -1169,6 +1176,9 @@ async def _decompose_worker(hyp_id: str, config, job_id: str | None = None) -> d today = date.today().isoformat() for cand in result["passed"]: src = derive_source(cand["expression"]) # 已过门,必为 str + # 仅建条纪律(codex review):已存在条目(同名同假设幂等重注册) + # 不重写 similarity——与 description/origin 落盘纪律同款 + is_new = cand["name"] not in reg["factors"] vr.upsert_factor(reg, cand["name"], hypothesis=hyp_id, status="incubating", origin="decomposer", description=cand.get("description")) @@ -1180,14 +1190,15 @@ async def _decompose_worker(hyp_id: str, config, job_id: str | None = None) -> d # AST 防换皮提示(spec §4.2 2026-10-10):对既有池比对,≥0.6 存 # entry.similarity——硬门(dup_subtree)外的部分同构仍可注册, # 提示非硬拒,判定人做.池=注册表条目当前表达式(versions[-1]). - from sanguo_factor import expression_match as em - _cands = {n: str(((v.get("versions") or [{}])[-1] - .get("params") or {}).get("expression") or "") - for n, v in reg["factors"].items() - if n != cand["name"]} - _sim = em.top_similar(str(cand["expression"]), _cands) - if _sim: - reg["factors"][cand["name"]]["similarity"] = _sim + if is_new: + from sanguo_factor import expression_match as em + _cands = {n: str(((v.get("versions") or [{}])[-1] + .get("params") or {}).get("expression") or "") + for n, v in reg["factors"].items() + if n != cand["name"]} + _sim = em.top_similar(str(cand["expression"]), _cands) + if _sim: + reg["factors"][cand["name"]]["similarity"] = _sim registered.append({"name": cand["name"], "source": src, "expression": cand["expression"], "description": cand.get("description", ""), diff --git a/tests/api/test_routes_pipeline_factors.py b/tests/api/test_routes_pipeline_factors.py index 84d31bd7..dcca268b 100644 --- a/tests/api/test_routes_pipeline_factors.py +++ b/tests/api/test_routes_pipeline_factors.py @@ -155,3 +155,34 @@ def test_factor_detail_ic_stats(client, tmp_path, monkeypatch): monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(empty)) r2 = client.get(f"/api/v1/pipeline/factors/{name}/detail") assert r2.status_code == 200 and r2.json()["factor"]["icStats"] is None + + +def test_factors_list_ic_full_t_from_stats(client, tmp_path, monkeypatch): + """codex review:列表端点 icFullT 从最新报告 factor_stats.tAll 接真 + (spec 承诺 factors 端点全史 t 升级);无报告仍如实 None.""" + import json as _json + name, _ = _seed_factor_card(client, name="fa_x") + rep_dir = tmp_path / "reports" / "factor_monthly" + rep_dir.mkdir(parents=True) + doc = {"as_of": "2026-09-30", + "factor_stats": {"fa_x": {"icAll": 0.04, "tAll": 1.7321, + "positiveRatio": 1.0, + "byYear": {"2025": 0.05}}}} + (rep_dir / "nas_2026-09-30.json").write_text(_json.dumps(doc), "utf-8") + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) + items = client.get("/api/v1/pipeline/factors").json()["items"] + row = next(i for i in items if i["id"] == name) + assert row["icFullT"] == 1.7321 + # tAll=None(样本不足)如实 null,不造数 + doc["factor_stats"]["fa_x"]["tAll"] = None + (rep_dir / "nas_2026-09-30.json").write_text(_json.dumps(doc), "utf-8") + 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 + # 无报告:零读不崩,如实 null + empty = tmp_path / "reports_empty" + empty.mkdir() + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(empty)) + 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 diff --git a/tests/api/test_routes_pipeline_hypotheses.py b/tests/api/test_routes_pipeline_hypotheses.py index 23d1e36c..c7f6109f 100644 --- a/tests/api/test_routes_pipeline_hypotheses.py +++ b/tests/api/test_routes_pipeline_hypotheses.py @@ -295,6 +295,46 @@ class TestDecompose: from sanguo_api import routes_pipeline as rp assert isinstance(rp._DECOMPOSE_LOCK, type(threading.Lock())) assert isinstance(rp._GRADUATE_LOCK, type(threading.Lock())) + def test_decompose_similarity_only_on_new_entry(self, client, fake_llm, + tmp_path, monkeypatch): + """codex review 仅建条纪律:条目已存在时 upsert 不重写 similarity + (强化形态——同名异假设 upsert 会拒,同名同假设幂等返回,此测后者).""" + import yaml + # 先建卡拿 hyp id,再预置 registry:fa_dup 绑同一假设(幂等重注册路径) + hyp = _seed_card(client) + reg_path = tmp_path / "r_sim2.yaml" + monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg_path)) + reg = {"factors": { + "fa_old": {"name": "fa_old", "hypothesis": "H-1", + "status": "assessable", + "versions": [{"v": 1, "commit": "x", + "params": {"expression": + "ts_mean(close, 5) * turnover"}, + "effective_from": "2026-09-01"}]}, + "fa_dup": {"name": "fa_dup", "hypothesis": hyp, + "status": "incubating", + "versions": [{"v": 1, "commit": "x", + "params": {"expression": "close + open"}, + "effective_from": "2026-09-01"}]}}} + with open(reg_path, "w", encoding="utf-8") as f: + yaml.safe_dump(reg, f, allow_unicode=True) + # 绕开 LLM 与 name_taken 硬门,直接喂「已存在名」的 passed + import sanguo_api.routes_pipeline as rp + + async def fake_run(client_, card, **k): + return {"passed": [{"name": "fa_dup", + "expression": "ts_mean(close, 5) / volume", + "description": "同窗均值除量", + "justification": "j"}], + "failed": [], "rounds": 1} + + monkeypatch.setattr(rp, "_run_decompose", fake_run) + assert _wait_job(client, hyp)["status"] == "completed" + with open(reg_path, encoding="utf-8") as f: + reg2 = yaml.safe_load(f) + # 已存在条目:similarity 不写入(旧值 None 保持 None) + assert "similarity" not in reg2["factors"]["fa_dup"] + def test_decompose_register_similarity_hint(self, client, fake_llm, tmp_path, monkeypatch): """AST 防换皮提示(spec §4.2 2026-10-10):新因子注册时对既有池比对,